diff --git a/app/Http/Controllers/Api/Tenant/EventController.php b/app/Http/Controllers/Api/Tenant/EventController.php
index c45029d..3aa6273 100644
--- a/app/Http/Controllers/Api/Tenant/EventController.php
+++ b/app/Http/Controllers/Api/Tenant/EventController.php
@@ -370,7 +370,7 @@ class EventController extends Controller
];
$taskSummary['pending'] = max(0, $taskSummary['total'] - $taskSummary['completed']);
- $translate = static function ($value, string $fallback = '') {
+ $translate = static function ($value, ?string $fallback = '') {
if (is_array($value)) {
$locale = app()->getLocale();
$candidates = array_filter([
diff --git a/app/Http/Resources/Tenant/PhotoResource.php b/app/Http/Resources/Tenant/PhotoResource.php
index 53a1d73..87086cb 100644
--- a/app/Http/Resources/Tenant/PhotoResource.php
+++ b/app/Http/Resources/Tenant/PhotoResource.php
@@ -16,6 +16,8 @@ class PhotoResource extends JsonResource
{
$tenantId = $request->attributes->get('tenant_id');
$showSensitive = $this->event->tenant_id === $tenantId;
+ $fullUrl = $this->getFullUrl();
+ $thumbnailUrl = $this->getThumbnailUrl();
return [
'id' => $this->id,
@@ -23,8 +25,8 @@ class PhotoResource extends JsonResource
'original_name' => $this->original_name,
'mime_type' => $this->mime_type,
'size' => (int) ($this->size ?? 0),
- 'url' => $showSensitive ? $this->getFullUrl() : $this->getThumbnailUrl(),
- 'thumbnail_url' => $this->getThumbnailUrl(),
+ 'url' => $showSensitive ? ($fullUrl ?? $thumbnailUrl) : ($thumbnailUrl ?? $fullUrl),
+ 'thumbnail_url' => $thumbnailUrl ?? $fullUrl,
'width' => $this->width,
'height' => $this->height,
'is_featured' => (bool) ($this->is_featured ?? false),
@@ -46,16 +48,24 @@ class PhotoResource extends JsonResource
/**
* Get full image URL
*/
- private function getFullUrl(): string
+ private function getFullUrl(): ?string
{
+ if (empty($this->filename)) {
+ return null;
+ }
+
return url("storage/events/{$this->event->slug}/photos/{$this->filename}");
}
/**
* Get thumbnail URL
*/
- private function getThumbnailUrl(): string
+ private function getThumbnailUrl(): ?string
{
+ if (empty($this->filename)) {
+ return null;
+ }
+
return url("storage/events/{$this->event->slug}/thumbnails/{$this->filename}");
}
}
diff --git a/resources/js/admin/api.ts b/resources/js/admin/api.ts
index 2cbb53f..971cb86 100644
--- a/resources/js/admin/api.ts
+++ b/resources/js/admin/api.ts
@@ -112,12 +112,12 @@ export type SendGuestNotificationPayload = {
export type TenantPhoto = {
id: number;
- filename: string;
+ filename: string | null;
original_name: string | null;
mime_type: string | null;
size: number;
- url: string;
- thumbnail_url: string;
+ url: string | null;
+ thumbnail_url: string | null;
status: string;
is_featured: boolean;
likes_count: number;
diff --git a/resources/js/admin/i18n/locales/de/common.json b/resources/js/admin/i18n/locales/de/common.json
index e29fb00..71becab 100644
--- a/resources/js/admin/i18n/locales/de/common.json
+++ b/resources/js/admin/i18n/locales/de/common.json
@@ -67,7 +67,7 @@
"photosBlocked": "Foto-Uploads sind blockiert. Bitte Paket upgraden oder erweitern.",
"guestsTitle": "Gäste-Limit",
"guestsWarning": "Nur noch {remaining} von {limit} Gästelinks verfügbar.",
- "guestsBlocked": "Gästeinladungen sind blockiert. Bitte Paket upgraden oder Kontingent freigeben.",
+ "guestsBlocked": "Alle Gästeplätze sind belegt. Upgrade euer Paket oder erweitert das Kontingent, um weitere Gäste zuzulassen.",
"galleryTitle": "Galerie",
"galleryWarningDay": "Galerie läuft in {days} Tag ab.",
"galleryWarningDays": "Galerie läuft in {days} Tagen ab.",
diff --git a/resources/js/admin/i18n/locales/en/common.json b/resources/js/admin/i18n/locales/en/common.json
index ab4d02b..8bc277c 100644
--- a/resources/js/admin/i18n/locales/en/common.json
+++ b/resources/js/admin/i18n/locales/en/common.json
@@ -67,7 +67,7 @@
"photosBlocked": "Photo uploads are blocked. Please upgrade or extend your package.",
"guestsTitle": "Guest limit",
"guestsWarning": "Only {remaining} of {limit} guest invites remaining.",
- "guestsBlocked": "Guest invites are blocked. Please upgrade your package.",
+ "guestsBlocked": "All guest slots are filled. Upgrade your package or extend the quota to welcome more guests.",
"galleryTitle": "Gallery",
"galleryWarningDay": "Gallery expires in {days} day.",
"galleryWarningDays": "Gallery expires in {days} days.",
diff --git a/resources/js/admin/onboarding/__tests__/store.test.tsx b/resources/js/admin/onboarding/__tests__/store.test.tsx
new file mode 100644
index 0000000..d2f5cae
--- /dev/null
+++ b/resources/js/admin/onboarding/__tests__/store.test.tsx
@@ -0,0 +1,47 @@
+import React from 'react';
+import { describe, expect, it, beforeEach, vi } from 'vitest';
+import { render, screen, waitFor } from '@testing-library/react';
+import { OnboardingProgressProvider, useOnboardingProgress } from '..';
+
+const fetchStatusMock = vi.fn();
+const trackMock = vi.fn();
+
+vi.mock('../../auth/context', () => ({
+ useAuth: () => ({ status: 'authenticated', user: { id: 1, role: 'owner' } }),
+}));
+
+vi.mock('../../api', () => ({
+ fetchOnboardingStatus: () => fetchStatusMock(),
+ trackOnboarding: () => trackMock(),
+}));
+
+function ProgressProbe() {
+ const { progress } = useOnboardingProgress();
+ return (
+
+ {progress.adminAppOpenedAt ?? 'null'}
+ {String(progress.inviteCreated)}
+
+ );
+}
+
+describe('OnboardingProgressProvider', () => {
+ beforeEach(() => {
+ fetchStatusMock.mockResolvedValue({ steps: undefined });
+ trackMock.mockResolvedValue(undefined);
+ window.localStorage.clear();
+ });
+
+ it('handles onboarding status responses without steps', async () => {
+ render(
+
+
+
+ );
+
+ await waitFor(() => expect(fetchStatusMock).toHaveBeenCalled());
+
+ expect(screen.getByTestId('admin-opened').textContent).toBeTruthy();
+ expect(screen.getByTestId('invite-created').textContent).toBe('false');
+ });
+});
diff --git a/resources/js/admin/onboarding/store.tsx b/resources/js/admin/onboarding/store.tsx
index 5745bbc..97d2919 100644
--- a/resources/js/admin/onboarding/store.tsx
+++ b/resources/js/admin/onboarding/store.tsx
@@ -105,14 +105,16 @@ export function OnboardingProgressProvider({ children }: { children: React.React
return;
}
+ const steps = status.steps ?? {};
+
setProgressState((prev) => {
const next: OnboardingProgress = {
...prev,
- adminAppOpenedAt: status.steps.admin_app_opened_at ?? prev.adminAppOpenedAt ?? null,
- eventCreated: Boolean(status.steps.event_created ?? prev.eventCreated),
- packageSelected: Boolean(status.steps.selected_packages ?? prev.packageSelected),
- inviteCreated: Boolean(status.steps.invite_created ?? prev.inviteCreated),
- brandingConfigured: Boolean(status.steps.branding_completed ?? prev.brandingConfigured),
+ adminAppOpenedAt: steps.admin_app_opened_at ?? prev.adminAppOpenedAt ?? null,
+ eventCreated: Boolean(steps.event_created ?? prev.eventCreated),
+ packageSelected: Boolean(steps.selected_packages ?? prev.packageSelected),
+ inviteCreated: Boolean(steps.invite_created ?? prev.inviteCreated),
+ brandingConfigured: Boolean(steps.branding_completed ?? prev.brandingConfigured),
};
writeStoredProgress(next);
@@ -120,7 +122,7 @@ export function OnboardingProgressProvider({ children }: { children: React.React
return next;
});
- if (!status.steps.admin_app_opened_at) {
+ if (!steps.admin_app_opened_at) {
const timestamp = new Date().toISOString();
trackOnboarding('admin_app_opened').catch(() => {});
setProgressState((prev) => {
diff --git a/resources/js/guest/demo/demoMode.ts b/resources/js/guest/demo/demoMode.ts
new file mode 100644
index 0000000..dfdd241
--- /dev/null
+++ b/resources/js/guest/demo/demoMode.ts
@@ -0,0 +1,213 @@
+import { demoFixtures, type DemoFixtures } from './fixtures';
+
+type DemoConfig = {
+ fixtures: DemoFixtures;
+};
+
+let enabled = false;
+let originalFetch: typeof window.fetch | null = null;
+const likeState = new Map();
+
+export function shouldEnableGuestDemoMode(): boolean {
+ if (typeof window === 'undefined') {
+ return false;
+ }
+ const params = new URLSearchParams(window.location.search);
+ if (params.get('demo') === '1') {
+ return true;
+ }
+ if ((window as any).__FOTOSPIEL_DEMO__ === true) {
+ return true;
+ }
+ const attr = document.documentElement?.dataset?.guestDemo;
+ return attr === 'true';
+}
+
+export function enableGuestDemoMode(config: DemoConfig = { fixtures: demoFixtures }): void {
+ if (typeof window === 'undefined' || enabled) {
+ return;
+ }
+
+ originalFetch = window.fetch.bind(window);
+ window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
+ const request = new Request(input, init);
+ const url = new URL(request.url, window.location.origin);
+
+ const response = handleDemoRequest(url, request, config.fixtures);
+ if (response) {
+ return response;
+ }
+
+ return originalFetch!(request);
+ };
+
+ enabled = true;
+ (window as any).__FOTOSPIEL_DEMO_ACTIVE__ = true;
+ notifyDemoToast();
+}
+
+function handleDemoRequest(url: URL, request: Request, fixtures: DemoFixtures): Promise | null {
+ if (!url.pathname.startsWith('/api/')) {
+ return null;
+ }
+
+ const eventMatch = url.pathname.match(/^\/api\/v1\/events\/([^/]+)(?:\/(.*))?/);
+ if (eventMatch) {
+ const token = decodeURIComponent(eventMatch[1]);
+ const remainder = eventMatch[2] ?? '';
+ if (token !== fixtures.token && token !== fixtures.event.slug && token !== 'demo') {
+ return null;
+ }
+ return Promise.resolve(handleDemoEventEndpoint(remainder, request, fixtures));
+ }
+
+ const galleryMatch = url.pathname.match(/^\/api\/v1\/gallery\/([^/]+)(?:\/(.*))?/);
+ if (galleryMatch) {
+ const token = decodeURIComponent(galleryMatch[1]);
+ if (token !== fixtures.token && token !== fixtures.event.slug && token !== 'demo') {
+ return null;
+ }
+ const resource = galleryMatch[2] ?? '';
+ if (!resource) {
+ return Promise.resolve(jsonResponse(fixtures.gallery.meta));
+ }
+ if (resource.startsWith('photos')) {
+ return Promise.resolve(
+ jsonResponse({ data: fixtures.gallery.photos, next_cursor: null }, { etag: '"demo-gallery"' })
+ );
+ }
+ }
+
+ if (url.pathname.startsWith('/api/v1/photo-shares/')) {
+ return Promise.resolve(jsonResponse(fixtures.share));
+ }
+
+ if (url.pathname.startsWith('/api/v1/photos/')) {
+ return Promise.resolve(handlePhotoAction(url, request, fixtures));
+ }
+
+ return null;
+}
+
+function handleDemoEventEndpoint(path: string, request: Request, fixtures: DemoFixtures): Response {
+ const [resource, ...rest] = path.split('/').filter(Boolean);
+ const method = request.method.toUpperCase();
+
+ switch (resource) {
+ case undefined:
+ return jsonResponse(fixtures.event);
+ case 'stats':
+ return jsonResponse(fixtures.stats);
+ case 'package':
+ return jsonResponse(fixtures.eventPackage);
+ case 'tasks':
+ if (method === 'GET') {
+ return jsonResponse(fixtures.tasks, { etag: '"demo-tasks"' });
+ }
+ return blockedResponse('Aufgaben können in der Demo nicht geändert werden.');
+ case 'photos':
+ if (method === 'GET') {
+ return jsonResponse({ data: fixtures.photos, latest_photo_at: fixtures.photos[0]?.created_at ?? null }, {
+ etag: '"demo-photos"',
+ });
+ }
+ if (method === 'POST') {
+ return blockedResponse('Uploads sind in der Demo deaktiviert.');
+ }
+ break;
+ case 'upload':
+ return blockedResponse('Uploads sind in der Demo deaktiviert.');
+ case 'achievements':
+ return jsonResponse(fixtures.achievements, { etag: '"demo-achievements"' });
+ case 'emotions':
+ return jsonResponse(fixtures.emotions, { etag: '"demo-emotions"' });
+ case 'notifications':
+ if (rest.length >= 2) {
+ return new Response(null, { status: 204 });
+ }
+ return jsonResponse({ data: fixtures.notifications, meta: { unread_count: 1 } }, { etag: '"demo-notifications"' });
+ case 'push-subscriptions':
+ return new Response(null, { status: 204 });
+ default:
+ break;
+ }
+
+ return jsonResponse({ demo: true });
+}
+
+function handlePhotoAction(url: URL, request: Request, fixtures: DemoFixtures): Response {
+ const pathname = url.pathname.replace('/api/v1/photos/', '');
+ const [photoIdPart, action] = pathname.split('/');
+ const photoId = Number(photoIdPart);
+ const targetPhoto = fixtures.photos.find((photo) => photo.id === photoId);
+
+ if (action === 'like') {
+ if (!targetPhoto) {
+ return new Response(JSON.stringify({ error: { message: 'Foto nicht gefunden' } }), { status: 404, headers: demoHeaders() });
+ }
+ const current = likeState.get(photoId) ?? targetPhoto.likes_count;
+ const next = current + 1;
+ likeState.set(photoId, next);
+ return jsonResponse({ likes_count: next });
+ }
+
+ if (action === 'share' && request.method.toUpperCase() === 'POST') {
+ return jsonResponse({ slug: fixtures.share.slug, url: `${window.location.origin}/share/${fixtures.share.slug}` });
+ }
+
+ return new Response(JSON.stringify({ error: { message: 'Demo-Endpunkt nicht verfügbar.' } }), {
+ status: 404,
+ headers: demoHeaders(),
+ });
+}
+
+function jsonResponse(data: unknown, options: { etag?: string } = {}): Response {
+ const headers = demoHeaders();
+ if (options.etag) {
+ headers.ETag = options.etag;
+ }
+ return new Response(JSON.stringify(data), {
+ status: 200,
+ headers,
+ });
+}
+
+function demoHeaders(): Record {
+ return {
+ 'Content-Type': 'application/json',
+ 'Cache-Control': 'no-store',
+ };
+}
+
+function blockedResponse(message: string): Response {
+ return new Response(
+ JSON.stringify({
+ error: {
+ code: 'demo_read_only',
+ message,
+ },
+ }),
+ {
+ status: 403,
+ headers: demoHeaders(),
+ }
+ );
+}
+
+export function isGuestDemoModeEnabled(): boolean {
+ return enabled;
+}
+
+function notifyDemoToast(): void {
+ if (typeof document === 'undefined') {
+ return;
+ }
+ try {
+ const detail = { type: 'info', text: 'Demo-Modus aktiv. Änderungen werden nicht gespeichert.' };
+ window.setTimeout(() => {
+ window.dispatchEvent(new CustomEvent('guest-toast', { detail }));
+ }, 0);
+ } catch {
+ // ignore
+ }
+}
diff --git a/resources/js/guest/demo/fixtures.ts b/resources/js/guest/demo/fixtures.ts
new file mode 100644
index 0000000..ce14212
--- /dev/null
+++ b/resources/js/guest/demo/fixtures.ts
@@ -0,0 +1,378 @@
+import type { AchievementsPayload } from '../services/achievementApi';
+import type { EventData, EventPackage, EventStats } from '../services/eventApi';
+import type { GalleryMetaResponse, GalleryPhotoResource } from '../services/galleryApi';
+
+export type DemoTask = {
+ id: number;
+ title: string;
+ description: string;
+ duration?: number;
+ emotion?: {
+ slug: string;
+ name: string;
+ emoji?: string;
+ } | null;
+ category?: string | null;
+};
+
+export type DemoPhoto = {
+ id: number;
+ url: string;
+ thumbnail_url: string;
+ created_at: string;
+ uploader_name: string;
+ likes_count: number;
+ task_id?: number | null;
+ task_title?: string | null;
+ ingest_source?: string | null;
+};
+
+export type DemoEmotion = {
+ id: number;
+ slug: string;
+ name: string;
+ emoji: string;
+ description?: string;
+};
+
+export type DemoNotification = {
+ id: number;
+ type: string;
+ title: string;
+ body: string;
+ status: 'new' | 'read' | 'dismissed';
+ created_at: string;
+ cta?: { label: string; href: string } | null;
+};
+
+export type DemoSharePayload = {
+ slug: string;
+ expires_at?: string;
+ photo: {
+ id: number;
+ title: string;
+ likes_count: number;
+ emotion?: { name: string; emoji: string } | null;
+ image_urls: { full: string; thumbnail: string };
+ };
+ event?: { id: number; name: string } | null;
+};
+
+export interface DemoFixtures {
+ token: string;
+ event: EventData;
+ stats: EventStats;
+ eventPackage: EventPackage;
+ tasks: DemoTask[];
+ photos: DemoPhoto[];
+ gallery: {
+ meta: GalleryMetaResponse;
+ photos: GalleryPhotoResource[];
+ };
+ achievements: AchievementsPayload;
+ emotions: DemoEmotion[];
+ notifications: DemoNotification[];
+ share: DemoSharePayload;
+}
+
+const now = () => new Date().toISOString();
+
+export const demoFixtures: DemoFixtures = {
+ token: 'demo',
+ event: {
+ id: 999,
+ slug: 'demo-wedding-2025',
+ name: 'Demo Wedding 2025',
+ default_locale: 'de',
+ created_at: '2025-01-10T12:00:00Z',
+ updated_at: now(),
+ branding: {
+ primary_color: '#FF6B6B',
+ secondary_color: '#FEB47B',
+ background_color: '#FFF7F5',
+ font_family: '"General Sans", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
+ logo_url: null,
+ },
+ join_token: 'demo',
+ type: {
+ slug: 'wedding',
+ name: 'Hochzeit',
+ icon: 'sparkles',
+ },
+ },
+ stats: {
+ onlineGuests: 42,
+ tasksSolved: 187,
+ latestPhotoAt: now(),
+ },
+ eventPackage: {
+ id: 501,
+ event_id: 999,
+ package_id: 301,
+ used_photos: 820,
+ used_guests: 95,
+ expires_at: '2025-12-31T23:59:59Z',
+ package: {
+ id: 301,
+ name: 'Soulmate Unlimited',
+ max_photos: 5000,
+ max_guests: 250,
+ gallery_days: 365,
+ },
+ limits: {
+ photos: {
+ limit: 5000,
+ used: 820,
+ remaining: 4180,
+ percentage: 0.164,
+ state: 'ok',
+ threshold_reached: null,
+ next_threshold: 0.5,
+ thresholds: [0.5, 0.8],
+ },
+ guests: {
+ limit: 250,
+ used: 95,
+ remaining: 155,
+ percentage: 0.38,
+ state: 'ok',
+ threshold_reached: null,
+ next_threshold: 0.6,
+ thresholds: [0.6, 0.9],
+ },
+ gallery: {
+ state: 'ok',
+ expires_at: '2025-12-31T23:59:59Z',
+ days_remaining: 320,
+ warning_thresholds: [30, 7],
+ warning_triggered: null,
+ warning_sent_at: null,
+ expired_notified_at: null,
+ },
+ can_upload_photos: true,
+ can_add_guests: true,
+ },
+ },
+ tasks: [
+ {
+ id: 101,
+ title: 'Der erste Blick',
+ description: 'Haltet den Moment fest, wenn sich das Paar zum ersten Mal sieht.',
+ duration: 4,
+ emotion: { slug: 'romance', name: 'Romantik', emoji: '💞' },
+ },
+ {
+ id: 102,
+ title: 'Dancefloor Close-Up',
+ description: 'Zoomt auf Hände, Schuhe oder Accessoires, die auf der Tanzfläche glänzen.',
+ duration: 3,
+ emotion: { slug: 'party', name: 'Party', emoji: '🎉' },
+ },
+ {
+ id: 103,
+ title: 'Tischgespräche',
+ description: 'Fotografiert zwei Personen, die heimlich lachen.',
+ duration: 2,
+ emotion: { slug: 'fun', name: 'Spaß', emoji: '😄' },
+ },
+ {
+ id: 104,
+ title: 'Team Selfie',
+ description: 'Mindestens fünf Gäste auf einem Selfie – Bonus für wilde Posen.',
+ duration: 5,
+ emotion: { slug: 'squad', name: 'Squad Goals', emoji: '🤳' },
+ },
+ ],
+ photos: [
+ {
+ id: 8801,
+ url: 'https://images.unsplash.com/photo-1520854223477-5e2c1a6610f0?auto=format&fit=crop&w=1600&q=80',
+ thumbnail_url: 'https://images.unsplash.com/photo-1520854223477-5e2c1a6610f0?auto=format&fit=crop&w=600&q=60',
+ created_at: '2025-05-10T18:45:00Z',
+ uploader_name: 'Lena',
+ likes_count: 24,
+ task_id: 101,
+ task_title: 'Der erste Blick',
+ ingest_source: 'guest',
+ },
+ {
+ id: 8802,
+ url: 'https://images.unsplash.com/photo-1502727135889-a63a201a02f9?auto=format&fit=crop&w=1600&q=80',
+ thumbnail_url: 'https://images.unsplash.com/photo-1502727135889-a63a201a02f9?auto=format&fit=crop&w=600&q=60',
+ created_at: '2025-05-10T19:12:00Z',
+ uploader_name: 'Nico',
+ likes_count: 31,
+ task_id: 102,
+ task_title: 'Dancefloor Close-Up',
+ ingest_source: 'guest',
+ },
+ {
+ id: 8803,
+ url: 'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=1600&q=80',
+ thumbnail_url: 'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=600&q=60',
+ created_at: '2025-05-10T19:40:00Z',
+ uploader_name: 'Aylin',
+ likes_count: 18,
+ task_id: 103,
+ task_title: 'Tischgespräche',
+ ingest_source: 'guest',
+ },
+ {
+ id: 8804,
+ url: 'https://images.unsplash.com/photo-1519741497674-611481863552?auto=format&fit=crop&w=1600&q=80',
+ thumbnail_url: 'https://images.unsplash.com/photo-1519741497674-611481863552?auto=format&fit=crop&w=600&q=60',
+ created_at: '2025-05-10T20:05:00Z',
+ uploader_name: 'Mara',
+ likes_count: 42,
+ task_id: 104,
+ task_title: 'Team Selfie',
+ ingest_source: 'guest',
+ },
+ ],
+ gallery: {
+ meta: {
+ event: {
+ id: 999,
+ name: 'Demo Wedding 2025',
+ slug: 'demo-wedding-2025',
+ description: 'Erlebe die Story eines Demo-Events – Fotos, Aufgaben und Emotionen live in der PWA.',
+ gallery_expires_at: '2025-12-31T23:59:59Z',
+ },
+ branding: {
+ primary_color: '#FF6B6B',
+ secondary_color: '#FEB47B',
+ background_color: '#FFF7F5',
+ },
+ },
+ photos: [
+ {
+ id: 9001,
+ thumbnail_url: 'https://images.unsplash.com/photo-1520854223477-5e2c1a6610f0?auto=format&fit=crop&w=400&q=60',
+ full_url: 'https://images.unsplash.com/photo-1520854223477-5e2c1a6610f0?auto=format&fit=crop&w=1600&q=80',
+ download_url: 'https://images.unsplash.com/photo-1520854223477-5e2c1a6610f0?auto=format&fit=crop&w=1600&q=80',
+ likes_count: 18,
+ guest_name: 'Leonie',
+ created_at: '2025-05-10T18:40:00Z',
+ },
+ {
+ id: 9002,
+ thumbnail_url: 'https://images.unsplash.com/photo-1502727135889-a63a201a02f9?auto=format&fit=crop&w=400&q=60',
+ full_url: 'https://images.unsplash.com/photo-1502727135889-a63a201a02f9?auto=format&fit=crop&w=1600&q=80',
+ download_url: 'https://images.unsplash.com/photo-1502727135889-a63a201a02f9?auto=format&fit=crop&w=1600&q=80',
+ likes_count: 25,
+ guest_name: 'Chris',
+ created_at: '2025-05-10T19:10:00Z',
+ },
+ ],
+ },
+ achievements: {
+ summary: {
+ totalPhotos: 820,
+ uniqueGuests: 96,
+ tasksSolved: 312,
+ likesTotal: 2100,
+ },
+ personal: {
+ guestName: 'Demo Gast',
+ photos: 12,
+ tasks: 5,
+ likes: 38,
+ badges: [
+ { id: 'starter', title: 'Warm-up', description: 'Deine ersten 3 Fotos', earned: true, progress: 3, target: 3 },
+ { id: 'mission', title: 'Mission Master', description: '5 Aufgaben geschafft', earned: true, progress: 5, target: 5 },
+ { id: 'marathon', title: 'Galerie-Profi', description: '50 Fotos hochladen', earned: false, progress: 12, target: 50 },
+ ],
+ },
+ leaderboards: {
+ uploads: [
+ { guest: 'Sven', photos: 35, likes: 120 },
+ { guest: 'Lena', photos: 28, likes: 140 },
+ { guest: 'Demo Gast', photos: 12, likes: 38 },
+ ],
+ likes: [
+ { guest: 'Mara', photos: 18, likes: 160 },
+ { guest: 'Noah', photos: 22, likes: 150 },
+ { guest: 'Sven', photos: 35, likes: 120 },
+ ],
+ },
+ highlights: {
+ topPhoto: {
+ photoId: 8802,
+ guest: 'Nico',
+ likes: 31,
+ task: 'Dancefloor Close-Up',
+ createdAt: '2025-05-10T19:12:00Z',
+ thumbnail: 'https://images.unsplash.com/photo-1502727135889-a63a201a02f9?auto=format&fit=crop&w=600&q=60',
+ },
+ trendingEmotion: {
+ emotionId: 4,
+ name: 'Party',
+ count: 58,
+ },
+ timeline: [
+ { date: '2025-05-08', photos: 120, guests: 25 },
+ { date: '2025-05-09', photos: 240, guests: 40 },
+ { date: '2025-05-10', photos: 460, guests: 55 },
+ ],
+ },
+ feed: [
+ {
+ photoId: 8804,
+ guest: 'Mara',
+ task: 'Team Selfie',
+ likes: 42,
+ createdAt: '2025-05-10T20:05:00Z',
+ thumbnail: 'https://images.unsplash.com/photo-1519741497674-611481863552?auto=format&fit=crop&w=400&q=60',
+ },
+ {
+ photoId: 8803,
+ guest: 'Aylin',
+ task: 'Tischgespräche',
+ likes: 18,
+ createdAt: '2025-05-10T19:40:00Z',
+ thumbnail: 'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=400&q=60',
+ },
+ ],
+ },
+ emotions: [
+ { id: 1, slug: 'romance', name: 'Romantik', emoji: '💞', description: 'Samtweiche Szenen & verliebte Blicke' },
+ { id: 2, slug: 'party', name: 'Party', emoji: '🎉', description: 'Alles, was knallt und funkelt' },
+ { id: 3, slug: 'calm', name: 'Ruhepause', emoji: '🌙', description: 'Leise Momente zum Durchatmen' },
+ { id: 4, slug: 'squad', name: 'Squad Goals', emoji: '🤳', description: 'Teams, Crews und wilde Selfies' },
+ ],
+ notifications: [
+ {
+ id: 1,
+ type: 'broadcast',
+ title: 'Mission-Alarm',
+ body: 'Neue Spotlight-Aufgabe verfügbar: „Dancefloor Close-Up“. Schau gleich vorbei!'
+ + ' ',
+ status: 'new',
+ created_at: now(),
+ cta: { label: 'Zur Aufgabe', href: '/e/demo/tasks' },
+ },
+ {
+ id: 2,
+ type: 'broadcast',
+ title: 'Galerie wächst',
+ body: '18 neue Uploads in den letzten 30 Minuten – helft mit beim Kuratieren!',
+ status: 'read',
+ created_at: '2025-05-10T19:50:00Z',
+ },
+ ],
+ share: {
+ slug: 'demo-share',
+ expires_at: null,
+ photo: {
+ id: 8801,
+ title: 'First Look',
+ likes_count: 24,
+ emotion: { name: 'Romantik', emoji: '💞' },
+ image_urls: {
+ full: 'https://images.unsplash.com/photo-1520854223477-5e2c1a6610f0?auto=format&fit=crop&w=1600&q=80',
+ thumbnail: 'https://images.unsplash.com/photo-1520854223477-5e2c1a6610f0?auto=format&fit=crop&w=600&q=60',
+ },
+ },
+ event: { id: 999, name: 'Demo Wedding 2025' },
+ },
+};
diff --git a/resources/js/guest/main.tsx b/resources/js/guest/main.tsx
index 33c487d..c454861 100644
--- a/resources/js/guest/main.tsx
+++ b/resources/js/guest/main.tsx
@@ -6,8 +6,12 @@ import '../../css/app.css';
import { initializeTheme } from '@/hooks/use-appearance';
import { ToastProvider } from './components/ToastHost';
import { LocaleProvider } from './i18n/LocaleContext';
+import { enableGuestDemoMode, shouldEnableGuestDemoMode } from './demo/demoMode';
initializeTheme();
+if (shouldEnableGuestDemoMode()) {
+ enableGuestDemoMode();
+}
const rootEl = document.getElementById('root')!;
// Register a minimal service worker for background sync (best-effort)
diff --git a/resources/js/pages/marketing/Demo.tsx b/resources/js/pages/marketing/Demo.tsx
index 921e23b..883daaf 100644
--- a/resources/js/pages/marketing/Demo.tsx
+++ b/resources/js/pages/marketing/Demo.tsx
@@ -18,7 +18,7 @@ interface DemoPageProps {
const DemoPage: React.FC = ({ demoToken }) => {
const { t } = useTranslation('marketing');
const { localizedPath } = useLocalizedRoutes();
- const embedUrl = demoToken ? `/e/${demoToken}` : null;
+ const embedUrl = demoToken ? `/e/${demoToken}` : '/e/demo?demo=1';
const demo = t('demo_page', { returnObjects: true }) as {
title: string;