Files
fotospiel-app/resources/js/guest/demo/demoMode.ts

221 lines
6.5 KiB
TypeScript

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>();
declare global {
interface Window {
__FOTOSPIEL_DEMO__?: boolean;
__FOTOSPIEL_DEMO_ACTIVE__?: boolean;
}
}
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.__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.__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
}
}