-
- {event?.name ?? t('galleryPage.hero.eventFallback', 'Event')}
-
+
+
+
+ {event?.name ?? t('galleryPage.hero.eventFallback', 'Event')}
+
+
{t('galleryPage.title', 'Gallery')}
diff --git a/resources/js/guest-v2/screens/TaskDetailScreen.tsx b/resources/js/guest-v2/screens/TaskDetailScreen.tsx
index 03d0e76..b144c71 100644
--- a/resources/js/guest-v2/screens/TaskDetailScreen.tsx
+++ b/resources/js/guest-v2/screens/TaskDetailScreen.tsx
@@ -10,7 +10,7 @@ import { fetchTasks, type TaskItem } from '../services/tasksApi';
import { useEventData } from '../context/EventDataContext';
import { buildEventPath } from '../lib/routes';
import { useTranslation } from '@/guest/i18n/useTranslation';
-import { useAppearance } from '@/hooks/use-appearance';
+import { useGuestThemeVariant } from '../lib/guestTheme';
function getTaskValue(task: TaskItem, key: string): string | undefined {
const value = task?.[key as keyof TaskItem];
@@ -39,8 +39,7 @@ export default function TaskDetailScreen() {
const { taskId } = useParams<{ taskId: string }>();
const { t, locale } = useTranslation();
const navigate = useNavigate();
- const { resolved } = useAppearance();
- const isDark = resolved === 'dark';
+ const { isDark } = useGuestThemeVariant();
const mutedText = isDark ? 'rgba(248, 250, 252, 0.7)' : 'rgba(15, 23, 42, 0.65)';
const [task, setTask] = React.useState
(null);
const [loading, setLoading] = React.useState(true);
diff --git a/resources/js/guest-v2/screens/TasksScreen.tsx b/resources/js/guest-v2/screens/TasksScreen.tsx
index 4904755..3beafad 100644
--- a/resources/js/guest-v2/screens/TasksScreen.tsx
+++ b/resources/js/guest-v2/screens/TasksScreen.tsx
@@ -9,7 +9,7 @@ import { useTranslation } from '@/guest/i18n/useTranslation';
import { useLocale } from '@/guest/i18n/LocaleContext';
import { fetchTasks } from '../services/tasksApi';
import { fetchEmotions } from '../services/emotionsApi';
-import { useAppearance } from '@/hooks/use-appearance';
+import { useGuestThemeVariant } from '../lib/guestTheme';
import { useNavigate } from 'react-router-dom';
import { useGuestTaskProgress } from '@/guest/hooks/useGuestTaskProgress';
@@ -27,8 +27,7 @@ export default function TasksScreen() {
const { locale } = useLocale();
const navigate = useNavigate();
const { completedCount } = useGuestTaskProgress(token ?? undefined);
- const { resolved } = useAppearance();
- const isDark = resolved === 'dark';
+ const { isDark } = useGuestThemeVariant();
const cardBorder = isDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(15, 23, 42, 0.12)';
const cardShadow = isDark ? '0 16px 32px rgba(2, 6, 23, 0.35)' : '0 14px 24px rgba(15, 23, 42, 0.12)';
const mutedButton = isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(15, 23, 42, 0.06)';
diff --git a/resources/js/guest-v2/screens/UploadQueueScreen.tsx b/resources/js/guest-v2/screens/UploadQueueScreen.tsx
index 51bd5b6..4c86c7d 100644
--- a/resources/js/guest-v2/screens/UploadQueueScreen.tsx
+++ b/resources/js/guest-v2/screens/UploadQueueScreen.tsx
@@ -7,7 +7,7 @@ import AppShell from '../components/AppShell';
import SurfaceCard from '../components/SurfaceCard';
import { useUploadQueue } from '../services/uploadApi';
import { useTranslation } from '@/guest/i18n/useTranslation';
-import { useAppearance } from '@/hooks/use-appearance';
+import { useGuestThemeVariant } from '../lib/guestTheme';
import { useLocale } from '@/guest/i18n/LocaleContext';
import { useEventData } from '../context/EventDataContext';
import { fetchPendingUploadsSummary, type PendingUpload } from '@/guest/services/pendingUploadsApi';
@@ -20,8 +20,7 @@ export default function UploadQueueScreen() {
const { token } = useEventData();
const { items, loading, retryAll, clearFinished, refresh } = useUploadQueue();
const [progress, setProgress] = React.useState({});
- const { resolved } = useAppearance();
- const isDark = resolved === 'dark';
+ const { isDark } = useGuestThemeVariant();
const mutedText = isDark ? 'rgba(248, 250, 252, 0.7)' : 'rgba(15, 23, 42, 0.65)';
const [pending, setPending] = React.useState([]);
const [pendingLoading, setPendingLoading] = React.useState(false);
diff --git a/resources/js/guest-v2/screens/UploadScreen.tsx b/resources/js/guest-v2/screens/UploadScreen.tsx
index 47306fa..22b3f76 100644
--- a/resources/js/guest-v2/screens/UploadScreen.tsx
+++ b/resources/js/guest-v2/screens/UploadScreen.tsx
@@ -7,7 +7,7 @@ import AppShell from '../components/AppShell';
import { useEventData } from '../context/EventDataContext';
import { useOptionalGuestIdentity } from '../context/GuestIdentityContext';
import { uploadPhoto, useUploadQueue } from '../services/uploadApi';
-import { useAppearance } from '@/hooks/use-appearance';
+import { useGuestThemeVariant } from '../lib/guestTheme';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useTranslation } from '@/guest/i18n/useTranslation';
import { useGuestTaskProgress } from '@/guest/hooks/useGuestTaskProgress';
@@ -15,6 +15,7 @@ import { fetchPendingUploadsSummary, type PendingUpload } from '@/guest/services
import { resolveUploadErrorDialog, type UploadErrorDialog } from '@/guest/lib/uploadErrorDialog';
import { fetchTasks, type TaskItem } from '../services/tasksApi';
import SurfaceCard from '../components/SurfaceCard';
+import { pushGuestToast } from '../lib/toast';
function getTaskValue(task: TaskItem, key: string): string | undefined {
const value = task?.[key as keyof TaskItem];
@@ -46,8 +47,7 @@ export default function UploadScreen() {
const [mirror, setMirror] = React.useState(true);
const [previewFile, setPreviewFile] = React.useState(null);
const [previewUrl, setPreviewUrl] = React.useState(null);
- const { resolved } = useAppearance();
- const isDark = resolved === 'dark';
+ const { isDark } = useGuestThemeVariant();
const cardBorder = isDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(15, 23, 42, 0.12)';
const cardShadow = isDark ? '0 18px 40px rgba(2, 6, 23, 0.4)' : '0 16px 30px rgba(15, 23, 42, 0.12)';
const iconColor = isDark ? '#F8FAFF' : '#0F172A';
@@ -186,6 +186,7 @@ export default function UploadScreen() {
for (const file of files) {
if (!navigator.onLine) {
await enqueueFile(file);
+ pushGuestToast({ text: t('uploadV2.toast.queued', 'Offline — added to upload queue.'), type: 'info' });
continue;
}
@@ -204,6 +205,7 @@ export default function UploadScreen() {
if (autoApprove) {
void triggerConfetti();
}
+ pushGuestToast({ text: t('uploadV2.toast.uploaded', 'Upload complete.'), type: 'success' });
void loadPending();
} catch (err) {
const uploadErr = err as { code?: string; meta?: Record };
diff --git a/resources/js/guest-v2/services/qrApi.ts b/resources/js/guest-v2/services/qrApi.ts
new file mode 100644
index 0000000..8988be1
--- /dev/null
+++ b/resources/js/guest-v2/services/qrApi.ts
@@ -0,0 +1,19 @@
+import { fetchJson } from './apiClient';
+
+export type EventQrCodePayload = {
+ url?: string | null;
+ qr_code_data_url?: string | null;
+};
+
+export async function fetchEventQrCode(eventToken: string, size = 240): Promise {
+ const params = new URLSearchParams();
+ if (Number.isFinite(size)) {
+ params.set('size', String(size));
+ }
+ const query = params.toString();
+ const url = `/api/v1/events/${encodeURIComponent(eventToken)}/qr${query ? `?${query}` : ''}`;
+
+ const response = await fetchJson(url, { noStore: true });
+
+ return response.data ?? { url: null, qr_code_data_url: null };
+}
diff --git a/resources/js/guest/context/EventBrandingContext.tsx b/resources/js/guest/context/EventBrandingContext.tsx
index 2535be5..1cd1bfa 100644
--- a/resources/js/guest/context/EventBrandingContext.tsx
+++ b/resources/js/guest/context/EventBrandingContext.tsx
@@ -131,6 +131,10 @@ function resolveThemeVariant(
? 'dark'
: null;
+ if (appearanceOverride) {
+ return appearanceOverride;
+ }
+
if (mode === 'dark') {
return 'dark';
}
@@ -139,10 +143,6 @@ function resolveThemeVariant(
return 'light';
}
- if (appearanceOverride) {
- return appearanceOverride;
- }
-
if (backgroundPrefers) {
return backgroundPrefers;
}
diff --git a/resources/js/guest/context/__tests__/EventBrandingContext.test.tsx b/resources/js/guest/context/__tests__/EventBrandingContext.test.tsx
index bc20522..8590203 100644
--- a/resources/js/guest/context/__tests__/EventBrandingContext.test.tsx
+++ b/resources/js/guest/context/__tests__/EventBrandingContext.test.tsx
@@ -70,4 +70,27 @@ describe('EventBrandingProvider', () => {
unmount();
});
+
+ it('prefers explicit appearance over branding mode', async () => {
+ localStorage.setItem('theme', 'light');
+ const darkBranding: EventBranding = {
+ ...sampleBranding,
+ mode: 'dark',
+ backgroundColor: '#0f172a',
+ };
+
+ const { unmount } = render(
+
+
+ Guest
+
+
+ );
+
+ await waitFor(() => {
+ expect(document.documentElement.classList.contains('dark')).toBe(false);
+ });
+
+ unmount();
+ });
});
diff --git a/resources/js/guest/i18n/messages.ts b/resources/js/guest/i18n/messages.ts
index 4405a6d..fefb9a5 100644
--- a/resources/js/guest/i18n/messages.ts
+++ b/resources/js/guest/i18n/messages.ts
@@ -561,6 +561,10 @@ export const messages: Record = {
flash: 'Blitz',
upload: 'Upload',
},
+ toast: {
+ queued: 'Offline – in der Warteschlange gespeichert.',
+ uploaded: 'Upload abgeschlossen.',
+ },
queue: {
summary: '{waiting} wartend, {sending} sendend',
uploading: 'Upload {name} · {progress}%',
@@ -1461,6 +1465,10 @@ export const messages: Record = {
flash: 'Flash',
upload: 'Upload',
},
+ toast: {
+ queued: 'Offline — added to upload queue.',
+ uploaded: 'Upload complete.',
+ },
queue: {
summary: '{waiting} waiting, {sending} sending',
uploading: 'Uploading {name} · {progress}%',
diff --git a/resources/js/guest/services/__tests__/photosApi.test.ts b/resources/js/guest/services/__tests__/photosApi.test.ts
new file mode 100644
index 0000000..e5ad32c
--- /dev/null
+++ b/resources/js/guest/services/__tests__/photosApi.test.ts
@@ -0,0 +1,33 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { createPhotoShareLink } from '../photosApi';
+
+const fetchMock = vi.fn();
+
+describe('photosApi', () => {
+ beforeEach(() => {
+ fetchMock.mockReset();
+ global.fetch = fetchMock as unknown as typeof fetch;
+ document.head.innerHTML = '';
+ localStorage.setItem('device-id', 'device-123');
+ });
+
+ it('creates a share link with CSRF headers', async () => {
+ fetchMock.mockResolvedValueOnce(
+ new Response(JSON.stringify({ slug: 'demo', url: 'http://example.com/share/demo' }), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ })
+ );
+
+ const payload = await createPhotoShareLink('token', 123);
+
+ expect(payload.url).toBe('http://example.com/share/demo');
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+
+ const [, options] = fetchMock.mock.calls[0];
+ const headers = options?.headers as Record;
+ expect(headers['X-CSRF-TOKEN']).toBe('csrf-token-demo');
+ expect(headers['X-XSRF-TOKEN']).toBe('csrf-token-demo');
+ expect(headers['X-Device-Id']).toBe('device-123');
+ });
+});
diff --git a/resources/js/guest/services/galleryApi.ts b/resources/js/guest/services/galleryApi.ts
index f7009e0..134f291 100644
--- a/resources/js/guest/services/galleryApi.ts
+++ b/resources/js/guest/services/galleryApi.ts
@@ -1,18 +1,7 @@
import type { LocaleCode } from '../i18n/messages';
+import type { EventBrandingPayload } from './eventApi';
-export interface GalleryBranding {
- primary_color: string;
- secondary_color: string;
- background_color: string;
- surface_color?: string;
- mode?: 'light' | 'dark' | 'auto';
- palette?: {
- primary?: string | null;
- secondary?: string | null;
- background?: string | null;
- surface?: string | null;
- } | null;
-}
+export type GalleryBranding = EventBrandingPayload;
export interface GalleryMetaResponse {
event: {
diff --git a/resources/js/guest/services/photosApi.ts b/resources/js/guest/services/photosApi.ts
index 15dca3e..d4520da 100644
--- a/resources/js/guest/services/photosApi.ts
+++ b/resources/js/guest/services/photosApi.ts
@@ -174,7 +174,7 @@ export async function uploadPhoto(
}
export async function createPhotoShareLink(eventToken: string, photoId: number): Promise<{ slug: string; url: string; expires_at?: string }> {
- const headers = getCsrfHeaders();
+ const headers = buildCsrfHeaders();
const res = await fetch(`/api/v1/events/${encodeURIComponent(eventToken)}/photos/${photoId}/share`, {
method: 'POST',
diff --git a/routes/api.php b/routes/api.php
index 2084051..55940d4 100644
--- a/routes/api.php
+++ b/routes/api.php
@@ -174,6 +174,7 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
Route::get('/events/{token}', [EventPublicController::class, 'event'])->name('events.show');
Route::get('/events/{token}/stats', [EventPublicController::class, 'stats'])->name('events.stats');
+ Route::get('/events/{token}/qr', [EventPublicController::class, 'qr'])->name('events.qr');
Route::get('/events/{token}/package', [EventPublicController::class, 'package'])->name('events.package');
Route::get('/events/{token}/notifications', [EventPublicController::class, 'notifications'])->name('events.notifications');
Route::post('/events/{token}/notifications/{notification}/read', [EventPublicController::class, 'markNotificationRead'])
diff --git a/tests/Feature/Api/Event/EventQrCodeTest.php b/tests/Feature/Api/Event/EventQrCodeTest.php
new file mode 100644
index 0000000..b0ba21e
--- /dev/null
+++ b/tests/Feature/Api/Event/EventQrCodeTest.php
@@ -0,0 +1,35 @@
+create([
+ 'status' => 'published',
+ ]);
+
+ $token = app(EventJoinTokenService::class)->createToken($event);
+
+ $response = $this->getJson("/api/v1/events/{$token->token}/qr?size=240");
+
+ $response->assertOk()
+ ->assertJsonStructure([
+ 'url',
+ 'qr_code_data_url',
+ ])
+ ->assertJsonPath('url', url('/e/'.$token->token));
+
+ $dataUrl = $response->json('qr_code_data_url');
+ $this->assertIsString($dataUrl);
+ $this->assertStringStartsWith('data:image/png;base64,', $dataUrl);
+ }
+}
diff --git a/tests/Feature/Api/PhotoShareLinkTest.php b/tests/Feature/Api/PhotoShareLinkTest.php
index 74f5119..59d31ed 100644
--- a/tests/Feature/Api/PhotoShareLinkTest.php
+++ b/tests/Feature/Api/PhotoShareLinkTest.php
@@ -82,5 +82,18 @@ class PhotoShareLinkTest extends TestCase
$response->assertJsonPath('photo.id', $photo->id);
$response->assertJsonPath('photo.image_urls.full', fn ($value) => str_contains($value, '/photo-shares/'));
$response->assertJsonPath('event.id', $event->id);
+ $response->assertJsonStructure([
+ 'branding' => [
+ 'primary_color',
+ 'secondary_color',
+ 'background_color',
+ 'logo_mode',
+ 'logo_value',
+ 'palette',
+ 'typography',
+ 'logo',
+ 'buttons',
+ ],
+ ]);
}
}