created a demo mode for the guest pwa
This commit is contained in:
@@ -370,7 +370,7 @@ class EventController extends Controller
|
|||||||
];
|
];
|
||||||
$taskSummary['pending'] = max(0, $taskSummary['total'] - $taskSummary['completed']);
|
$taskSummary['pending'] = max(0, $taskSummary['total'] - $taskSummary['completed']);
|
||||||
|
|
||||||
$translate = static function ($value, string $fallback = '') {
|
$translate = static function ($value, ?string $fallback = '') {
|
||||||
if (is_array($value)) {
|
if (is_array($value)) {
|
||||||
$locale = app()->getLocale();
|
$locale = app()->getLocale();
|
||||||
$candidates = array_filter([
|
$candidates = array_filter([
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ class PhotoResource extends JsonResource
|
|||||||
{
|
{
|
||||||
$tenantId = $request->attributes->get('tenant_id');
|
$tenantId = $request->attributes->get('tenant_id');
|
||||||
$showSensitive = $this->event->tenant_id === $tenantId;
|
$showSensitive = $this->event->tenant_id === $tenantId;
|
||||||
|
$fullUrl = $this->getFullUrl();
|
||||||
|
$thumbnailUrl = $this->getThumbnailUrl();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => $this->id,
|
'id' => $this->id,
|
||||||
@@ -23,8 +25,8 @@ class PhotoResource extends JsonResource
|
|||||||
'original_name' => $this->original_name,
|
'original_name' => $this->original_name,
|
||||||
'mime_type' => $this->mime_type,
|
'mime_type' => $this->mime_type,
|
||||||
'size' => (int) ($this->size ?? 0),
|
'size' => (int) ($this->size ?? 0),
|
||||||
'url' => $showSensitive ? $this->getFullUrl() : $this->getThumbnailUrl(),
|
'url' => $showSensitive ? ($fullUrl ?? $thumbnailUrl) : ($thumbnailUrl ?? $fullUrl),
|
||||||
'thumbnail_url' => $this->getThumbnailUrl(),
|
'thumbnail_url' => $thumbnailUrl ?? $fullUrl,
|
||||||
'width' => $this->width,
|
'width' => $this->width,
|
||||||
'height' => $this->height,
|
'height' => $this->height,
|
||||||
'is_featured' => (bool) ($this->is_featured ?? false),
|
'is_featured' => (bool) ($this->is_featured ?? false),
|
||||||
@@ -46,16 +48,24 @@ class PhotoResource extends JsonResource
|
|||||||
/**
|
/**
|
||||||
* Get full image URL
|
* 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}");
|
return url("storage/events/{$this->event->slug}/photos/{$this->filename}");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get thumbnail URL
|
* 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}");
|
return url("storage/events/{$this->event->slug}/thumbnails/{$this->filename}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,12 +112,12 @@ export type SendGuestNotificationPayload = {
|
|||||||
|
|
||||||
export type TenantPhoto = {
|
export type TenantPhoto = {
|
||||||
id: number;
|
id: number;
|
||||||
filename: string;
|
filename: string | null;
|
||||||
original_name: string | null;
|
original_name: string | null;
|
||||||
mime_type: string | null;
|
mime_type: string | null;
|
||||||
size: number;
|
size: number;
|
||||||
url: string;
|
url: string | null;
|
||||||
thumbnail_url: string;
|
thumbnail_url: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
is_featured: boolean;
|
is_featured: boolean;
|
||||||
likes_count: number;
|
likes_count: number;
|
||||||
|
|||||||
@@ -67,7 +67,7 @@
|
|||||||
"photosBlocked": "Foto-Uploads sind blockiert. Bitte Paket upgraden oder erweitern.",
|
"photosBlocked": "Foto-Uploads sind blockiert. Bitte Paket upgraden oder erweitern.",
|
||||||
"guestsTitle": "Gäste-Limit",
|
"guestsTitle": "Gäste-Limit",
|
||||||
"guestsWarning": "Nur noch {remaining} von {limit} Gästelinks verfügbar.",
|
"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",
|
"galleryTitle": "Galerie",
|
||||||
"galleryWarningDay": "Galerie läuft in {days} Tag ab.",
|
"galleryWarningDay": "Galerie läuft in {days} Tag ab.",
|
||||||
"galleryWarningDays": "Galerie läuft in {days} Tagen ab.",
|
"galleryWarningDays": "Galerie läuft in {days} Tagen ab.",
|
||||||
|
|||||||
@@ -67,7 +67,7 @@
|
|||||||
"photosBlocked": "Photo uploads are blocked. Please upgrade or extend your package.",
|
"photosBlocked": "Photo uploads are blocked. Please upgrade or extend your package.",
|
||||||
"guestsTitle": "Guest limit",
|
"guestsTitle": "Guest limit",
|
||||||
"guestsWarning": "Only {remaining} of {limit} guest invites remaining.",
|
"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",
|
"galleryTitle": "Gallery",
|
||||||
"galleryWarningDay": "Gallery expires in {days} day.",
|
"galleryWarningDay": "Gallery expires in {days} day.",
|
||||||
"galleryWarningDays": "Gallery expires in {days} days.",
|
"galleryWarningDays": "Gallery expires in {days} days.",
|
||||||
|
|||||||
47
resources/js/admin/onboarding/__tests__/store.test.tsx
Normal file
47
resources/js/admin/onboarding/__tests__/store.test.tsx
Normal file
@@ -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 (
|
||||||
|
<div>
|
||||||
|
<span data-testid="admin-opened">{progress.adminAppOpenedAt ?? 'null'}</span>
|
||||||
|
<span data-testid="invite-created">{String(progress.inviteCreated)}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('OnboardingProgressProvider', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
fetchStatusMock.mockResolvedValue({ steps: undefined });
|
||||||
|
trackMock.mockResolvedValue(undefined);
|
||||||
|
window.localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles onboarding status responses without steps', async () => {
|
||||||
|
render(
|
||||||
|
<OnboardingProgressProvider>
|
||||||
|
<ProgressProbe />
|
||||||
|
</OnboardingProgressProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(fetchStatusMock).toHaveBeenCalled());
|
||||||
|
|
||||||
|
expect(screen.getByTestId('admin-opened').textContent).toBeTruthy();
|
||||||
|
expect(screen.getByTestId('invite-created').textContent).toBe('false');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -105,14 +105,16 @@ export function OnboardingProgressProvider({ children }: { children: React.React
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const steps = status.steps ?? {};
|
||||||
|
|
||||||
setProgressState((prev) => {
|
setProgressState((prev) => {
|
||||||
const next: OnboardingProgress = {
|
const next: OnboardingProgress = {
|
||||||
...prev,
|
...prev,
|
||||||
adminAppOpenedAt: status.steps.admin_app_opened_at ?? prev.adminAppOpenedAt ?? null,
|
adminAppOpenedAt: steps.admin_app_opened_at ?? prev.adminAppOpenedAt ?? null,
|
||||||
eventCreated: Boolean(status.steps.event_created ?? prev.eventCreated),
|
eventCreated: Boolean(steps.event_created ?? prev.eventCreated),
|
||||||
packageSelected: Boolean(status.steps.selected_packages ?? prev.packageSelected),
|
packageSelected: Boolean(steps.selected_packages ?? prev.packageSelected),
|
||||||
inviteCreated: Boolean(status.steps.invite_created ?? prev.inviteCreated),
|
inviteCreated: Boolean(steps.invite_created ?? prev.inviteCreated),
|
||||||
brandingConfigured: Boolean(status.steps.branding_completed ?? prev.brandingConfigured),
|
brandingConfigured: Boolean(steps.branding_completed ?? prev.brandingConfigured),
|
||||||
};
|
};
|
||||||
|
|
||||||
writeStoredProgress(next);
|
writeStoredProgress(next);
|
||||||
@@ -120,7 +122,7 @@ export function OnboardingProgressProvider({ children }: { children: React.React
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!status.steps.admin_app_opened_at) {
|
if (!steps.admin_app_opened_at) {
|
||||||
const timestamp = new Date().toISOString();
|
const timestamp = new Date().toISOString();
|
||||||
trackOnboarding('admin_app_opened').catch(() => {});
|
trackOnboarding('admin_app_opened').catch(() => {});
|
||||||
setProgressState((prev) => {
|
setProgressState((prev) => {
|
||||||
|
|||||||
213
resources/js/guest/demo/demoMode.ts
Normal file
213
resources/js/guest/demo/demoMode.ts
Normal file
@@ -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<number, number>();
|
||||||
|
|
||||||
|
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<Response> | 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<string, string> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
378
resources/js/guest/demo/fixtures.ts
Normal file
378
resources/js/guest/demo/fixtures.ts
Normal file
@@ -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' },
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -6,8 +6,12 @@ import '../../css/app.css';
|
|||||||
import { initializeTheme } from '@/hooks/use-appearance';
|
import { initializeTheme } from '@/hooks/use-appearance';
|
||||||
import { ToastProvider } from './components/ToastHost';
|
import { ToastProvider } from './components/ToastHost';
|
||||||
import { LocaleProvider } from './i18n/LocaleContext';
|
import { LocaleProvider } from './i18n/LocaleContext';
|
||||||
|
import { enableGuestDemoMode, shouldEnableGuestDemoMode } from './demo/demoMode';
|
||||||
|
|
||||||
initializeTheme();
|
initializeTheme();
|
||||||
|
if (shouldEnableGuestDemoMode()) {
|
||||||
|
enableGuestDemoMode();
|
||||||
|
}
|
||||||
const rootEl = document.getElementById('root')!;
|
const rootEl = document.getElementById('root')!;
|
||||||
|
|
||||||
// Register a minimal service worker for background sync (best-effort)
|
// Register a minimal service worker for background sync (best-effort)
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ interface DemoPageProps {
|
|||||||
const DemoPage: React.FC<DemoPageProps> = ({ demoToken }) => {
|
const DemoPage: React.FC<DemoPageProps> = ({ demoToken }) => {
|
||||||
const { t } = useTranslation('marketing');
|
const { t } = useTranslation('marketing');
|
||||||
const { localizedPath } = useLocalizedRoutes();
|
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 {
|
const demo = t('demo_page', { returnObjects: true }) as {
|
||||||
title: string;
|
title: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user