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;