refactor(guest): retire legacy guest app and move shared modules
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled

This commit is contained in:
Codex Agent
2026-02-06 08:42:53 +01:00
parent b14435df8b
commit 0a08f2704f
191 changed files with 243 additions and 12631 deletions

View File

@@ -0,0 +1,69 @@
import React from 'react';
import type { LiveShowBackgroundMode, LiveShowPhoto } from '../services/liveShowApi';
function resolvePhotoUrl(photo?: LiveShowPhoto | null): string | null {
if (!photo) {
return null;
}
return photo.full_url || photo.thumb_url || null;
}
function resolveBlurAmount(intensity: number): number {
const safe = Number.isFinite(intensity) ? intensity : 70;
return 28 + Math.min(60, Math.max(0, safe)) * 0.45;
}
export default function LiveShowBackdrop({
mode,
photo,
intensity,
}: {
mode: LiveShowBackgroundMode;
photo?: LiveShowPhoto | null;
intensity: number;
}) {
const photoUrl = resolvePhotoUrl(photo);
const blurAmount = resolveBlurAmount(intensity);
const fallbackMode = mode === 'blur_last' && !photoUrl ? 'gradient' : mode;
if (fallbackMode === 'solid') {
return (
<div
className="pointer-events-none absolute inset-0 z-0"
style={{ backgroundColor: 'rgb(8, 10, 16)' }}
/>
);
}
if (fallbackMode === 'gradient') {
return <div className="pointer-events-none absolute inset-0 z-0 bg-aurora-enhanced" />;
}
if (fallbackMode === 'brand') {
return (
<div
className="pointer-events-none absolute inset-0 z-0"
style={{
backgroundImage:
'radial-gradient(120% 120% at 15% 15%, var(--guest-primary) 0%, rgba(0,0,0,0.8) 55%), radial-gradient(120% 120% at 85% 20%, var(--guest-secondary) 0%, transparent 60%)',
backgroundColor: 'rgb(5, 8, 16)',
}}
/>
);
}
return (
<div className="pointer-events-none absolute inset-0 z-0">
<div
className="absolute inset-0 scale-110"
style={{
backgroundImage: photoUrl ? `url(${photoUrl})` : undefined,
backgroundSize: 'cover',
backgroundPosition: 'center',
filter: `blur(${blurAmount}px) saturate(1.15)`,
}}
/>
<div className="absolute inset-0 bg-black/60" />
</div>
);
}

View File

@@ -0,0 +1,80 @@
import React from 'react';
import type { LiveShowLayoutMode, LiveShowPhoto } from '../services/liveShowApi';
const BASE_TILE =
'relative overflow-hidden rounded-[28px] bg-black/70 shadow-[0_24px_70px_rgba(0,0,0,0.55)]';
function PhotoTile({
photo,
fit,
label,
className = '',
}: {
photo: LiveShowPhoto;
fit: 'cover' | 'contain';
label: string;
className?: string;
}) {
const src = photo.full_url || photo.thumb_url || '';
return (
<div className={`${BASE_TILE} ${className}`.trim()}>
{src ? (
<img
src={src}
alt={label}
className={`h-full w-full object-${fit} object-center`}
loading="eager"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-sm text-white/60">
{label}
</div>
)}
</div>
);
}
export default function LiveShowStage({
layout,
photos,
title,
}: {
layout: LiveShowLayoutMode;
photos: LiveShowPhoto[];
title: string;
}) {
if (photos.length === 0) {
return null;
}
if (layout === 'single') {
return (
<div className="flex h-full w-full items-center justify-center px-6 py-12">
<PhotoTile
photo={photos[0]}
fit="contain"
label={title}
className="h-[62vh] w-full max-w-[1200px] sm:h-[72vh]"
/>
</div>
);
}
if (layout === 'split') {
return (
<div className="grid h-[72vh] w-full grid-cols-1 gap-6 px-6 py-12 lg:grid-cols-2">
{photos.slice(0, 2).map((photo) => (
<PhotoTile key={photo.id} photo={photo} fit="cover" label={title} className="h-full w-full" />
))}
</div>
);
}
return (
<div className="grid h-[72vh] w-full grid-cols-2 grid-rows-2 gap-5 px-6 py-12">
{photos.slice(0, 4).map((photo) => (
<PhotoTile key={photo.id} photo={photo} fit="cover" label={title} className="h-full w-full" />
))}
</div>
);
}

View File

@@ -0,0 +1,160 @@
import React from 'react';
import { ArrowDown, Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { triggerHaptic } from '../lib/haptics';
const MAX_PULL = 96;
const TRIGGER_PULL = 72;
const DAMPING = 0.55;
type PullToRefreshProps = {
onRefresh: () => Promise<void> | void;
disabled?: boolean;
className?: string;
pullLabel?: string;
releaseLabel?: string;
refreshingLabel?: string;
children: React.ReactNode;
};
export default function PullToRefresh({
onRefresh,
disabled = false,
className,
pullLabel = 'Pull to refresh',
releaseLabel = 'Release to refresh',
refreshingLabel = 'Refreshing…',
children,
}: PullToRefreshProps) {
const containerRef = React.useRef<HTMLDivElement | null>(null);
const startYRef = React.useRef<number | null>(null);
const pullDistanceRef = React.useRef(0);
const readyRef = React.useRef(false);
const [pullDistance, setPullDistance] = React.useState(0);
const [dragging, setDragging] = React.useState(false);
const [refreshing, setRefreshing] = React.useState(false);
const updatePull = React.useCallback((value: number) => {
pullDistanceRef.current = value;
setPullDistance(value);
}, []);
React.useEffect(() => {
const container = containerRef.current;
if (!container || disabled) {
return;
}
const handleStart = (event: TouchEvent) => {
if (refreshing || window.scrollY > 0) {
return;
}
startYRef.current = event.touches[0]?.clientY ?? null;
setDragging(true);
};
const handleMove = (event: TouchEvent) => {
if (refreshing || startYRef.current === null) {
return;
}
if (window.scrollY > 0) {
startYRef.current = null;
updatePull(0);
setDragging(false);
return;
}
const currentY = event.touches[0]?.clientY ?? 0;
const delta = currentY - startYRef.current;
if (delta <= 0) {
updatePull(0);
return;
}
event.preventDefault();
const next = Math.min(MAX_PULL, delta * DAMPING);
updatePull(next);
const isReady = next >= TRIGGER_PULL;
if (isReady && !readyRef.current) {
readyRef.current = true;
triggerHaptic('selection');
} else if (!isReady && readyRef.current) {
readyRef.current = false;
}
};
const handleEnd = async () => {
if (startYRef.current === null) {
return;
}
startYRef.current = null;
setDragging(false);
readyRef.current = false;
if (pullDistanceRef.current >= TRIGGER_PULL) {
triggerHaptic('medium');
setRefreshing(true);
updatePull(TRIGGER_PULL);
try {
await onRefresh();
} finally {
setRefreshing(false);
updatePull(0);
}
return;
}
updatePull(0);
};
container.addEventListener('touchstart', handleStart, { passive: true });
container.addEventListener('touchmove', handleMove, { passive: false });
container.addEventListener('touchend', handleEnd);
container.addEventListener('touchcancel', handleEnd);
return () => {
container.removeEventListener('touchstart', handleStart);
container.removeEventListener('touchmove', handleMove);
container.removeEventListener('touchend', handleEnd);
container.removeEventListener('touchcancel', handleEnd);
};
}, [disabled, onRefresh, refreshing, updatePull]);
const progress = Math.min(pullDistance / TRIGGER_PULL, 1);
const ready = pullDistance >= TRIGGER_PULL;
const indicatorLabel = refreshing
? refreshingLabel
: ready
? releaseLabel
: pullLabel;
return (
<div ref={containerRef} className={cn('relative', className)}>
<div
className="pointer-events-none absolute left-0 right-0 top-2 flex h-10 items-center justify-center"
style={{
opacity: progress,
transform: `translateY(${Math.min(pullDistance, TRIGGER_PULL) - 48}px)`,
transition: dragging ? 'none' : 'transform 200ms ease-out, opacity 200ms ease-out',
}}
>
<div className="flex items-center gap-2 rounded-full border border-white/30 bg-white/90 px-3 py-1 text-xs font-semibold text-slate-700 shadow-sm dark:border-white/10 dark:bg-slate-900/80 dark:text-slate-100">
{refreshing ? (
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
) : (
<ArrowDown
className={cn('h-4 w-4 transition-transform duration-200', ready && 'rotate-180')}
aria-hidden
/>
)}
<span>{indicatorLabel}</span>
</div>
</div>
<div
className={cn('will-change-transform', !dragging && 'transition-transform duration-200 ease-out')}
style={{ transform: `translateY(${pullDistance}px)` }}
>
{children}
</div>
</div>
);
}

View File

@@ -0,0 +1,79 @@
// @ts-nocheck
import React from 'react';
type ToastAction = { label: string; onClick: () => void };
type Toast = {
id: number;
text: string;
type?: 'success' | 'error' | 'info';
action?: ToastAction;
durationMs?: number;
};
const Ctx = React.createContext<{ push: (t: Omit<Toast,'id'>) => void } | null>(null);
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [list, setList] = React.useState<Toast[]>([]);
const push = React.useCallback((t: Omit<Toast,'id'>) => {
const id = Date.now() + Math.random();
const durationMs = t.durationMs ?? 3000;
setList((arr) => [...arr, { id, ...t, durationMs }]);
if (durationMs > 0) {
setTimeout(() => setList((arr) => arr.filter((x) => x.id !== id)), durationMs);
}
}, []);
const dismiss = React.useCallback((id: number) => {
setList((arr) => arr.filter((x) => x.id !== id));
}, []);
const contextValue = React.useMemo(() => ({ push }), [push]);
React.useEffect(() => {
const onEvt = (e: CustomEvent<Omit<Toast, 'id'>>) => push(e.detail);
window.addEventListener('guest-toast', onEvt);
return () => window.removeEventListener('guest-toast', onEvt);
}, [push]);
return (
<Ctx.Provider value={contextValue}>
{children}
<div className="pointer-events-none fixed inset-x-0 bottom-4 z-50 flex justify-center px-4">
<div className="flex w-full max-w-sm flex-col gap-2">
{list.map((t) => (
<div
key={t.id}
className={`pointer-events-auto rounded-md border p-3 shadow-sm ${
t.type === 'error'
? 'border-red-300 bg-red-50 text-red-700'
: t.type === 'info'
? 'border-blue-300 bg-blue-50 text-blue-700'
: 'border-green-300 bg-green-50 text-green-700'
}`}
>
<div className="flex flex-wrap items-center justify-between gap-3">
<span className="text-sm">{t.text}</span>
{t.action ? (
<button
type="button"
className="pointer-events-auto rounded-full border border-current/30 px-3 py-1 text-xs font-semibold uppercase tracking-wide transition hover:border-current"
onClick={() => {
try {
t.action?.onClick();
} finally {
dismiss(t.id);
}
}}
>
{t.action.label}
</button>
) : null}
</div>
</div>
))}
</div>
</div>
</Ctx.Provider>
);
}
export function useToast() {
const ctx = React.useContext(Ctx);
if (!ctx) throw new Error('ToastProvider missing');
return ctx;
}

View File

@@ -0,0 +1,31 @@
import React from "react";
type Props = {
markdown?: string;
html?: string;
};
export function LegalMarkdown({ markdown = '', html }: Props) {
const derived = React.useMemo(() => {
if (html && html.trim().length > 0) {
return html;
}
const escaped = markdown
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
return escaped
.split(/\n{2,}/)
.map((block) => `<p>${block.replace(/\n/g, '<br/>')}</p>`)
.join('\n');
}, [markdown, html]);
return (
<div
className="prose prose-sm max-w-none dark:prose-invert [&_:where(p,ul,ol,li)]:text-foreground [&_:where(h1,h2,h3,h4,h5,h6)]:text-foreground"
dangerouslySetInnerHTML={{ __html: derived }}
/>
);
}

View File

@@ -0,0 +1,349 @@
import React, { createContext, useContext, useEffect, useMemo } from 'react';
import type { EventBranding } from '../types/event-branding';
import { useAppearance } from '@/hooks/use-appearance';
import { getContrastingTextColor, relativeLuminance } from '../lib/color';
type EventBrandingContextValue = {
branding: EventBranding;
isCustom: boolean;
};
export const DEFAULT_EVENT_BRANDING: EventBranding = {
primaryColor: '#E94B5A',
secondaryColor: '#F7C7CF',
backgroundColor: '#FFF6F2',
fontFamily: 'Montserrat, Inter, "Helvetica Neue", system-ui, -apple-system, BlinkMacSystemFont, sans-serif',
logoUrl: null,
welcomeMessage: null,
palette: {
primary: '#E94B5A',
secondary: '#F7C7CF',
background: '#FFF6F2',
surface: '#FFFFFF',
},
typography: {
heading: 'Playfair Display, "Times New Roman", serif',
body: 'Montserrat, Inter, "Helvetica Neue", system-ui, -apple-system, BlinkMacSystemFont, sans-serif',
sizePreset: 'm',
},
logo: {
mode: 'emoticon',
value: null,
position: 'left',
size: 'm',
},
buttons: {
style: 'filled',
radius: 12,
},
mode: 'auto',
};
const DEFAULT_PRIMARY = DEFAULT_EVENT_BRANDING.primaryColor.toLowerCase();
const DEFAULT_SECONDARY = DEFAULT_EVENT_BRANDING.secondaryColor.toLowerCase();
const DEFAULT_BACKGROUND = DEFAULT_EVENT_BRANDING.backgroundColor.toLowerCase();
const LIGHT_LUMINANCE_THRESHOLD = 0.65;
const DARK_LUMINANCE_THRESHOLD = 0.35;
const DARK_FALLBACK_SURFACE = '#0f172a';
const LIGHT_FALLBACK_SURFACE = '#ffffff';
const FONT_SCALE_MAP: Record<'s' | 'm' | 'l', number> = {
s: 0.94,
m: 1,
l: 1.08,
};
const EventBrandingContext = createContext<EventBrandingContextValue | undefined>(undefined);
function normaliseHexColor(value: string | null | undefined, fallback: string): string {
if (typeof value !== 'string') {
return fallback;
}
const trimmed = value.trim();
return /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(trimmed) ? trimmed : fallback;
}
function resolveBranding(input?: EventBranding | null): EventBranding {
if (!input) {
return DEFAULT_EVENT_BRANDING;
}
const palettePrimary = input.palette?.primary ?? input.primaryColor;
const paletteSecondary = input.palette?.secondary ?? input.secondaryColor;
const paletteBackground = input.palette?.background ?? input.backgroundColor;
const paletteSurface = input.palette?.surface ?? input.backgroundColor;
const headingFont = input.typography?.heading ?? input.fontFamily ?? null;
const bodyFont = input.typography?.body ?? input.fontFamily ?? null;
const rawSize = input.typography?.sizePreset ?? 'm';
const sizePreset = rawSize === 's' || rawSize === 'm' || rawSize === 'l' ? rawSize : 'm';
const logoMode = input.logo?.mode ?? (input.logoUrl ? 'upload' : 'emoticon');
const logoValue = input.logo?.value ?? input.logoUrl ?? null;
return {
primaryColor: normaliseHexColor(palettePrimary, DEFAULT_EVENT_BRANDING.primaryColor),
secondaryColor: normaliseHexColor(paletteSecondary, DEFAULT_EVENT_BRANDING.secondaryColor),
backgroundColor: normaliseHexColor(paletteBackground, DEFAULT_EVENT_BRANDING.backgroundColor),
fontFamily: bodyFont?.trim() || DEFAULT_EVENT_BRANDING.fontFamily,
logoUrl: logoMode === 'upload' ? (logoValue?.trim() || null) : null,
palette: {
primary: normaliseHexColor(palettePrimary, DEFAULT_EVENT_BRANDING.primaryColor),
secondary: normaliseHexColor(paletteSecondary, DEFAULT_EVENT_BRANDING.secondaryColor),
background: normaliseHexColor(paletteBackground, DEFAULT_EVENT_BRANDING.backgroundColor),
surface: normaliseHexColor(paletteSurface, paletteBackground ?? DEFAULT_EVENT_BRANDING.backgroundColor),
},
typography: {
heading: headingFont?.trim() || DEFAULT_EVENT_BRANDING.typography?.heading || null,
body: bodyFont?.trim() || DEFAULT_EVENT_BRANDING.typography?.body || DEFAULT_EVENT_BRANDING.fontFamily,
sizePreset,
},
logo: {
mode: logoMode,
value: logoMode === 'upload' ? (logoValue?.trim() || null) : (logoValue ?? null),
position: input.logo?.position ?? 'left',
size: input.logo?.size ?? 'm',
},
buttons: {
style: input.buttons?.style ?? 'filled',
radius: typeof input.buttons?.radius === 'number' ? input.buttons.radius : 12,
primary: input.buttons?.primary ?? normaliseHexColor(palettePrimary, DEFAULT_EVENT_BRANDING.primaryColor),
secondary: input.buttons?.secondary ?? normaliseHexColor(paletteSecondary, DEFAULT_EVENT_BRANDING.secondaryColor),
linkColor: input.buttons?.linkColor ?? normaliseHexColor(paletteSecondary, DEFAULT_EVENT_BRANDING.secondaryColor),
},
mode: input.mode ?? 'auto',
useDefaultBranding: input.useDefaultBranding ?? undefined,
welcomeMessage: input.welcomeMessage ?? null,
};
}
type ThemeVariant = 'light' | 'dark';
function resolveThemeVariant(
mode: EventBranding['mode'],
backgroundColor: string,
appearanceOverride: 'light' | 'dark' | null,
): ThemeVariant {
const prefersDark = typeof window !== 'undefined' && typeof window.matchMedia === 'function'
? window.matchMedia('(prefers-color-scheme: dark)').matches
: false;
const backgroundLuminance = relativeLuminance(backgroundColor || DEFAULT_EVENT_BRANDING.backgroundColor);
const backgroundPrefers = backgroundLuminance >= LIGHT_LUMINANCE_THRESHOLD
? 'light'
: backgroundLuminance <= DARK_LUMINANCE_THRESHOLD
? 'dark'
: null;
if (appearanceOverride) {
return appearanceOverride;
}
if (mode === 'dark') {
return 'dark';
}
if (mode === 'light') {
return 'light';
}
if (backgroundPrefers) {
return backgroundPrefers;
}
return prefersDark ? 'dark' : 'light';
}
function clampToTheme(color: string, theme: ThemeVariant): string {
const luminance = relativeLuminance(color);
if (theme === 'dark' && luminance >= LIGHT_LUMINANCE_THRESHOLD) {
return DARK_FALLBACK_SURFACE;
}
if (theme === 'light' && luminance <= DARK_LUMINANCE_THRESHOLD) {
return LIGHT_FALLBACK_SURFACE;
}
return color;
}
function applyCssVariables(branding: EventBranding, theme: ThemeVariant) {
if (typeof document === 'undefined') {
return;
}
const root = document.documentElement;
const background = clampToTheme(branding.backgroundColor, theme);
const surfaceCandidate = clampToTheme(branding.palette?.surface ?? background, theme);
const backgroundLuminance = relativeLuminance(background);
const surfaceLuminance = relativeLuminance(surfaceCandidate);
const surface = Math.abs(surfaceLuminance - backgroundLuminance) < 0.06
? theme === 'light'
? LIGHT_FALLBACK_SURFACE
: DARK_FALLBACK_SURFACE
: surfaceCandidate;
const isLight = theme === 'light';
const foreground = isLight ? '#1f2937' : '#f8fafc';
const mutedForeground = isLight ? '#6b7280' : '#cbd5e1';
const muted = isLight ? '#f6efec' : '#1f2937';
const border = isLight ? '#e6d9d6' : '#334155';
const input = isLight ? '#eadfda' : '#273247';
const primaryForeground = getContrastingTextColor(branding.primaryColor, '#ffffff', '#0f172a');
const secondaryForeground = getContrastingTextColor(branding.secondaryColor, '#ffffff', '#0f172a');
root.style.setProperty('--guest-primary', branding.primaryColor);
root.style.setProperty('--guest-secondary', branding.secondaryColor);
root.style.setProperty('--guest-background', background);
root.style.setProperty('--guest-surface', surface);
root.style.setProperty('--guest-button-radius', `${branding.buttons?.radius ?? 12}px`);
root.style.setProperty('--guest-radius', `${branding.buttons?.radius ?? 12}px`);
root.style.setProperty('--guest-link', branding.buttons?.linkColor ?? branding.secondaryColor);
root.style.setProperty('--guest-button-style', branding.buttons?.style ?? 'filled');
root.style.setProperty('--guest-font-scale', String(FONT_SCALE_MAP[branding.typography?.sizePreset ?? 'm'] ?? 1));
root.style.setProperty('--foreground', foreground);
root.style.setProperty('--card-foreground', foreground);
root.style.setProperty('--popover-foreground', foreground);
root.style.setProperty('--muted', muted);
root.style.setProperty('--muted-foreground', mutedForeground);
root.style.setProperty('--border', border);
root.style.setProperty('--input', input);
root.style.setProperty('--primary', branding.primaryColor);
root.style.setProperty('--primary-foreground', primaryForeground);
root.style.setProperty('--secondary', branding.secondaryColor);
root.style.setProperty('--secondary-foreground', secondaryForeground);
root.style.setProperty('--accent', branding.secondaryColor);
root.style.setProperty('--accent-foreground', secondaryForeground);
root.style.setProperty('--ring', branding.primaryColor);
const headingFont = branding.typography?.heading ?? branding.fontFamily;
const bodyFont = branding.typography?.body ?? branding.fontFamily;
if (bodyFont) {
root.style.setProperty('--guest-font-family', bodyFont);
root.style.setProperty('--guest-body-font', bodyFont);
} else {
root.style.removeProperty('--guest-font-family');
root.style.removeProperty('--guest-body-font');
}
if (headingFont) {
root.style.setProperty('--guest-heading-font', headingFont);
} else {
root.style.removeProperty('--guest-heading-font');
}
}
function resetCssVariables() {
if (typeof document === 'undefined') {
return;
}
const root = document.documentElement;
root.style.removeProperty('--guest-primary');
root.style.removeProperty('--guest-secondary');
root.style.removeProperty('--guest-background');
root.style.removeProperty('--guest-surface');
root.style.removeProperty('--guest-button-radius');
root.style.removeProperty('--guest-radius');
root.style.removeProperty('--guest-link');
root.style.removeProperty('--guest-button-style');
root.style.removeProperty('--guest-font-scale');
root.style.removeProperty('--guest-font-family');
root.style.removeProperty('--guest-body-font');
root.style.removeProperty('--guest-heading-font');
root.style.removeProperty('--foreground');
root.style.removeProperty('--card-foreground');
root.style.removeProperty('--popover-foreground');
root.style.removeProperty('--muted');
root.style.removeProperty('--muted-foreground');
root.style.removeProperty('--border');
root.style.removeProperty('--input');
root.style.removeProperty('--primary');
root.style.removeProperty('--primary-foreground');
root.style.removeProperty('--secondary');
root.style.removeProperty('--secondary-foreground');
root.style.removeProperty('--accent');
root.style.removeProperty('--accent-foreground');
root.style.removeProperty('--ring');
}
function applyThemeMode(
mode: EventBranding['mode'],
backgroundColor: string,
appearanceOverride: 'light' | 'dark' | null,
): ThemeVariant {
if (typeof document === 'undefined') {
return 'light';
}
const root = document.documentElement;
const theme = resolveThemeVariant(mode, backgroundColor, appearanceOverride);
if (theme === 'dark') {
root.classList.add('dark');
root.style.colorScheme = 'dark';
return 'dark';
}
root.classList.remove('dark');
root.style.colorScheme = 'light';
return 'light';
}
export function EventBrandingProvider({
branding,
children,
}: {
branding?: EventBranding | null;
children: React.ReactNode;
}) {
const resolved = useMemo(() => resolveBranding(branding), [branding]);
const { appearance } = useAppearance();
const appearanceOverride = appearance === 'light' || appearance === 'dark' ? appearance : null;
useEffect(() => {
if (typeof document !== 'undefined') {
document.documentElement.classList.add('guest-theme');
}
const previousDark = typeof document !== 'undefined' ? document.documentElement.classList.contains('dark') : false;
const theme = applyThemeMode(resolved.mode ?? 'auto', resolved.backgroundColor, appearanceOverride);
applyCssVariables(resolved, theme);
return () => {
if (typeof document !== 'undefined') {
if (previousDark) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
document.documentElement.classList.remove('guest-theme');
}
resetCssVariables();
const fallbackTheme = applyThemeMode(
DEFAULT_EVENT_BRANDING.mode ?? 'auto',
DEFAULT_EVENT_BRANDING.backgroundColor,
appearanceOverride,
);
applyCssVariables(DEFAULT_EVENT_BRANDING, fallbackTheme);
};
}, [appearanceOverride, resolved]);
const value = useMemo<EventBrandingContextValue>(() => ({
branding: resolved,
isCustom:
resolved.primaryColor.toLowerCase() !== DEFAULT_PRIMARY
|| resolved.secondaryColor.toLowerCase() !== DEFAULT_SECONDARY
|| resolved.backgroundColor.toLowerCase() !== DEFAULT_BACKGROUND,
// legacy surface check omitted by intent
}), [resolved]);
return <EventBrandingContext.Provider value={value}>{children}</EventBrandingContext.Provider>;
}
export function useEventBranding(): EventBrandingContextValue {
const context = useContext(EventBrandingContext);
if (!context) {
throw new Error('useEventBranding must be used within an EventBrandingProvider');
}
return context;
}
export function useOptionalEventBranding(): EventBrandingContextValue | undefined {
return useContext(EventBrandingContext);
}

View File

@@ -0,0 +1,304 @@
import React from 'react';
import { useUploadQueue } from '../queue/hooks';
import type { QueueItem } from '../queue/queue';
import {
dismissGuestNotification,
fetchGuestNotifications,
markGuestNotificationRead,
type GuestNotificationItem,
} from '../services/notificationApi';
import { fetchPendingUploadsSummary } from '../services/pendingUploadsApi';
import { updateAppBadge } from '../lib/badges';
export type NotificationCenterValue = {
notifications: GuestNotificationItem[];
unreadCount: number;
queueItems: QueueItem[];
queueCount: number;
pendingCount: number;
loading: boolean;
pendingLoading: boolean;
refresh: () => Promise<void>;
setFilters: (filters: { status?: 'new' | 'read' | 'dismissed' | 'all'; scope?: 'all' | 'uploads' | 'tips' | 'general' }) => void;
markAsRead: (id: number) => Promise<void>;
dismiss: (id: number) => Promise<void>;
eventToken: string;
lastFetchedAt: Date | null;
isOffline: boolean;
};
const NotificationCenterContext = React.createContext<NotificationCenterValue | null>(null);
export function NotificationCenterProvider({ eventToken, children }: { eventToken: string; children: React.ReactNode }) {
const { items, loading: queueLoading, refresh: refreshQueue } = useUploadQueue();
const [notifications, setNotifications] = React.useState<GuestNotificationItem[]>([]);
const [unreadCount, setUnreadCount] = React.useState(0);
const [loadingNotifications, setLoadingNotifications] = React.useState(true);
const [pendingCount, setPendingCount] = React.useState(0);
const [pendingLoading, setPendingLoading] = React.useState(true);
const [filters, setFiltersState] = React.useState<{ status?: 'new' | 'read' | 'dismissed' | 'all'; scope?: 'all' | 'uploads' | 'tips' | 'general' }>({
status: 'new',
scope: 'all',
});
const etagRef = React.useRef<string | null>(null);
const fetchLockRef = React.useRef(false);
const [lastFetchedAt, setLastFetchedAt] = React.useState<Date | null>(null);
const [isOffline, setIsOffline] = React.useState<boolean>(typeof navigator !== 'undefined' ? !navigator.onLine : false);
const queueCount = React.useMemo(
() => items.filter((item) => item.status !== 'done').length,
[items]
);
const loadNotifications = React.useCallback(
async (options: { silent?: boolean } = {}) => {
if (!eventToken) {
if (!options.silent) {
setLoadingNotifications(false);
}
return;
}
if (fetchLockRef.current) {
return;
}
fetchLockRef.current = true;
if (!options.silent) {
setLoadingNotifications(true);
}
try {
const statusFilter = filters.status && filters.status !== 'all' ? (filters.status === 'new' ? 'unread' : filters.status) : undefined;
const result = await fetchGuestNotifications(eventToken, etagRef.current, {
status: statusFilter as any,
scope: filters.scope,
});
if (!result.notModified) {
setNotifications(result.notifications);
setUnreadCount(result.unreadCount);
setLastFetchedAt(new Date());
}
etagRef.current = result.etag;
setIsOffline(false);
} catch (error) {
console.error('Failed to load guest notifications', error);
if (!options.silent) {
setNotifications([]);
setUnreadCount(0);
}
if (typeof navigator !== 'undefined' && !navigator.onLine) {
setIsOffline(true);
}
} finally {
fetchLockRef.current = false;
if (!options.silent) {
setLoadingNotifications(false);
}
}
},
[eventToken]
);
const loadPendingUploads = React.useCallback(async () => {
if (!eventToken) {
setPendingLoading(false);
return;
}
try {
setPendingLoading(true);
const result = await fetchPendingUploadsSummary(eventToken, 1);
setPendingCount(result.totalCount);
} catch (error) {
console.error('Failed to load pending uploads', error);
setPendingCount(0);
} finally {
setPendingLoading(false);
}
}, [eventToken]);
React.useEffect(() => {
setNotifications([]);
setUnreadCount(0);
etagRef.current = null;
setPendingCount(0);
if (!eventToken) {
setLoadingNotifications(false);
setPendingLoading(false);
return;
}
setLoadingNotifications(true);
void loadNotifications();
void loadPendingUploads();
}, [eventToken, loadNotifications, loadPendingUploads]);
React.useEffect(() => {
if (!eventToken) {
return;
}
const interval = window.setInterval(() => {
void loadNotifications({ silent: true });
void loadPendingUploads();
}, 90000);
return () => window.clearInterval(interval);
}, [eventToken, loadNotifications, loadPendingUploads]);
React.useEffect(() => {
const handleOnline = () => setIsOffline(false);
const handleOffline = () => setIsOffline(true);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
React.useEffect(() => {
const handler = (event: MessageEvent) => {
if (event.data?.type === 'guest-notification-refresh') {
void loadNotifications({ silent: true });
}
};
navigator.serviceWorker?.addEventListener('message', handler);
return () => {
navigator.serviceWorker?.removeEventListener('message', handler);
};
}, [loadNotifications]);
const markAsRead = React.useCallback(
async (id: number) => {
if (!eventToken) {
return;
}
let decremented = false;
setNotifications((prev) =>
prev.map((item) => {
if (item.id !== id) {
return item;
}
if (item.status === 'new') {
decremented = true;
}
return {
...item,
status: 'read',
readAt: new Date().toISOString(),
};
})
);
if (decremented) {
setUnreadCount((prev) => Math.max(0, prev - 1));
}
try {
await markGuestNotificationRead(eventToken, id);
} catch (error) {
console.error('Failed to mark notification as read', error);
void loadNotifications({ silent: true });
}
},
[eventToken, loadNotifications]
);
const dismiss = React.useCallback(
async (id: number) => {
if (!eventToken) {
return;
}
let decremented = false;
setNotifications((prev) =>
prev.map((item) => {
if (item.id !== id) {
return item;
}
if (item.status === 'new') {
decremented = true;
}
return {
...item,
status: 'dismissed',
dismissedAt: new Date().toISOString(),
};
})
);
if (decremented) {
setUnreadCount((prev) => Math.max(0, prev - 1));
}
try {
await dismissGuestNotification(eventToken, id);
} catch (error) {
console.error('Failed to dismiss notification', error);
void loadNotifications({ silent: true });
}
},
[eventToken, loadNotifications]
);
const setFilters = React.useCallback((next: { status?: 'new' | 'read' | 'dismissed' | 'all'; scope?: 'all' | 'uploads' | 'tips' | 'general' }) => {
setFiltersState((prev) => ({ ...prev, ...next }));
void loadNotifications({ silent: true });
}, [loadNotifications]);
const refresh = React.useCallback(async () => {
await Promise.all([loadNotifications(), refreshQueue(), loadPendingUploads()]);
}, [loadNotifications, refreshQueue, loadPendingUploads]);
const loading = loadingNotifications || queueLoading || pendingLoading;
React.useEffect(() => {
void updateAppBadge(unreadCount);
}, [unreadCount]);
const value: NotificationCenterValue = {
notifications,
unreadCount,
queueItems: items,
queueCount,
pendingCount,
loading,
pendingLoading,
refresh,
setFilters,
markAsRead,
dismiss,
eventToken,
lastFetchedAt,
isOffline,
};
return (
<NotificationCenterContext.Provider value={value}>
{children}
</NotificationCenterContext.Provider>
);
}
export function useNotificationCenter(): NotificationCenterValue {
const ctx = React.useContext(NotificationCenterContext);
if (!ctx) {
throw new Error('useNotificationCenter must be used within NotificationCenterProvider');
}
return ctx;
}
export function useOptionalNotificationCenter(): NotificationCenterValue | null {
return React.useContext(NotificationCenterContext);
}

View File

@@ -0,0 +1,93 @@
import React from 'react';
export const TASK_BADGE_TARGET = 5;
function storageKey(eventKey: string) {
return `guestTasks_${eventKey}`;
}
function parseStored(value: string | null) {
if (!value) {
return [] as number[];
}
try {
const parsed = JSON.parse(value);
if (Array.isArray(parsed)) {
return parsed.filter((item) => Number.isInteger(item)) as number[];
}
return [];
} catch (error) {
console.warn('Failed to parse task progress from storage', error);
return [];
}
}
export function useGuestTaskProgress(eventKey: string | undefined) {
const [completed, setCompleted] = React.useState<number[]>([]);
const [hydrated, setHydrated] = React.useState(false);
React.useEffect(() => {
if (!eventKey) {
setCompleted([]);
setHydrated(true);
return;
}
try {
const stored = window.localStorage.getItem(storageKey(eventKey));
setCompleted(parseStored(stored));
} catch (error) {
console.warn('Failed to read task progress', error);
setCompleted([]);
} finally {
setHydrated(true);
}
}, [eventKey]);
const markCompleted = React.useCallback(
(taskId: number) => {
if (!eventKey || !Number.isInteger(taskId)) {
return;
}
setCompleted((prev) => {
if (prev.includes(taskId)) {
return prev;
}
const next = [...prev, taskId];
try {
window.localStorage.setItem(storageKey(eventKey), JSON.stringify(next));
} catch (error) {
console.warn('Failed to persist task progress', error);
}
return next;
});
},
[eventKey]
);
const clearProgress = React.useCallback(() => {
if (!eventKey) return;
setCompleted([]);
try {
window.localStorage.removeItem(storageKey(eventKey));
} catch (error) {
console.warn('Failed to clear task progress', error);
}
}, [eventKey]);
const isCompleted = React.useCallback(
(taskId: number | null | undefined) => {
if (!Number.isInteger(taskId)) return false;
return completed.includes(taskId as number);
},
[completed]
);
return {
hydrated,
completed,
completedCount: completed.length,
markCompleted,
clearProgress,
isCompleted,
};
}

View File

@@ -0,0 +1,19 @@
import React from 'react';
import { getHapticsPreference, setHapticsPreference, supportsHaptics } from '../lib/haptics';
export function useHapticsPreference() {
const [enabled, setEnabledState] = React.useState(() => getHapticsPreference());
const [supported, setSupported] = React.useState(() => supportsHaptics());
React.useEffect(() => {
setEnabledState(getHapticsPreference());
setSupported(supportsHaptics());
}, []);
const setEnabled = React.useCallback((value: boolean) => {
setHapticsPreference(value);
setEnabledState(value);
}, []);
return { enabled, setEnabled, supported };
}

View File

@@ -0,0 +1,229 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import type { LiveShowLayoutMode, LiveShowPhoto, LiveShowSettings } from '../services/liveShowApi';
const MIN_FIXED_SECONDS = 3;
const MAX_FIXED_SECONDS = 20;
function resolveApprovedAt(photo: LiveShowPhoto): number {
if (!photo.approved_at) {
return 0;
}
const parsed = Date.parse(photo.approved_at);
return Number.isNaN(parsed) ? 0 : parsed;
}
function resolvePriority(photo: LiveShowPhoto): number {
return Number.isFinite(photo.live_priority) ? photo.live_priority : 0;
}
export function resolveItemsPerFrame(layout: LiveShowLayoutMode): number {
switch (layout) {
case 'split':
return 2;
case 'grid_burst':
return 4;
case 'single':
default:
return 1;
}
}
export function resolveIntervalMs(settings: LiveShowSettings, totalCount: number): number {
if (settings.pace_mode === 'fixed') {
const safeSeconds = Math.min(MAX_FIXED_SECONDS, Math.max(MIN_FIXED_SECONDS, settings.fixed_interval_seconds));
return safeSeconds * 1000;
}
if (totalCount >= 60) return 4500;
if (totalCount >= 30) return 5500;
if (totalCount >= 15) return 6500;
if (totalCount >= 6) return 7500;
return 9000;
}
export function resolvePlaybackQueue(photos: LiveShowPhoto[], settings: LiveShowSettings): LiveShowPhoto[] {
if (photos.length === 0) {
return [];
}
const newestFirst = [...photos].sort((a, b) => {
const timeDiff = resolveApprovedAt(b) - resolveApprovedAt(a);
if (timeDiff !== 0) return timeDiff;
return b.id - a.id;
});
if (settings.playback_mode === 'newest_first') {
return newestFirst;
}
if (settings.playback_mode === 'curated') {
const curated = photos.filter((photo) => photo.is_featured || resolvePriority(photo) > 0);
const base = curated.length > 0 ? curated : photos;
return [...base].sort((a, b) => {
const priorityDiff = resolvePriority(b) - resolvePriority(a);
if (priorityDiff !== 0) return priorityDiff;
const timeDiff = resolveApprovedAt(b) - resolveApprovedAt(a);
if (timeDiff !== 0) return timeDiff;
return b.id - a.id;
});
}
const oldestFirst = [...photos].sort((a, b) => {
const timeDiff = resolveApprovedAt(a) - resolveApprovedAt(b);
if (timeDiff !== 0) return timeDiff;
return a.id - b.id;
});
const balanced: LiveShowPhoto[] = [];
const seen = new Set<number>();
let newestIndex = 0;
let oldestIndex = 0;
let newestStreak = 0;
while (balanced.length < photos.length) {
let added = false;
if (newestIndex < newestFirst.length && newestStreak < 2) {
const candidate = newestFirst[newestIndex++];
if (!seen.has(candidate.id)) {
balanced.push(candidate);
seen.add(candidate.id);
newestStreak += 1;
added = true;
}
}
if (!added) {
while (oldestIndex < oldestFirst.length && seen.has(oldestFirst[oldestIndex].id)) {
oldestIndex += 1;
}
if (oldestIndex < oldestFirst.length) {
const candidate = oldestFirst[oldestIndex++];
balanced.push(candidate);
seen.add(candidate.id);
newestStreak = 0;
added = true;
}
}
if (!added) {
while (newestIndex < newestFirst.length && seen.has(newestFirst[newestIndex].id)) {
newestIndex += 1;
}
if (newestIndex < newestFirst.length) {
const candidate = newestFirst[newestIndex++];
balanced.push(candidate);
seen.add(candidate.id);
newestStreak += 1;
added = true;
}
}
if (!added) {
break;
}
}
return balanced;
}
export function buildFramePhotos(
queue: LiveShowPhoto[],
startIndex: number,
itemsPerFrame: number
): LiveShowPhoto[] {
if (queue.length === 0) {
return [];
}
const safeCount = Math.min(itemsPerFrame, queue.length);
const result: LiveShowPhoto[] = [];
for (let offset = 0; offset < safeCount; offset += 1) {
const idx = (startIndex + offset) % queue.length;
result.push(queue[idx]);
}
return result;
}
export type LiveShowPlaybackState = {
frame: LiveShowPhoto[];
layout: LiveShowLayoutMode;
intervalMs: number;
frameKey: string;
nextFrame: LiveShowPhoto[];
};
export function useLiveShowPlayback(
photos: LiveShowPhoto[],
settings: LiveShowSettings,
options: { paused?: boolean } = {}
): LiveShowPlaybackState {
const queue = useMemo(() => resolvePlaybackQueue(photos, settings), [photos, settings]);
const layout = settings.layout_mode;
const itemsPerFrame = resolveItemsPerFrame(layout);
const [index, setIndex] = useState(0);
const currentIdRef = useRef<number | null>(null);
const paused = Boolean(options.paused);
useEffect(() => {
if (queue.length === 0) {
setIndex(0);
currentIdRef.current = null;
return;
}
if (currentIdRef.current !== null) {
const existingIndex = queue.findIndex((photo) => photo.id === currentIdRef.current);
if (existingIndex >= 0) {
setIndex(existingIndex);
return;
}
}
setIndex((prev) => prev % queue.length);
}, [queue]);
const frame = useMemo(() => {
const framePhotos = buildFramePhotos(queue, index, itemsPerFrame);
currentIdRef.current = framePhotos[0]?.id ?? null;
return framePhotos;
}, [queue, index, itemsPerFrame]);
const frameKey = useMemo(() => {
if (frame.length === 0) {
return `empty-${layout}`;
}
return frame.map((photo) => photo.id).join('-');
}, [frame, layout]);
const nextFrame = useMemo(() => {
if (queue.length === 0) {
return [];
}
return buildFramePhotos(queue, index + itemsPerFrame, itemsPerFrame);
}, [index, itemsPerFrame, queue]);
const intervalMs = resolveIntervalMs(settings, queue.length);
useEffect(() => {
if (queue.length === 0 || paused) {
return undefined;
}
const timer = window.setInterval(() => {
setIndex((prev) => (prev + itemsPerFrame) % queue.length);
}, intervalMs);
return () => window.clearInterval(timer);
}, [intervalMs, itemsPerFrame, queue.length]);
return {
frame,
layout,
intervalMs,
frameKey,
nextFrame,
};
}

View File

@@ -0,0 +1,317 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
buildLiveShowStreamUrl,
DEFAULT_LIVE_SHOW_SETTINGS,
fetchLiveShowState,
fetchLiveShowUpdates,
LiveShowCursor,
LiveShowEvent,
LiveShowPhoto,
LiveShowSettings,
LiveShowState,
LiveShowError,
} from '../services/liveShowApi';
export type LiveShowStatus = 'loading' | 'ready' | 'error';
export type LiveShowConnection = 'idle' | 'sse' | 'polling';
const MAX_PHOTOS = 200;
const POLL_INTERVAL_MS = 12_000;
const POLL_HIDDEN_INTERVAL_MS = 30_000;
function mergePhotos(existing: LiveShowPhoto[], incoming: LiveShowPhoto[]): LiveShowPhoto[] {
if (incoming.length === 0) {
return existing;
}
const byId = new Map<number, LiveShowPhoto>();
existing.forEach((photo) => byId.set(photo.id, photo));
incoming.forEach((photo) => {
if (!byId.has(photo.id)) {
byId.set(photo.id, photo);
}
});
return Array.from(byId.values()).slice(-MAX_PHOTOS);
}
function resolveErrorMessage(error: unknown): string {
if (error instanceof LiveShowError) {
return error.message || 'Live Show konnte nicht geladen werden.';
}
if (error instanceof Error) {
return error.message || 'Live Show konnte nicht geladen werden.';
}
return 'Live Show konnte nicht geladen werden.';
}
function safeParseJson<T>(value: string): T | null {
try {
return JSON.parse(value) as T;
} catch (error) {
console.warn('Live show event payload parse failed:', error);
return null;
}
}
export type LiveShowStateResult = {
status: LiveShowStatus;
connection: LiveShowConnection;
error: string | null;
event: LiveShowEvent | null;
settings: LiveShowSettings;
settingsVersion: string;
photos: LiveShowPhoto[];
cursor: LiveShowCursor | null;
};
export function useLiveShowState(token: string | null, limit = 50): LiveShowStateResult {
const [status, setStatus] = useState<LiveShowStatus>('loading');
const [connection, setConnection] = useState<LiveShowConnection>('idle');
const [error, setError] = useState<string | null>(null);
const [event, setEvent] = useState<LiveShowEvent | null>(null);
const [settings, setSettings] = useState<LiveShowSettings>(DEFAULT_LIVE_SHOW_SETTINGS);
const [settingsVersion, setSettingsVersion] = useState('');
const [photos, setPhotos] = useState<LiveShowPhoto[]>([]);
const [cursor, setCursor] = useState<LiveShowCursor | null>(null);
const [visible, setVisible] = useState(
typeof document !== 'undefined' ? document.visibilityState === 'visible' : true
);
const cursorRef = useRef<LiveShowCursor | null>(null);
const settingsVersionRef = useRef<string>('');
const eventSourceRef = useRef<EventSource | null>(null);
const pollingTimerRef = useRef<number | null>(null);
const pollInFlight = useRef(false);
const updateCursor = useCallback((next: LiveShowCursor | null) => {
cursorRef.current = next;
setCursor(next);
}, []);
const applySettings = useCallback((nextSettings: LiveShowSettings, nextVersion: string) => {
setSettings(nextSettings);
setSettingsVersion(nextVersion);
settingsVersionRef.current = nextVersion;
}, []);
const applyPhotos = useCallback(
(incoming: LiveShowPhoto[], nextCursor?: LiveShowCursor | null) => {
if (incoming.length === 0) {
return;
}
setPhotos((existing) => mergePhotos(existing, incoming));
if (nextCursor) {
updateCursor(nextCursor);
}
},
[updateCursor]
);
const closeEventSource = useCallback(() => {
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
}, []);
const clearPolling = useCallback(() => {
if (pollingTimerRef.current !== null) {
window.clearInterval(pollingTimerRef.current);
pollingTimerRef.current = null;
}
}, []);
const pollUpdates = useCallback(async () => {
if (!token || pollInFlight.current) {
return;
}
pollInFlight.current = true;
try {
const update = await fetchLiveShowUpdates(token, {
cursor: cursorRef.current,
settingsVersion: settingsVersionRef.current,
limit,
});
if (update.settings) {
applySettings(update.settings, update.settings_version);
} else if (update.settings_version && update.settings_version !== settingsVersionRef.current) {
settingsVersionRef.current = update.settings_version;
setSettingsVersion(update.settings_version);
}
if (update.photos.length > 0) {
applyPhotos(update.photos, update.cursor ?? cursorRef.current);
} else if (update.cursor) {
updateCursor(update.cursor);
}
} catch (err) {
console.warn('Live show polling error:', err);
} finally {
pollInFlight.current = false;
}
}, [applyPhotos, applySettings, limit, token, updateCursor]);
const startPolling = useCallback(() => {
clearPolling();
setConnection('polling');
void pollUpdates();
const interval = visible ? POLL_INTERVAL_MS : POLL_HIDDEN_INTERVAL_MS;
pollingTimerRef.current = window.setInterval(() => {
void pollUpdates();
}, interval);
}, [clearPolling, pollUpdates, visible]);
const startSse = useCallback(() => {
if (!token || typeof EventSource === 'undefined') {
return false;
}
closeEventSource();
const url = buildLiveShowStreamUrl(token, {
cursor: cursorRef.current,
settingsVersion: settingsVersionRef.current,
limit,
});
try {
const stream = new EventSource(url);
eventSourceRef.current = stream;
setConnection('sse');
stream.addEventListener('settings.updated', (event) => {
const payload = safeParseJson<{
settings?: LiveShowSettings;
settings_version?: string;
}>((event as MessageEvent<string>).data);
if (!payload) {
return;
}
if (payload.settings && payload.settings_version) {
applySettings(payload.settings, payload.settings_version);
}
});
stream.addEventListener('photo.approved', (event) => {
const payload = safeParseJson<{
photo?: LiveShowPhoto;
cursor?: LiveShowCursor | null;
}>((event as MessageEvent<string>).data);
if (!payload) {
return;
}
if (payload.photo) {
applyPhotos([payload.photo], payload.cursor ?? null);
} else if (payload.cursor) {
updateCursor(payload.cursor);
}
});
stream.addEventListener('error', () => {
closeEventSource();
startPolling();
});
return true;
} catch (err) {
console.warn('Live show SSE failed:', err);
closeEventSource();
return false;
}
}, [applyPhotos, applySettings, closeEventSource, limit, startPolling, token, updateCursor]);
const startStreaming = useCallback(() => {
clearPolling();
if (!startSse()) {
startPolling();
}
}, [clearPolling, startPolling, startSse]);
useEffect(() => {
const onVisibility = () => setVisible(document.visibilityState === 'visible');
document.addEventListener('visibilitychange', onVisibility);
return () => document.removeEventListener('visibilitychange', onVisibility);
}, []);
useEffect(() => {
if (connection !== 'polling') {
return;
}
startPolling();
}, [connection, startPolling, visible]);
useEffect(() => {
if (!token) {
setStatus('error');
setError('Live Show konnte nicht geladen werden.');
setEvent(null);
setPhotos([]);
updateCursor(null);
setSettings(DEFAULT_LIVE_SHOW_SETTINGS);
setSettingsVersion('');
setConnection('idle');
return;
}
let cancelled = false;
const load = async () => {
setStatus('loading');
setError(null);
setConnection('idle');
try {
const data: LiveShowState = await fetchLiveShowState(token, limit);
if (cancelled) {
return;
}
setEvent(data.event);
setPhotos(data.photos);
updateCursor(data.cursor);
applySettings(data.settings, data.settings_version);
setStatus('ready');
startStreaming();
} catch (err) {
if (cancelled) {
return;
}
setStatus('error');
setError(resolveErrorMessage(err));
setConnection('idle');
}
};
load();
return () => {
cancelled = true;
closeEventSource();
clearPolling();
setConnection('idle');
};
}, [applySettings, clearPolling, closeEventSource, limit, startStreaming, token, updateCursor]);
return useMemo(
() => ({
status,
connection,
error,
event,
settings,
settingsVersion,
photos,
cursor,
}),
[status, connection, error, event, settings, settingsVersion, photos, cursor]
);
}

View File

@@ -0,0 +1,124 @@
import React from 'react';
import { DEFAULT_LOCALE, SUPPORTED_LOCALES, type LocaleCode, isLocaleCode } from './messages';
export interface LocaleContextValue {
locale: LocaleCode;
setLocale: (next: LocaleCode) => void;
resetLocale: () => void;
hydrated: boolean;
defaultLocale: LocaleCode;
storageKey: string;
availableLocales: typeof SUPPORTED_LOCALES;
}
const LocaleContext = React.createContext<LocaleContextValue | undefined>(undefined);
function sanitizeLocale(value: string | null | undefined, fallback: LocaleCode = DEFAULT_LOCALE): LocaleCode {
if (value && isLocaleCode(value)) {
return value;
}
return fallback;
}
export interface LocaleProviderProps {
children: React.ReactNode;
defaultLocale?: LocaleCode;
storageKey?: string;
}
export function LocaleProvider({
children,
defaultLocale = DEFAULT_LOCALE,
storageKey = 'guestLocale_global',
}: LocaleProviderProps) {
const resolvedDefault = sanitizeLocale(defaultLocale, DEFAULT_LOCALE);
const [locale, setLocaleState] = React.useState<LocaleCode>(resolvedDefault);
const [userLocale, setUserLocale] = React.useState<LocaleCode | null>(null);
const [hydrated, setHydrated] = React.useState(false);
React.useEffect(() => {
setHydrated(false);
if (typeof window === 'undefined') {
setLocaleState(resolvedDefault);
setUserLocale(null);
setHydrated(true);
return;
}
let stored: string | null = null;
try {
stored = window.localStorage.getItem(storageKey);
} catch (error) {
console.warn('Failed to read stored locale', error);
}
const nextLocale = sanitizeLocale(stored, resolvedDefault);
setLocaleState(nextLocale);
setUserLocale(isLocaleCode(stored) ? stored : null);
setHydrated(true);
}, [storageKey, resolvedDefault]);
React.useEffect(() => {
if (!hydrated || userLocale !== null) {
return;
}
setLocaleState(resolvedDefault);
}, [hydrated, userLocale, resolvedDefault]);
const setLocale = React.useCallback(
(next: LocaleCode) => {
const safeLocale = sanitizeLocale(next, resolvedDefault);
setLocaleState(safeLocale);
setUserLocale(safeLocale);
if (typeof window !== 'undefined') {
try {
window.localStorage.setItem(storageKey, safeLocale);
} catch (error) {
console.warn('Failed to persist locale', error);
}
}
},
[storageKey, resolvedDefault],
);
const resetLocale = React.useCallback(() => {
setUserLocale(null);
setLocaleState(resolvedDefault);
if (typeof window !== 'undefined') {
try {
window.localStorage.removeItem(storageKey);
} catch (error) {
console.warn('Failed to clear stored locale', error);
}
}
}, [resolvedDefault, storageKey]);
const value = React.useMemo<LocaleContextValue>(
() => ({
locale,
setLocale,
resetLocale,
hydrated,
defaultLocale: resolvedDefault,
storageKey,
availableLocales: SUPPORTED_LOCALES,
}),
[locale, setLocale, resetLocale, hydrated, resolvedDefault, storageKey],
);
return <LocaleContext.Provider value={value}>{children}</LocaleContext.Provider>;
}
export function useLocale(): LocaleContextValue {
const ctx = React.useContext(LocaleContext);
if (!ctx) {
throw new Error('useLocale must be used within a LocaleProvider');
}
return ctx;
}
export function useOptionalLocale(): LocaleContextValue | undefined {
return React.useContext(LocaleContext);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,49 @@
import React from 'react';
import { translate, DEFAULT_LOCALE, type LocaleCode } from './messages';
import { useLocale } from './LocaleContext';
type ReplacementValues = Record<string, string | number>;
export type TranslateFn = {
(key: string): string;
(key: string, fallback: string): string;
(key: string, replacements: ReplacementValues): string;
(key: string, replacements: ReplacementValues, fallback: string): string;
};
function resolveTranslation(locale: LocaleCode, key: string, fallback?: string): string {
return translate(locale, key) ?? translate(DEFAULT_LOCALE, key) ?? fallback ?? key;
}
function applyReplacements(value: string, replacements?: ReplacementValues): string {
if (!replacements) {
return value;
}
return Object.entries(replacements).reduce((acc, [token, replacement]) => {
const pattern = new RegExp(`\\{${token}\\}`, 'g');
return acc.replace(pattern, String(replacement));
}, value);
}
export function useTranslation() {
const { locale } = useLocale();
const t = React.useCallback<TranslateFn>((key: string, arg2?: ReplacementValues | string, arg3?: string) => {
let replacements: ReplacementValues | undefined;
let fallback: string | undefined;
if (typeof arg2 === 'string' || arg2 === undefined) {
fallback = arg2 ?? arg3;
} else {
replacements = arg2;
fallback = arg3;
}
const raw = resolveTranslation(locale, key, fallback);
return applyReplacements(raw, replacements);
}, [locale]);
return React.useMemo(() => ({ t, locale }), [t, locale]);
}

View File

@@ -0,0 +1,34 @@
export type AnalyticsNudgeState = {
decisionMade: boolean;
analyticsConsent: boolean;
snoozedUntil: number | null;
now: number;
activeSeconds: number;
routeCount: number;
thresholdSeconds: number;
thresholdRoutes: number;
isUpload: boolean;
};
export function isUploadPath(pathname: string): boolean {
return /\/upload(?:\/|$)/.test(pathname);
}
export function shouldShowAnalyticsNudge(state: AnalyticsNudgeState): boolean {
if (state.decisionMade || state.analyticsConsent) {
return false;
}
if (state.isUpload) {
return false;
}
if (state.snoozedUntil && state.snoozedUntil > state.now) {
return false;
}
return (
state.activeSeconds >= state.thresholdSeconds &&
state.routeCount >= state.thresholdRoutes
);
}

View File

@@ -0,0 +1,46 @@
type BadgingNavigator = Navigator & {
setAppBadge?: (contents?: number) => Promise<void> | void;
clearAppBadge?: () => Promise<void> | void;
};
function getNavigator(): BadgingNavigator | null {
if (typeof navigator === 'undefined') {
return null;
}
return navigator as BadgingNavigator;
}
export function supportsBadging(): boolean {
const nav = getNavigator();
return Boolean(nav && (typeof nav.setAppBadge === 'function' || typeof nav.clearAppBadge === 'function'));
}
export async function updateAppBadge(count: number): Promise<void> {
const nav = getNavigator();
if (!nav) {
return;
}
const safeCount = Number.isFinite(count) ? Math.max(0, Math.floor(count)) : 0;
if (!supportsBadging()) {
return;
}
try {
if (safeCount > 0 && nav.setAppBadge) {
await nav.setAppBadge(safeCount);
return;
}
if (nav.clearAppBadge) {
await nav.clearAppBadge();
return;
}
if (nav.setAppBadge) {
await nav.setAppBadge(0);
}
} catch (error) {
console.warn('Updating app badge failed', error);
}
}

View File

@@ -0,0 +1,50 @@
export function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
const normalized = hex.trim();
if (!/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(normalized)) {
return null;
}
let r: number;
let g: number;
let b: number;
if (normalized.length === 4) {
r = parseInt(normalized[1] + normalized[1], 16);
g = parseInt(normalized[2] + normalized[2], 16);
b = parseInt(normalized[3] + normalized[3], 16);
} else {
r = parseInt(normalized.slice(1, 3), 16);
g = parseInt(normalized.slice(3, 5), 16);
b = parseInt(normalized.slice(5, 7), 16);
}
return { r, g, b };
}
export function relativeLuminance(hex: string): number {
const rgb = hexToRgb(hex);
if (!rgb) {
return 0;
}
const normalize = (channel: number) => {
const c = channel / 255;
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
};
const r = normalize(rgb.r);
const g = normalize(rgb.g);
const b = normalize(rgb.b);
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
export function getContrastingTextColor(
backgroundHex: string,
lightColor = '#ffffff',
darkColor = '#0f172a',
): string {
const luminance = relativeLuminance(backgroundHex);
return luminance > 0.5 ? darkColor : lightColor;
}

View File

@@ -0,0 +1,49 @@
import { getDeviceId } from './device';
function getCsrfToken(): string | null {
if (typeof document === 'undefined') {
return null;
}
const metaToken = document.querySelector('meta[name="csrf-token"]');
if (metaToken instanceof HTMLMetaElement) {
return metaToken.getAttribute('content') || null;
}
const name = 'XSRF-TOKEN=';
const decodedCookie = decodeURIComponent(document.cookie ?? '');
const parts = decodedCookie.split(';');
for (const part of parts) {
const trimmed = part.trimStart();
if (!trimmed.startsWith(name)) {
continue;
}
const token = trimmed.substring(name.length);
try {
return decodeURIComponent(atob(token));
} catch {
return token;
}
}
return null;
}
export function buildCsrfHeaders(deviceId?: string): Record<string, string> {
const token = getCsrfToken();
const resolvedDeviceId = deviceId ?? (typeof window !== 'undefined' ? getDeviceId() : undefined);
const headers: Record<string, string> = {
Accept: 'application/json',
};
if (resolvedDeviceId) {
headers['X-Device-Id'] = resolvedDeviceId;
}
if (token) {
headers['X-CSRF-TOKEN'] = token;
headers['X-XSRF-TOKEN'] = token;
}
return headers;
}

View File

@@ -0,0 +1,19 @@
export function getDeviceId(): string {
const KEY = 'device-id';
let id = localStorage.getItem(KEY);
if (!id) {
id = genId();
localStorage.setItem(KEY, id);
}
return id;
}
function genId() {
// Simple UUID v4-ish generator
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (crypto.getRandomValues(new Uint8Array(1))[0] & 0xf) >> 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}

View File

@@ -0,0 +1,128 @@
export type EmotionTheme = {
gradientClass: string;
gradientBackground: string;
suggestionGradient: string;
suggestionBorder: string;
};
export type EmotionIdentity = {
slug?: string | null;
name?: string | null;
};
const themeFreude: EmotionTheme = {
gradientClass: 'from-amber-300 via-orange-400 to-rose-500',
gradientBackground: 'linear-gradient(135deg, #fde68a, #fb923c, #fb7185)',
suggestionGradient: 'from-amber-50 via-white to-orange-50 dark:from-amber-500/20 dark:via-gray-900 dark:to-orange-500/10',
suggestionBorder: 'border-amber-200/60 dark:border-amber-400/30',
};
const themeLiebe: EmotionTheme = {
gradientClass: 'from-rose-400 via-fuchsia-500 to-purple-600',
gradientBackground: 'linear-gradient(135deg, #fb7185, #ec4899, #9333ea)',
suggestionGradient: 'from-rose-50 via-white to-fuchsia-50 dark:from-rose-500/20 dark:via-gray-900 dark:to-fuchsia-500/10',
suggestionBorder: 'border-rose-200/60 dark:border-rose-400/30',
};
const themeEkstase: EmotionTheme = {
gradientClass: 'from-fuchsia-400 via-purple-500 to-indigo-500',
gradientBackground: 'linear-gradient(135deg, #f472b6, #a855f7, #6366f1)',
suggestionGradient: 'from-fuchsia-50 via-white to-indigo-50 dark:from-fuchsia-500/20 dark:via-gray-900 dark:to-indigo-500/10',
suggestionBorder: 'border-fuchsia-200/60 dark:border-fuchsia-400/30',
};
const themeEntspannt: EmotionTheme = {
gradientClass: 'from-teal-300 via-emerald-400 to-cyan-500',
gradientBackground: 'linear-gradient(135deg, #5eead4, #34d399, #22d3ee)',
suggestionGradient: 'from-teal-50 via-white to-emerald-50 dark:from-teal-500/20 dark:via-gray-900 dark:to-emerald-500/10',
suggestionBorder: 'border-emerald-200/60 dark:border-emerald-400/30',
};
const themeBesinnlich: EmotionTheme = {
gradientClass: 'from-slate-500 via-blue-500 to-indigo-600',
gradientBackground: 'linear-gradient(135deg, #64748b, #3b82f6, #4f46e5)',
suggestionGradient: 'from-slate-50 via-white to-blue-50 dark:from-slate-600/20 dark:via-gray-900 dark:to-blue-500/10',
suggestionBorder: 'border-slate-200/60 dark:border-slate-500/30',
};
const themeUeberraschung: EmotionTheme = {
gradientClass: 'from-indigo-300 via-violet-500 to-rose-500',
gradientBackground: 'linear-gradient(135deg, #a5b4fc, #a855f7, #fb7185)',
suggestionGradient: 'from-indigo-50 via-white to-violet-50 dark:from-indigo-500/20 dark:via-gray-900 dark:to-violet-500/10',
suggestionBorder: 'border-indigo-200/60 dark:border-indigo-400/30',
};
const themeDefault: EmotionTheme = {
gradientClass: 'from-pink-500 via-purple-500 to-indigo-600',
gradientBackground: 'linear-gradient(135deg, #ec4899, #a855f7, #4f46e5)',
suggestionGradient: 'from-pink-50 via-white to-indigo-50 dark:from-pink-500/20 dark:via-gray-900 dark:to-indigo-500/10',
suggestionBorder: 'border-pink-200/60 dark:border-pink-400/30',
};
const EMOTION_THEMES: Record<string, EmotionTheme> = {
freude: themeFreude,
happy: themeFreude,
liebe: themeLiebe,
romance: themeLiebe,
romantik: themeLiebe,
nostalgie: themeEntspannt,
relaxed: themeEntspannt,
ruehrung: themeBesinnlich,
traurigkeit: themeBesinnlich,
teamgeist: themeFreude,
gemeinschaft: themeFreude,
ueberraschung: themeUeberraschung,
surprise: themeUeberraschung,
ekstase: themeEkstase,
excited: themeEkstase,
besinnlichkeit: themeBesinnlich,
sad: themeBesinnlich,
default: themeDefault,
};
const EMOTION_ICONS: Record<string, string> = {
freude: '😊',
happy: '😊',
liebe: '❤️',
romantik: '💞',
nostalgie: '📼',
ruehrung: '🥲',
teamgeist: '🤝',
ueberraschung: '😲',
surprise: '😲',
ekstase: '🤩',
besinnlichkeit: '🕯️',
};
function sluggify(value?: string | null): string {
return (value ?? '')
.toString()
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '')
.trim();
}
function resolveEmotionKey(identity?: EmotionIdentity | null): string {
if (!identity) return 'default';
const nameKey = sluggify(identity.name);
if (nameKey && EMOTION_THEMES[nameKey]) {
return nameKey;
}
const slugKey = sluggify(identity.slug);
if (slugKey && EMOTION_THEMES[slugKey]) {
return slugKey;
}
return 'default';
}
export function getEmotionTheme(identity?: EmotionIdentity | null): EmotionTheme {
const key = resolveEmotionKey(identity);
return EMOTION_THEMES[key] ?? themeDefault;
}
export function getEmotionIcon(identity?: EmotionIdentity | null): string {
const key = resolveEmotionKey(identity);
return EMOTION_ICONS[key] ?? '✨';
}

View File

@@ -0,0 +1,8 @@
import type { EventData } from '../services/eventApi';
export function isTaskModeEnabled(event?: EventData | null): boolean {
if (!event) return true;
const mode = event.engagement_mode;
if (!mode) return true;
return mode === 'tasks';
}

View File

@@ -0,0 +1,62 @@
import { prefersReducedMotion } from './motion';
export type HapticPattern = 'selection' | 'light' | 'medium' | 'success' | 'error';
const PATTERNS: Record<HapticPattern, number | number[]> = {
selection: 10,
light: 15,
medium: 30,
success: [10, 30, 10],
error: [20, 30, 20],
};
export const HAPTICS_STORAGE_KEY = 'guestHapticsEnabled';
export function supportsHaptics(): boolean {
return typeof navigator !== 'undefined' && typeof navigator.vibrate === 'function';
}
export function getHapticsPreference(): boolean {
if (typeof window === 'undefined') {
return true;
}
try {
const raw = window.localStorage.getItem(HAPTICS_STORAGE_KEY);
if (raw === null) {
return true;
}
return raw !== '0';
} catch (error) {
console.warn('Failed to read haptics preference', error);
return true;
}
}
export function setHapticsPreference(enabled: boolean): void {
if (typeof window === 'undefined') {
return;
}
try {
window.localStorage.setItem(HAPTICS_STORAGE_KEY, enabled ? '1' : '0');
} catch (error) {
console.warn('Failed to store haptics preference', error);
}
}
export function isHapticsEnabled(): boolean {
return getHapticsPreference() && supportsHaptics() && !prefersReducedMotion();
}
export function triggerHaptic(pattern: HapticPattern): void {
if (!isHapticsEnabled()) {
return;
}
try {
navigator.vibrate(PATTERNS[pattern]);
} catch (error) {
console.warn('Haptic feedback failed', error);
}
}

View File

@@ -0,0 +1,97 @@
// @ts-nocheck
export async function compressPhoto(
file: File,
opts: { targetBytes?: number; maxEdge?: number; qualityStart?: number } = {}
): Promise<File> {
const targetBytes = opts.targetBytes ?? 1_500_000; // 1.5 MB
const maxEdge = opts.maxEdge ?? 2560;
const qualityStart = opts.qualityStart ?? 0.85;
// If already small and jpeg, return as-is
if (file.size <= targetBytes && file.type === 'image/jpeg') return file;
const img = await loadImageBitmap(file);
const { width, height } = fitWithin(img.width, img.height, maxEdge);
const canvas = createCanvas(width, height);
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Canvas unsupported');
ctx.drawImage(img, 0, 0, width, height);
// Iteratively lower quality to fit target size
let quality = qualityStart;
let blob: Blob | null = await toBlob(canvas, 'image/jpeg', quality);
if (!blob) throw new Error('Failed to encode image');
while (blob.size > targetBytes && quality > 0.5) {
quality -= 0.05;
const attempt = await toBlob(canvas, 'image/jpeg', quality);
if (attempt) blob = attempt;
else break;
}
// If still too large, downscale further by 0.9 until it fits or edge < 800
let currentWidth = width;
let currentHeight = height;
while (blob.size > targetBytes && Math.max(currentWidth, currentHeight) > 800) {
currentWidth = Math.round(currentWidth * 0.9);
currentHeight = Math.round(currentHeight * 0.9);
const c2 = createCanvas(currentWidth, currentHeight);
const c2ctx = c2.getContext('2d');
if (!c2ctx) break;
c2ctx.drawImage(canvas, 0, 0, currentWidth, currentHeight);
const attempt = await toBlob(c2, 'image/jpeg', quality);
if (attempt) blob = attempt;
}
const outName = ensureJpegExtension(file.name);
return new File([blob], outName, { type: 'image/jpeg', lastModified: Date.now() });
}
function fitWithin(w: number, h: number, maxEdge: number) {
const scale = Math.min(1, maxEdge / Math.max(w, h));
return { width: Math.round(w * scale), height: Math.round(h * scale) };
}
function createCanvas(w: number, h: number): HTMLCanvasElement {
const c = document.createElement('canvas');
c.width = w; c.height = h; return c;
}
function toBlob(canvas: HTMLCanvasElement, type: string, quality: number): Promise<Blob | null> {
return new Promise(resolve => canvas.toBlob(resolve, type, quality));
}
async function loadImageBitmap(file: File): Promise<CanvasImageSource> {
const canBitmap = 'createImageBitmap' in window;
if (canBitmap) {
try {
return await createImageBitmap(file);
} catch (error) {
console.warn('Falling back to HTML image decode', error);
}
}
return await loadHtmlImage(file);
}
function loadHtmlImage(file: File): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const url = URL.createObjectURL(file);
const img = new Image();
img.onload = () => { URL.revokeObjectURL(url); resolve(img); };
img.onerror = (e) => { URL.revokeObjectURL(url); reject(e); };
img.src = url;
});
}
function ensureJpegExtension(name: string) {
return name.replace(/\.(heic|heif|png|webp|jpg|jpeg)$/i, '') + '.jpg';
}
export function formatBytes(bytes: number) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
}

View File

@@ -0,0 +1,107 @@
import type { MotionProps, Transition } from 'framer-motion';
import { IOS_EASE, IOS_EASE_SOFT } from './motion';
import type { LiveShowEffectPreset } from '../services/liveShowApi';
export type LiveShowEffectSpec = {
frame: MotionProps;
flash?: MotionProps;
};
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
function resolveIntensity(intensity: number): number {
const safe = Number.isFinite(intensity) ? intensity : 70;
return clamp(safe / 100, 0, 1);
}
function buildTransition(duration: number, ease: Transition['ease']): Transition {
return {
duration,
ease,
};
}
export function resolveLiveShowEffect(
preset: LiveShowEffectPreset,
intensity: number,
reducedMotion: boolean
): LiveShowEffectSpec {
const strength = reducedMotion ? 0 : resolveIntensity(intensity);
const baseDuration = reducedMotion ? 0.2 : clamp(0.9 - strength * 0.35, 0.45, 1);
const exitDuration = reducedMotion ? 0.15 : clamp(baseDuration * 0.6, 0.25, 0.6);
if (reducedMotion) {
return {
frame: {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0, transition: buildTransition(exitDuration, IOS_EASE_SOFT) },
transition: buildTransition(baseDuration, IOS_EASE_SOFT),
},
};
}
switch (preset) {
case 'shutter_flash': {
const scale = 1 + strength * 0.05;
return {
frame: {
initial: { opacity: 0, scale, y: 12 * strength },
animate: { opacity: 1, scale: 1, y: 0 },
exit: { opacity: 0, scale: 0.98, transition: buildTransition(exitDuration, IOS_EASE) },
transition: buildTransition(baseDuration, IOS_EASE),
},
flash: {
initial: { opacity: 0 },
animate: { opacity: [0, 0.85, 0], transition: { duration: 0.5, times: [0, 0.2, 1] } },
},
};
}
case 'polaroid_toss': {
const rotation = 3 + strength * 5;
return {
frame: {
initial: { opacity: 0, rotate: -rotation, scale: 0.9 },
animate: { opacity: 1, rotate: 0, scale: 1 },
exit: { opacity: 0, rotate: rotation * 0.5, scale: 0.98, transition: buildTransition(exitDuration, IOS_EASE) },
transition: buildTransition(baseDuration, IOS_EASE),
},
};
}
case 'parallax_glide': {
const scale = 1 + strength * 0.06;
return {
frame: {
initial: { opacity: 0, scale, y: 24 * strength },
animate: { opacity: 1, scale: 1, y: 0 },
exit: { opacity: 0, scale: 1.02, transition: buildTransition(exitDuration, IOS_EASE_SOFT) },
transition: buildTransition(baseDuration + 0.2, IOS_EASE_SOFT),
},
};
}
case 'light_effects': {
return {
frame: {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0, transition: buildTransition(exitDuration, IOS_EASE_SOFT) },
transition: buildTransition(baseDuration * 0.8, IOS_EASE_SOFT),
},
};
}
case 'film_cut':
default: {
const scale = 1 + strength * 0.03;
return {
frame: {
initial: { opacity: 0, scale },
animate: { opacity: 1, scale: 1 },
exit: { opacity: 0, scale: 0.99, transition: buildTransition(exitDuration, IOS_EASE) },
transition: buildTransition(baseDuration, IOS_EASE),
},
};
}
}
}

View File

@@ -0,0 +1,51 @@
import type { LocaleCode } from '../i18n/messages';
type LocalizedRecord = Record<string, string | null | undefined>;
function pickLocalizedValue(record: LocalizedRecord, locale: LocaleCode): string | null {
if (typeof record[locale] === 'string' && record[locale]) {
return record[locale] as string;
}
if (typeof record.de === 'string' && record.de) {
return record.de as string;
}
if (typeof record.en === 'string' && record.en) {
return record.en as string;
}
const firstValue = Object.values(record).find((value) => typeof value === 'string' && value);
return (firstValue as string | undefined) ?? null;
}
export function localizeTaskLabel(
raw: string | LocalizedRecord | null | undefined,
locale: LocaleCode,
): string | null {
if (!raw) {
return null;
}
if (typeof raw === 'object') {
return pickLocalizedValue(raw, locale);
}
const trimmed = raw.trim();
if (!trimmed) {
return null;
}
if (trimmed.startsWith('{')) {
try {
const parsed = JSON.parse(trimmed);
if (parsed && typeof parsed === 'object') {
return pickLocalizedValue(parsed as LocalizedRecord, locale) ?? trimmed;
}
} catch {
return trimmed;
}
}
return trimmed;
}

View File

@@ -0,0 +1,86 @@
import type { Variants } from 'framer-motion';
export const IOS_EASE = [0.22, 0.61, 0.36, 1] as const;
export const IOS_EASE_SOFT = [0.25, 0.8, 0.25, 1] as const;
export const STAGGER_FAST: Variants = {
hidden: {},
show: {
transition: {
staggerChildren: 0.06,
delayChildren: 0.04,
},
},
};
export const FADE_UP: Variants = {
hidden: { opacity: 0, y: 10 },
show: {
opacity: 1,
y: 0,
transition: {
duration: 0.24,
ease: IOS_EASE,
},
},
};
export const FADE_SCALE: Variants = {
hidden: { opacity: 0, scale: 0.98 },
show: {
opacity: 1,
scale: 1,
transition: {
duration: 0.22,
ease: IOS_EASE,
},
},
};
export function prefersReducedMotion(): boolean {
if (typeof window === 'undefined') {
return false;
}
return Boolean(window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches);
}
export function getMotionContainerProps(enabled: boolean, variants: Variants) {
if (!enabled) {
return { initial: false } as const;
}
return { variants, initial: 'hidden', animate: 'show' } as const;
}
export function getMotionItemProps(enabled: boolean, variants: Variants) {
return enabled ? { variants } : {};
}
export function getMotionContainerPropsForNavigation(
enabled: boolean,
variants: Variants,
navigationType: 'POP' | 'PUSH' | 'REPLACE'
) {
if (!enabled) {
return { initial: false } as const;
}
const initial = navigationType === 'POP' ? 'hidden' : false;
return { variants, initial, animate: 'show' } as const;
}
export function getMotionItemPropsForNavigation(
enabled: boolean,
variants: Variants,
navigationType: 'POP' | 'PUSH' | 'REPLACE'
) {
if (!enabled) {
return {};
}
const initial = navigationType === 'POP' ? 'hidden' : false;
return { variants, initial, animate: 'show' } as const;
}

View File

@@ -0,0 +1,94 @@
import type { TranslateFn } from '../i18n/useTranslation';
export type UploadErrorDialog = {
code: string;
title: string;
description: string;
hint?: string;
tone: 'danger' | 'warning' | 'info';
};
function formatWithNumbers(template: string, values: Record<string, number | string | undefined>): string {
return Object.entries(values).reduce((acc, [key, value]) => {
if (value === undefined) {
return acc;
}
return acc.replaceAll(`{${key}}`, String(value));
}, template);
}
export function resolveUploadErrorDialog(
code: string | undefined,
meta: Record<string, unknown> | undefined,
t: TranslateFn
): UploadErrorDialog {
const normalized = (code ?? 'unknown').toLowerCase();
const getNumber = (value: unknown): number | undefined => (typeof value === 'number' ? value : undefined);
switch (normalized) {
case 'photo_limit_exceeded': {
const used = getNumber(meta?.used);
const limit = getNumber(meta?.limit);
const remaining = getNumber(meta?.remaining);
return {
code: normalized,
tone: 'danger',
title: t('upload.dialogs.photoLimit.title'),
description: formatWithNumbers(t('upload.dialogs.photoLimit.description'), {
used,
limit,
remaining,
}),
hint: t('upload.dialogs.photoLimit.hint'),
};
}
case 'upload_device_limit':
return {
code: normalized,
tone: 'warning',
title: t('upload.dialogs.deviceLimit.title'),
description: t('upload.dialogs.deviceLimit.description'),
hint: t('upload.dialogs.deviceLimit.hint'),
};
case 'event_package_missing':
case 'event_not_found':
return {
code: normalized,
tone: 'info',
title: t('upload.dialogs.packageMissing.title'),
description: t('upload.dialogs.packageMissing.description'),
hint: t('upload.dialogs.packageMissing.hint'),
};
case 'gallery_expired':
return {
code: normalized,
tone: 'danger',
title: t('upload.dialogs.galleryExpired.title'),
description: t('upload.dialogs.galleryExpired.description'),
hint: t('upload.dialogs.galleryExpired.hint'),
};
case 'csrf_mismatch':
return {
code: normalized,
tone: 'warning',
title: t('upload.dialogs.csrf.title'),
description: t('upload.dialogs.csrf.description'),
hint: t('upload.dialogs.csrf.hint'),
};
default:
return {
code: normalized,
tone: 'info',
title: t('upload.dialogs.generic.title'),
description: t('upload.dialogs.generic.description'),
hint: t('upload.dialogs.generic.hint'),
};
}
}

View File

@@ -0,0 +1,47 @@
import React from 'react';
import { enqueue, list, processQueue, clearDone, remove, type QueueItem } from './queue';
export function useUploadQueue() {
const [items, setItems] = React.useState<QueueItem[]>([]);
const [loading, setLoading] = React.useState(true);
const refresh = React.useCallback(async () => {
setLoading(true);
const all = await list();
setItems(all);
setLoading(false);
}, []);
const add = React.useCallback(async (it: Parameters<typeof enqueue>[0]) => {
await enqueue(it);
await refresh();
await processQueue();
}, [refresh]);
const retryAll = React.useCallback(async () => {
await processQueue();
await refresh();
}, [refresh]);
const clearFinished = React.useCallback(async () => {
await clearDone();
await refresh();
}, [refresh]);
const removeItem = React.useCallback(
async (id: number) => {
await remove(id);
await refresh();
},
[refresh]
);
React.useEffect(() => {
refresh();
const online = () => processQueue().then(refresh);
window.addEventListener('online', online);
return () => window.removeEventListener('online', online);
}, [refresh]);
return { items, loading, refresh, add, retryAll, clearFinished, remove: removeItem } as const;
}

View File

@@ -0,0 +1,34 @@
// @ts-nocheck
export type TxMode = 'readonly' | 'readwrite';
export function openDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const req = indexedDB.open('guest-upload-queue', 1);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains('items')) {
const store = db.createObjectStore('items', { keyPath: 'id', autoIncrement: true });
store.createIndex('status', 'status', { unique: false });
store.createIndex('nextAttemptAt', 'nextAttemptAt', { unique: false });
}
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
export async function withStore<T>(mode: TxMode, fn: (store: IDBObjectStore) => void | Promise<T>): Promise<T> {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction('items', mode);
const store = tx.objectStore('items');
let result: unknown;
const wrap = async () => {
try { result = await fn(store); } catch (e) { reject(e); }
};
wrap();
tx.oncomplete = () => resolve(result);
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error);
});
}

View File

@@ -0,0 +1,16 @@
export function notify(text: string, type: 'success'|'error') {
// Lazy import to avoid cycle
import('../components/ToastHost')
.then(() => {
try {
// This only works inside React tree; for SW-triggered, we fallback
const evt = new CustomEvent('guest-toast', { detail: { text, type } });
window.dispatchEvent(evt);
} catch (error) {
console.warn('Dispatching toast event failed', error);
}
})
.catch((error) => {
console.warn('Toast module failed to load', error);
});
}

View File

@@ -0,0 +1,134 @@
// @ts-nocheck
import { withStore } from './idb';
import { getDeviceId } from '../lib/device';
import { createUpload } from './xhr';
import { notify } from './notify';
type SyncManager = { register(tag: string): Promise<void>; };
export const buildQueueUploadUrl = (eventToken: string) =>
`/api/v1/events/${encodeURIComponent(eventToken)}/upload`;
export type QueueItem = {
id?: number;
eventToken: string;
fileName: string;
blob: Blob;
emotion_id?: number | null;
task_id?: number | null;
live_show_opt_in?: boolean | null;
status: 'pending' | 'uploading' | 'done' | 'error';
retries: number;
nextAttemptAt?: number | null;
createdAt: number;
photoId?: number;
};
let processing = false;
export async function enqueue(item: Omit<QueueItem, 'id' | 'status' | 'retries' | 'createdAt'>) {
const now = Date.now();
await withStore('readwrite', (store) => {
store.add({ ...item, status: 'pending', retries: 0, createdAt: now });
});
// Register background sync if available
if ('serviceWorker' in navigator && 'SyncManager' in window) {
try {
const reg = await navigator.serviceWorker.ready;
(reg as ServiceWorkerRegistration & { sync?: SyncManager }).sync?.register('upload-queue');
} catch (error) {
console.warn('Background sync registration failed', error);
}
}
}
export async function list(): Promise<QueueItem[]> {
return withStore('readonly', (store) => new Promise((resolve) => {
const req = store.getAll();
req.onsuccess = () => resolve(req.result as QueueItem[]);
}));
}
export async function clearDone() {
const items = await list();
await withStore('readwrite', (store) => {
for (const it of items) {
if (it.status === 'done') store.delete(it.id!);
}
});
}
export async function remove(id: number) {
await withStore('readwrite', (store) => {
store.delete(id);
});
}
export async function processQueue() {
if (processing) return; processing = true;
try {
const now = Date.now();
const items = await list();
for (const it of items) {
if (it.status === 'done') continue;
if (it.nextAttemptAt && it.nextAttemptAt > now) continue;
await markStatus(it.id!, 'uploading');
const ok = await attemptUpload(it);
if (ok) {
await markStatus(it.id!, 'done');
} else {
const retries = (it.retries ?? 0) + 1;
const backoffSec = Math.min(60, Math.pow(2, Math.min(retries, 5))); // 2,4,8,16,32,60
await update(it.id!, { status: 'error', retries, nextAttemptAt: Date.now() + backoffSec * 1000 });
}
}
} finally {
processing = false;
}
}
async function attemptUpload(it: QueueItem): Promise<boolean> {
if (!navigator.onLine) return false;
try {
const json = await createUpload(
buildQueueUploadUrl(it.eventToken),
it,
getDeviceId(),
(pct) => {
try {
window.dispatchEvent(new CustomEvent('queue-progress', { detail: { id: it.id, progress: pct } }));
} catch (error) {
console.warn('Queue progress dispatch failed', error);
}
}
);
// mark my-photo-ids for "Meine"
try {
const raw = localStorage.getItem('my-photo-ids');
const arr: number[] = raw ? JSON.parse(raw) : [];
if (json.id && !arr.includes(json.id)) localStorage.setItem('my-photo-ids', JSON.stringify([json.id, ...arr]));
} catch (error) {
console.warn('Failed to persist my-photo-ids', error);
}
notify('Upload erfolgreich', 'success');
return true;
} catch {
notify('Upload fehlgeschlagen', 'error');
return false;
}
}
async function markStatus(id: number, status: QueueItem['status']) {
await update(id, { status });
}
async function update(id: number, patch: Partial<QueueItem>) {
await withStore('readwrite', (store) => new Promise<void>((resolve, reject) => {
const getReq = store.get(id);
getReq.onsuccess = () => {
const val = getReq.result as QueueItem;
store.put({ ...val, ...patch, id });
resolve();
};
getReq.onerror = () => reject(getReq.error);
}));
}

View File

@@ -0,0 +1,45 @@
import type { QueueItem } from './queue';
import { buildCsrfHeaders } from '../lib/csrf';
export async function createUpload(
url: string,
it: QueueItem,
deviceId: string,
onProgress?: (percent: number) => void
): Promise<Record<string, unknown>> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', url, true);
const headers = buildCsrfHeaders(deviceId);
Object.entries(headers).forEach(([key, value]) => {
xhr.setRequestHeader(key, value);
});
const form = new FormData();
form.append('photo', it.blob, it.fileName);
if (it.emotion_id) form.append('emotion_id', String(it.emotion_id));
if (it.task_id) form.append('task_id', String(it.task_id));
if (typeof it.live_show_opt_in === 'boolean') {
form.append('live_show_opt_in', it.live_show_opt_in ? '1' : '0');
}
xhr.upload.onprogress = (ev) => {
if (onProgress && ev.lengthComputable) {
const pct = Math.min(100, Math.round((ev.loaded / ev.total) * 100));
onProgress(pct);
}
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
resolve(JSON.parse(xhr.responseText));
} catch (error) {
console.warn('Upload response parse failed', error);
resolve({});
}
} else {
reject(new Error('upload failed'));
}
};
xhr.onerror = () => reject(new Error('network error'));
xhr.send(form);
});
}

View File

@@ -0,0 +1,271 @@
// @ts-nocheck
import { getDeviceId } from '../lib/device';
import { DEFAULT_LOCALE } from '../i18n/messages';
export interface AchievementBadge {
id: string;
title: string;
description: string;
earned: boolean;
progress: number;
target: number;
}
export interface LeaderboardEntry {
guest: string;
photos: number;
likes: number;
}
export interface TopPhotoHighlight {
photoId: number;
guest: string;
likes: number;
task?: string | null;
createdAt: string;
thumbnail: string | null;
}
export interface TrendingEmotionHighlight {
emotionId: number;
name: string;
count: number;
}
export interface TimelinePoint {
date: string;
photos: number;
guests: number;
}
export interface FeedEntry {
photoId: number;
guest: string;
task?: string | null;
likes: number;
createdAt: string;
thumbnail: string | null;
}
export interface AchievementsPayload {
summary: {
totalPhotos: number;
uniqueGuests: number;
tasksSolved: number;
likesTotal: number;
};
personal: {
guestName: string;
photos: number;
tasks: number;
likes: number;
badges: AchievementBadge[];
} | null;
leaderboards: {
uploads: LeaderboardEntry[];
likes: LeaderboardEntry[];
};
highlights: {
topPhoto: TopPhotoHighlight | null;
trendingEmotion: TrendingEmotionHighlight | null;
timeline: TimelinePoint[];
};
feed: FeedEntry[];
}
function toNumber(value: unknown, fallback = 0): number {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string' && value !== '') {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
return fallback;
}
function safeString(value: unknown): string {
return typeof value === 'string' ? value : '';
}
type FetchAchievementsOptions = {
guestName?: string;
locale?: string;
signal?: AbortSignal;
forceRefresh?: boolean;
};
type AchievementsCacheEntry = {
data: AchievementsPayload;
etag: string | null;
};
const achievementsCache = new Map<string, AchievementsCacheEntry>();
export async function fetchAchievements(
eventToken: string,
options: FetchAchievementsOptions = {}
): Promise<AchievementsPayload> {
const { guestName, signal, forceRefresh } = options;
const locale = options.locale ?? DEFAULT_LOCALE;
const params = new URLSearchParams();
if (guestName && guestName.trim().length > 0) {
params.set('guest_name', guestName.trim());
}
if (locale) {
params.set('locale', locale);
}
const deviceId = getDeviceId();
const cacheKey = [eventToken, locale, guestName?.trim() ?? '', deviceId].join(':');
const cached = forceRefresh ? null : achievementsCache.get(cacheKey);
const headers: HeadersInit = {
'X-Device-Id': deviceId,
'Cache-Control': 'no-store',
Accept: 'application/json',
'X-Locale': locale,
};
if (cached?.etag) {
headers['If-None-Match'] = cached.etag;
}
const response = await fetch(`/api/v1/events/${encodeURIComponent(eventToken)}/achievements?${params.toString()}`, {
method: 'GET',
headers,
signal,
});
if (response.status === 304 && cached) {
return cached.data;
}
if (!response.ok) {
const message = await response.text();
throw new Error(message || 'Achievements request failed');
}
const json = await response.json();
const summary = json.summary ?? {};
const personalRaw = json.personal ?? null;
const leaderboards = json.leaderboards ?? {};
const highlights = json.highlights ?? {};
const feedRaw = Array.isArray(json.feed) ? json.feed : [];
const personal = personalRaw
? {
guestName: safeString(personalRaw.guest_name),
photos: toNumber(personalRaw.photos),
tasks: toNumber(personalRaw.tasks),
likes: toNumber(personalRaw.likes),
badges: Array.isArray(personalRaw.badges)
? personalRaw.badges.map((badge): AchievementBadge => {
const record = badge as Record<string, unknown>;
return {
id: safeString(record.id),
title: safeString(record.title),
description: safeString(record.description),
earned: Boolean(record.earned),
progress: toNumber(record.progress),
target: toNumber(record.target, 1),
};
})
: [],
}
: null;
const uploadsBoard = Array.isArray(leaderboards.uploads)
? leaderboards.uploads.map((row): LeaderboardEntry => {
const record = row as Record<string, unknown>;
return {
guest: safeString(record.guest),
photos: toNumber(record.photos),
likes: toNumber(record.likes),
};
})
: [];
const likesBoard = Array.isArray(leaderboards.likes)
? leaderboards.likes.map((row): LeaderboardEntry => {
const record = row as Record<string, unknown>;
return {
guest: safeString(record.guest),
photos: toNumber(record.photos),
likes: toNumber(record.likes),
};
})
: [];
const topPhotoRaw = highlights.top_photo ?? null;
const topPhoto = topPhotoRaw
? {
photoId: toNumber(topPhotoRaw.photo_id),
guest: safeString(topPhotoRaw.guest),
likes: toNumber(topPhotoRaw.likes),
task: topPhotoRaw.task ?? null,
createdAt: safeString(topPhotoRaw.created_at),
thumbnail: topPhotoRaw.thumbnail ? safeString(topPhotoRaw.thumbnail) : null,
}
: null;
const trendingRaw = highlights.trending_emotion ?? null;
const trendingEmotion = trendingRaw
? {
emotionId: toNumber(trendingRaw.emotion_id),
name: safeString(trendingRaw.name),
count: toNumber(trendingRaw.count),
}
: null;
const timeline = Array.isArray(highlights.timeline)
? highlights.timeline.map((row): TimelinePoint => {
const record = row as Record<string, unknown>;
return {
date: safeString(record.date),
photos: toNumber(record.photos),
guests: toNumber(record.guests),
};
})
: [];
const feed = feedRaw.map((row): FeedEntry => {
const record = row as Record<string, unknown>;
return {
photoId: toNumber(record.photo_id),
guest: safeString(record.guest),
task: (record as { task?: string }).task ?? null,
likes: toNumber(record.likes),
createdAt: safeString(record.created_at),
thumbnail: record.thumbnail ? safeString(record.thumbnail) : null,
};
});
const payload: AchievementsPayload = {
summary: {
totalPhotos: toNumber(summary.total_photos),
uniqueGuests: toNumber(summary.unique_guests),
tasksSolved: toNumber(summary.tasks_solved),
likesTotal: toNumber(summary.likes_total),
},
personal,
leaderboards: {
uploads: uploadsBoard,
likes: likesBoard,
},
highlights: {
topPhoto,
trendingEmotion,
timeline,
},
feed,
};
achievementsCache.set(cacheKey, {
data: payload,
etag: response.headers.get('ETag'),
});
return payload;
}

View File

@@ -0,0 +1,362 @@
import { getDeviceId } from '../lib/device';
import { DEFAULT_LOCALE } from '../i18n/messages';
export interface EventBrandingPayload {
primary_color?: string | null;
secondary_color?: string | null;
background_color?: string | null;
font_family?: string | null;
logo_url?: string | null;
surface_color?: string | null;
heading_font?: string | null;
body_font?: string | null;
font_size?: 's' | 'm' | 'l' | null;
welcome_message?: string | null;
icon?: string | null;
logo_mode?: 'emoticon' | 'upload' | null;
logo_value?: string | null;
logo_position?: 'left' | 'right' | 'center' | null;
logo_size?: 's' | 'm' | 'l' | null;
button_style?: 'filled' | 'outline' | null;
button_radius?: number | null;
button_primary_color?: string | null;
button_secondary_color?: string | null;
link_color?: string | null;
mode?: 'light' | 'dark' | 'auto' | null;
use_default_branding?: boolean | null;
palette?: {
primary?: string | null;
secondary?: string | null;
background?: string | null;
surface?: string | null;
} | null;
typography?: {
heading?: string | null;
body?: string | null;
size?: 's' | 'm' | 'l' | null;
} | null;
logo?: {
mode?: 'emoticon' | 'upload';
value?: string | null;
position?: 'left' | 'right' | 'center';
size?: 's' | 'm' | 'l';
} | null;
buttons?: {
style?: 'filled' | 'outline';
radius?: number | null;
primary?: string | null;
secondary?: string | null;
link_color?: string | null;
} | null;
}
export interface EventData {
id: number;
slug: string;
name: string;
default_locale: string;
engagement_mode?: 'tasks' | 'photo_only' | 'no_tasks';
created_at: string;
updated_at: string;
join_token?: string | null;
demo_read_only?: boolean;
photobooth_enabled?: boolean | null;
type?: {
slug: string;
name: string;
icon: string | null;
};
branding?: EventBrandingPayload | null;
guest_upload_visibility?: 'immediate' | 'review';
live_show?: {
moderation_mode?: 'off' | 'manual' | 'trusted_only';
};
}
export interface PackageData {
id: number;
name: string;
max_photos: number;
max_guests?: number | null;
gallery_days?: number | null;
}
export interface LimitUsageSummary {
limit: number | null;
used: number;
remaining: number | null;
percentage: number | null;
state: 'ok' | 'warning' | 'limit_reached' | 'unlimited';
threshold_reached: number | null;
next_threshold: number | null;
thresholds: number[];
}
export interface GallerySummary {
state: 'ok' | 'warning' | 'expired' | 'unlimited';
expires_at: string | null;
days_remaining: number | null;
warning_thresholds: number[];
warning_triggered: number | null;
warning_sent_at: string | null;
expired_notified_at: string | null;
}
export interface EventPackageLimits {
photos: LimitUsageSummary | null;
guests: LimitUsageSummary | null;
gallery: GallerySummary | null;
can_upload_photos: boolean;
can_add_guests: boolean;
}
export interface EventPackage {
id: number;
event_id?: number;
package_id?: number;
used_photos: number;
used_guests?: number;
expires_at: string | null;
package: PackageData | null;
limits: EventPackageLimits | null;
}
export interface EventStats {
onlineGuests: number;
tasksSolved: number;
guestCount: number;
likesCount: number;
latestPhotoAt: string | null;
}
export type FetchEventErrorCode =
| 'invalid_token'
| 'token_expired'
| 'token_revoked'
| 'token_rate_limited'
| 'access_rate_limited'
| 'guest_limit_exceeded'
| 'gallery_expired'
| 'event_not_public'
| 'network_error'
| 'server_error'
| 'unknown';
interface FetchEventErrorOptions {
code: FetchEventErrorCode;
message: string;
status?: number;
}
export class FetchEventError extends Error {
readonly code: FetchEventErrorCode;
readonly status?: number;
constructor({ code, message, status }: FetchEventErrorOptions) {
super(message);
this.name = 'FetchEventError';
this.code = code;
this.status = status;
}
}
function coerceLocalized(value: unknown, fallback: string): string {
if (typeof value === 'string' && value.trim() !== '') {
return value;
}
if (value && typeof value === 'object') {
const obj = value as Record<string, unknown>;
const preferredKeys = ['de', 'en'];
for (const key of preferredKeys) {
const candidate = obj[key];
if (typeof candidate === 'string' && candidate.trim() !== '') {
return candidate;
}
}
const firstString = Object.values(obj).find((candidate) => typeof candidate === 'string' && candidate.trim() !== '');
if (typeof firstString === 'string') {
return firstString;
}
}
return fallback;
}
const API_ERROR_CODES: FetchEventErrorCode[] = [
'invalid_token',
'token_expired',
'token_revoked',
'token_rate_limited',
'access_rate_limited',
'guest_limit_exceeded',
'gallery_expired',
'event_not_public',
];
function resolveErrorCode(rawCode: unknown, status: number): FetchEventErrorCode {
if (typeof rawCode === 'string') {
const normalized = rawCode.toLowerCase() as FetchEventErrorCode;
if ((API_ERROR_CODES as string[]).includes(normalized)) {
return normalized;
}
}
if (status === 429) return rawCode === 'access_rate_limited' ? 'access_rate_limited' : 'token_rate_limited';
if (status === 404) return 'event_not_public';
if (status === 410) return rawCode === 'gallery_expired' ? 'gallery_expired' : 'token_expired';
if (status === 401) return 'invalid_token';
if (status === 403) return 'token_revoked';
if (status >= 500) return 'server_error';
return 'unknown';
}
function defaultMessageForCode(code: FetchEventErrorCode): string {
switch (code) {
case 'invalid_token':
return 'Der eingegebene Zugriffscode ist ungültig.';
case 'token_revoked':
return 'Dieser Zugriffscode wurde deaktiviert. Bitte fordere einen neuen Code an.';
case 'token_expired':
return 'Dieser Zugriffscode ist abgelaufen.';
case 'token_rate_limited':
return 'Zu viele Versuche in kurzer Zeit. Bitte warte einen Moment und versuche es erneut.';
case 'access_rate_limited':
return 'Zu viele Aufrufe in kurzer Zeit. Bitte warte einen Moment und versuche es erneut.';
case 'guest_limit_exceeded':
return 'Dieses Event hat sein Gäste-Limit erreicht. Bitte kontaktiere die Veranstalter:innen.';
case 'gallery_expired':
return 'Die Galerie ist nicht mehr verfügbar.';
case 'event_not_public':
return 'Dieses Event ist nicht öffentlich verfügbar.';
case 'network_error':
return 'Keine Verbindung zum Server. Prüfe deine Internetverbindung und versuche es erneut.';
case 'server_error':
return 'Der Server ist gerade nicht erreichbar. Bitte versuche es später erneut.';
case 'unknown':
default:
return 'Event konnte nicht geladen werden.';
}
}
export async function fetchEvent(eventKey: string): Promise<EventData> {
try {
const res = await fetch(`/api/v1/events/${encodeURIComponent(eventKey)}`, {
headers: {
'X-Device-Id': getDeviceId(),
},
});
if (!res.ok) {
let apiMessage: string | null = null;
let rawCode: unknown;
try {
const data = await res.json();
rawCode = data?.error?.code ?? data?.code;
const message = data?.error?.message ?? data?.message;
if (typeof message === 'string' && message.trim() !== '') {
apiMessage = message.trim();
}
} catch {
// ignore parse errors and fall back to defaults
}
const code = resolveErrorCode(rawCode, res.status);
const message = apiMessage ?? defaultMessageForCode(code);
throw new FetchEventError({
code,
message,
status: res.status,
});
}
const json = await res.json();
const moderationMode = json?.live_show?.moderation_mode;
const normalized: EventData = {
...json,
name: coerceLocalized(json?.name, 'Fotospiel Event'),
default_locale: typeof json?.default_locale === 'string' && json.default_locale.trim() !== ''
? json.default_locale
: DEFAULT_LOCALE,
engagement_mode: (json?.engagement_mode as 'tasks' | 'photo_only' | 'no_tasks' | undefined) ?? 'tasks',
guest_upload_visibility:
json?.guest_upload_visibility === 'immediate' ? 'immediate' : 'review',
live_show: {
moderation_mode: moderationMode === 'off' || moderationMode === 'manual' || moderationMode === 'trusted_only'
? moderationMode
: 'manual',
},
demo_read_only: Boolean(json?.demo_read_only),
};
if (json?.type) {
normalized.type = {
...json.type,
name: coerceLocalized(json.type?.name, 'Event'),
};
}
return normalized;
} catch (error) {
if (error instanceof FetchEventError) {
throw error;
}
if (error instanceof TypeError) {
throw new FetchEventError({
code: 'network_error',
message: defaultMessageForCode('network_error'),
status: 0,
});
}
if (error instanceof Error) {
throw new FetchEventError({
code: 'unknown',
message: error.message || defaultMessageForCode('unknown'),
status: 0,
});
}
throw new FetchEventError({
code: 'unknown',
message: defaultMessageForCode('unknown'),
status: 0,
});
}
}
export async function fetchStats(eventKey: string): Promise<EventStats> {
const res = await fetch(`/api/v1/events/${encodeURIComponent(eventKey)}/stats`, {
headers: {
'X-Device-Id': getDeviceId(),
},
});
if (!res.ok) throw new Error('Stats fetch failed');
const json = await res.json();
return {
onlineGuests: json.online_guests ?? json.onlineGuests ?? 0,
tasksSolved: json.tasks_solved ?? json.tasksSolved ?? 0,
guestCount: json.guest_count ?? json.guestCount ?? 0,
likesCount: json.likes_count ?? json.likesCount ?? 0,
latestPhotoAt: json.latest_photo_at ?? json.latestPhotoAt ?? null,
};
}
export async function getEventPackage(eventToken: string): Promise<EventPackage | null> {
const res = await fetch(`/api/v1/events/${encodeURIComponent(eventToken)}/package`);
if (!res.ok) {
if (res.status === 404) return null;
throw new Error('Failed to load event package');
}
const payload = await res.json();
return {
...payload,
limits: payload?.limits ?? null,
} as EventPackage;
}

View File

@@ -0,0 +1,113 @@
import type { LocaleCode } from '../i18n/messages';
import type { EventBrandingPayload } from './eventApi';
export type GalleryBranding = EventBrandingPayload;
export interface GalleryMetaResponse {
event: {
id: number;
name: string;
slug?: string | null;
description?: string | null;
gallery_expires_at?: string | null;
guest_downloads_enabled?: boolean;
guest_sharing_enabled?: boolean;
};
branding: GalleryBranding;
}
export interface GalleryPhotoResource {
id: number;
thumbnail_url: string | null;
full_url: string | null;
download_url: string;
likes_count: number;
guest_name?: string | null;
created_at?: string | null;
}
export interface GalleryPhotosResponse {
data: GalleryPhotoResource[];
next_cursor: string | null;
}
async function handleResponse<T>(response: Response): Promise<T> {
if (response.status === 204) {
return {} as T;
}
const data = await response.json().catch(() => null);
if (!response.ok) {
const errorPayload = data as { error?: { message?: string; code?: unknown } } | null;
const error = new Error(errorPayload?.error?.message ?? 'Request failed') as Error & { code?: unknown };
error.code = errorPayload?.error?.code ?? response.status;
throw error;
}
return data as T;
}
function coerceLocalized(value: unknown, fallback: string): string {
if (typeof value === 'string' && value.trim() !== '') {
return value;
}
if (value && typeof value === 'object') {
const obj = value as Record<string, unknown>;
const preferred = ['de', 'en'];
for (const key of preferred) {
const candidate = obj[key];
if (typeof candidate === 'string' && candidate.trim() !== '') {
return candidate;
}
}
const firstString = Object.values(obj).find((candidate) => typeof candidate === 'string' && candidate.trim() !== '');
if (typeof firstString === 'string') {
return firstString;
}
}
return fallback;
}
export async function fetchGalleryMeta(token: string, locale?: LocaleCode): Promise<GalleryMetaResponse> {
const params = new URLSearchParams();
if (locale) params.set('locale', locale);
const response = await fetch(`/api/v1/gallery/${encodeURIComponent(token)}${params.toString() ? `?${params.toString()}` : ''}`, {
headers: {
'Accept': 'application/json',
},
credentials: 'omit',
});
const data = await handleResponse<GalleryMetaResponse>(response);
if (data?.event) {
data.event = {
...data.event,
name: coerceLocalized((data.event as any).name, 'Fotospiel Event'),
description: coerceLocalized((data.event as any).description, ''),
};
}
return data;
}
export async function fetchGalleryPhotos(token: string, cursor?: string | null, limit = 30): Promise<GalleryPhotosResponse> {
const params = new URLSearchParams();
params.set('limit', String(limit));
if (cursor) {
params.set('cursor', cursor);
}
const response = await fetch(`/api/v1/gallery/${encodeURIComponent(token)}/photos?${params.toString()}`, {
headers: {
'Accept': 'application/json',
},
credentials: 'omit',
});
return handleResponse<GalleryPhotosResponse>(response);
}

View File

@@ -0,0 +1,162 @@
import type { LocaleCode } from '../i18n/messages';
export type HelpArticleSummary = {
slug: string;
title: string;
summary: string;
version_introduced?: string;
requires_app_version?: string | null;
status?: string;
translation_state?: string;
last_reviewed_at?: string;
owner?: string;
updated_at?: string;
related?: Array<{ slug: string; title?: string }>;
};
export type HelpArticleDetail = HelpArticleSummary & {
body_markdown?: string;
body_html?: string;
source_path?: string;
};
export interface HelpListResult {
articles: HelpArticleSummary[];
servedFromCache: boolean;
}
export interface HelpArticleResult {
article: HelpArticleDetail;
servedFromCache: boolean;
}
const AUDIENCE = 'guest';
const LIST_CACHE_TTL = 1000 * 60 * 60 * 6; // 6 hours
const DETAIL_CACHE_TTL = 1000 * 60 * 60 * 24; // 24 hours
interface CacheRecord<T> {
storedAt: number;
data: T;
}
function listCacheKey(locale: LocaleCode): string {
return `help:list:${AUDIENCE}:${locale}`;
}
function detailCacheKey(locale: LocaleCode, slug: string): string {
return `help:article:${AUDIENCE}:${locale}:${slug}`;
}
function readCache<T>(key: string, ttl: number): CacheRecord<T> | null {
if (typeof window === 'undefined') {
return null;
}
try {
const raw = window.localStorage.getItem(key);
if (!raw) {
return null;
}
const parsed = JSON.parse(raw) as CacheRecord<T>;
if (!parsed?.data || !parsed?.storedAt) {
return null;
}
if (Date.now() - parsed.storedAt > ttl) {
return null;
}
return parsed;
} catch (error) {
console.warn('[HelpApi] Failed to read cache', error);
return null;
}
}
function writeCache<T>(key: string, data: T): void {
if (typeof window === 'undefined') {
return;
}
try {
const payload: CacheRecord<T> = {
storedAt: Date.now(),
data,
};
window.localStorage.setItem(key, JSON.stringify(payload));
} catch (error) {
console.warn('[HelpApi] Failed to write cache', error);
}
}
async function requestJson<T>(url: string): Promise<T> {
const response = await fetch(url, {
headers: {
'Accept': 'application/json',
},
credentials: 'same-origin',
});
if (!response.ok) {
const error = new Error('Help request failed') as Error & { status?: number };
error.status = response.status;
throw error;
}
const payload = await response.json();
return payload as T;
}
export async function getHelpArticles(locale: LocaleCode, options?: { forceRefresh?: boolean }): Promise<HelpListResult> {
const cacheKey = listCacheKey(locale);
const cached = readCache<HelpArticleSummary[]>(cacheKey, LIST_CACHE_TTL);
if (cached && !options?.forceRefresh) {
return { articles: cached.data, servedFromCache: true };
}
try {
const params = new URLSearchParams({
audience: AUDIENCE,
locale,
});
const data = await requestJson<{ data?: HelpArticleSummary[] }>(`/api/v1/help?${params.toString()}`);
const articles = Array.isArray(data?.data) ? data.data : [];
writeCache(cacheKey, articles);
return { articles, servedFromCache: false };
} catch (error) {
if (cached) {
return { articles: cached.data, servedFromCache: true };
}
console.error('[HelpApi] Failed to fetch help articles', error);
throw error;
}
}
export async function getHelpArticle(slug: string, locale: LocaleCode): Promise<HelpArticleResult> {
const cacheKey = detailCacheKey(locale, slug);
const cached = readCache<HelpArticleDetail>(cacheKey, DETAIL_CACHE_TTL);
if (cached) {
return { article: cached.data, servedFromCache: true };
}
try {
const params = new URLSearchParams({
audience: AUDIENCE,
locale,
});
const data = await requestJson<{ data?: HelpArticleDetail }>(`/api/v1/help/${encodeURIComponent(slug)}?${params.toString()}`);
const article: HelpArticleDetail | undefined = data?.data;
const safeArticle: HelpArticleDetail = article ?? { slug, title: slug, summary: '' };
writeCache(cacheKey, safeArticle);
return { article: safeArticle, servedFromCache: false };
} catch (error) {
const cachedArticle: HelpArticleDetail | undefined = (cached as { data?: HelpArticleDetail } | null | undefined)?.data;
if (cachedArticle) {
return { article: cachedArticle, servedFromCache: true };
}
console.error('[HelpApi] Failed to fetch help article', error);
throw error;
}
}

View File

@@ -0,0 +1,302 @@
export type LiveShowModerationMode = 'off' | 'manual' | 'trusted_only';
export type LiveShowPlaybackMode = 'newest_first' | 'balanced' | 'curated';
export type LiveShowPaceMode = 'auto' | 'fixed';
export type LiveShowLayoutMode = 'single' | 'split' | 'grid_burst';
export type LiveShowEffectPreset =
| 'film_cut'
| 'shutter_flash'
| 'polaroid_toss'
| 'parallax_glide'
| 'light_effects';
export type LiveShowBackgroundMode = 'blur_last' | 'gradient' | 'solid' | 'brand';
export type LiveShowSettings = {
retention_window_hours: number;
moderation_mode: LiveShowModerationMode;
playback_mode: LiveShowPlaybackMode;
pace_mode: LiveShowPaceMode;
fixed_interval_seconds: number;
layout_mode: LiveShowLayoutMode;
effect_preset: LiveShowEffectPreset;
effect_intensity: number;
background_mode: LiveShowBackgroundMode;
};
export type LiveShowCursor = {
approved_at: string | null;
id: number;
};
export type LiveShowEvent = {
id: number;
slug?: string | null;
name: string;
default_locale?: string | null;
};
export type LiveShowPhoto = {
id: number;
full_url: string;
thumb_url: string | null;
approved_at: string | null;
width?: number | null;
height?: number | null;
is_featured: boolean;
live_priority: number;
};
export type LiveShowState = {
event: LiveShowEvent;
settings: LiveShowSettings;
settings_version: string;
photos: LiveShowPhoto[];
cursor: LiveShowCursor | null;
};
export type LiveShowUpdates = {
settings: LiveShowSettings | null;
settings_version: string;
photos: LiveShowPhoto[];
cursor: LiveShowCursor | null;
};
export type LiveShowErrorCode = 'not_found' | 'invalid_cursor' | 'rate_limited' | 'unknown';
export class LiveShowError extends Error {
readonly code: LiveShowErrorCode;
readonly status?: number;
constructor(code: LiveShowErrorCode, message: string, status?: number) {
super(message);
this.name = 'LiveShowError';
this.code = code;
this.status = status;
}
}
export const DEFAULT_LIVE_SHOW_SETTINGS: LiveShowSettings = {
retention_window_hours: 12,
moderation_mode: 'manual',
playback_mode: 'newest_first',
pace_mode: 'auto',
fixed_interval_seconds: 8,
layout_mode: 'single',
effect_preset: 'film_cut',
effect_intensity: 70,
background_mode: 'blur_last',
};
const DEFAULT_EVENT_NAME = 'Fotospiel Live Show';
function coerceLocalized(value: unknown, fallback: string): string {
if (typeof value === 'string' && value.trim() !== '') {
return value;
}
if (value && typeof value === 'object') {
const obj = value as Record<string, unknown>;
const preferredKeys = ['de', 'en'];
for (const key of preferredKeys) {
const candidate = obj[key];
if (typeof candidate === 'string' && candidate.trim() !== '') {
return candidate;
}
}
const firstString = Object.values(obj).find((candidate) => typeof candidate === 'string' && candidate.trim() !== '');
if (typeof firstString === 'string') {
return firstString;
}
}
return fallback;
}
function toNumber(value: unknown, fallback: number): number {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
export function normalizeLiveShowSettings(raw?: Partial<LiveShowSettings> | null): LiveShowSettings {
const merged = {
...DEFAULT_LIVE_SHOW_SETTINGS,
...(raw ?? {}),
};
return {
retention_window_hours: toNumber(merged.retention_window_hours, DEFAULT_LIVE_SHOW_SETTINGS.retention_window_hours),
moderation_mode: merged.moderation_mode,
playback_mode: merged.playback_mode,
pace_mode: merged.pace_mode,
fixed_interval_seconds: toNumber(merged.fixed_interval_seconds, DEFAULT_LIVE_SHOW_SETTINGS.fixed_interval_seconds),
layout_mode: merged.layout_mode,
effect_preset: merged.effect_preset,
effect_intensity: toNumber(merged.effect_intensity, DEFAULT_LIVE_SHOW_SETTINGS.effect_intensity),
background_mode: merged.background_mode,
};
}
function normalizeLiveShowEvent(raw: Record<string, unknown>): LiveShowEvent {
return {
id: Number(raw.id ?? 0),
slug: typeof raw.slug === 'string' ? raw.slug : null,
name: coerceLocalized(raw.name, DEFAULT_EVENT_NAME),
default_locale: typeof raw.default_locale === 'string' ? raw.default_locale : null,
};
}
function normalizeLiveShowPhoto(raw: Record<string, unknown>): LiveShowPhoto {
return {
id: Number(raw.id ?? 0),
full_url: String(raw.full_url ?? ''),
thumb_url: typeof raw.thumb_url === 'string' ? raw.thumb_url : null,
approved_at: typeof raw.approved_at === 'string' ? raw.approved_at : null,
width: typeof raw.width === 'number' ? raw.width : null,
height: typeof raw.height === 'number' ? raw.height : null,
is_featured: Boolean(raw.is_featured),
live_priority: Number(raw.live_priority ?? 0),
};
}
function normalizeCursor(raw: Record<string, unknown> | null): LiveShowCursor | null {
if (!raw) {
return null;
}
return {
approved_at: typeof raw.approved_at === 'string' ? raw.approved_at : null,
id: Number(raw.id ?? 0),
};
}
function resolveErrorCode(status: number, payload: Record<string, unknown> | null): LiveShowErrorCode {
const error = typeof payload?.error === 'string' ? payload.error : null;
if (error === 'live_show_not_found' || status === 404) {
return 'not_found';
}
if (error === 'invalid_cursor' || status === 422) {
return 'invalid_cursor';
}
if (status === 429) {
return 'rate_limited';
}
return 'unknown';
}
async function handleResponse<T>(response: Response): Promise<T> {
if (response.status === 204) {
return {} as T;
}
const data = await response.json().catch(() => null);
if (!response.ok) {
const payload = data as Record<string, unknown> | null;
const code = resolveErrorCode(response.status, payload);
const message =
typeof payload?.message === 'string'
? payload.message
: typeof payload?.error === 'string'
? payload.error
: 'Live show request failed';
throw new LiveShowError(code, message, response.status);
}
return data as T;
}
function buildParams(options: {
cursor?: LiveShowCursor | null;
settingsVersion?: string;
limit?: number;
} = {}): URLSearchParams {
const params = new URLSearchParams();
if (options.limit) {
params.set('limit', String(options.limit));
}
if (options.settingsVersion) {
params.set('settings_version', options.settingsVersion);
}
if (options.cursor?.approved_at) {
params.set('after_approved_at', options.cursor.approved_at);
params.set('after_id', String(options.cursor.id));
}
return params;
}
export function buildLiveShowStreamUrl(
token: string,
options: {
cursor?: LiveShowCursor | null;
settingsVersion?: string;
limit?: number;
} = {}
): string {
const params = buildParams(options);
const base = `/api/v1/live-show/${encodeURIComponent(token)}/stream`;
const query = params.toString();
return query ? `${base}?${query}` : base;
}
export async function fetchLiveShowState(token: string, limit = 50): Promise<LiveShowState> {
const params = new URLSearchParams();
if (limit) {
params.set('limit', String(limit));
}
const response = await fetch(
`/api/v1/live-show/${encodeURIComponent(token)}${params.toString() ? `?${params.toString()}` : ''}`,
{
headers: { Accept: 'application/json' },
credentials: 'omit',
}
);
const data = await handleResponse<Record<string, unknown>>(response);
const rawEvent = (data.event as Record<string, unknown>) ?? {};
return {
event: normalizeLiveShowEvent(rawEvent),
settings: normalizeLiveShowSettings(data.settings as Partial<LiveShowSettings> | null),
settings_version: String(data.settings_version ?? ''),
photos: Array.isArray(data.photos)
? data.photos.map((photo) => normalizeLiveShowPhoto(photo as Record<string, unknown>))
: [],
cursor: normalizeCursor((data.cursor as Record<string, unknown>) ?? null),
};
}
export async function fetchLiveShowUpdates(
token: string,
options: {
cursor?: LiveShowCursor | null;
settingsVersion?: string;
limit?: number;
} = {}
): Promise<LiveShowUpdates> {
const params = buildParams(options);
const response = await fetch(
`/api/v1/live-show/${encodeURIComponent(token)}/updates${params.toString() ? `?${params.toString()}` : ''}`,
{
headers: { Accept: 'application/json' },
credentials: 'omit',
}
);
const data = await handleResponse<Record<string, unknown>>(response);
return {
settings: data.settings ? normalizeLiveShowSettings(data.settings as Partial<LiveShowSettings>) : null,
settings_version: String(data.settings_version ?? ''),
photos: Array.isArray(data.photos)
? data.photos.map((photo) => normalizeLiveShowPhoto(photo as Record<string, unknown>))
: [],
cursor: normalizeCursor((data.cursor as Record<string, unknown>) ?? null),
};
}

View File

@@ -0,0 +1,154 @@
import { getDeviceId } from '../lib/device';
export type GuestNotificationCta = {
label: string;
href: string;
};
export type GuestNotificationItem = {
id: number;
type: string;
title: string;
body: string | null;
status: 'new' | 'read' | 'dismissed';
createdAt: string;
readAt?: string | null;
dismissedAt?: string | null;
cta?: GuestNotificationCta | null;
payload?: Record<string, unknown> | null;
};
export type GuestNotificationFetchResult = {
notifications: GuestNotificationItem[];
unreadCount: number;
etag: string | null;
notModified: boolean;
};
type GuestNotificationResponse = {
data?: Array<{
id?: number | string;
type?: string;
title?: string;
body?: string | null;
status?: 'new' | 'read' | 'dismissed';
created_at?: string;
read_at?: string | null;
dismissed_at?: string | null;
cta?: GuestNotificationCta | null;
payload?: Record<string, unknown> | null;
}>;
meta?: {
unread_count?: number;
};
};
type GuestNotificationRow = NonNullable<GuestNotificationResponse['data']>[number];
function buildHeaders(etag?: string | null): HeadersInit {
const headers: Record<string, string> = {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-Device-Id': getDeviceId(),
};
if (etag) {
headers['If-None-Match'] = etag;
}
return headers;
}
function mapNotification(payload: GuestNotificationRow): GuestNotificationItem {
return {
id: Number(payload.id ?? 0),
type: payload.type ?? 'broadcast',
title: payload.title ?? '',
body: payload.body ?? null,
status: payload.status === 'read' || payload.status === 'dismissed' ? payload.status : 'new',
createdAt: payload.created_at ?? new Date().toISOString(),
readAt: payload.read_at ?? null,
dismissedAt: payload.dismissed_at ?? null,
cta: payload.cta ?? null,
payload: payload.payload ?? null,
};
}
export async function fetchGuestNotifications(
eventToken: string,
etag?: string | null,
options?: { status?: 'unread' | 'read' | 'dismissed'; scope?: 'all' | 'uploads' | 'tips' | 'general' }
): Promise<GuestNotificationFetchResult> {
const params = new URLSearchParams();
if (options?.status) params.set('status', options.status);
if (options?.scope && options.scope !== 'all') params.set('scope', options.scope);
const response = await fetch(`/api/v1/events/${encodeURIComponent(eventToken)}/notifications${params.toString() ? `?${params.toString()}` : ''}`, {
method: 'GET',
headers: buildHeaders(etag),
credentials: 'include',
});
if (response.status === 304 && etag) {
return {
notifications: [],
unreadCount: 0,
etag,
notModified: true,
};
}
if (!response.ok) {
const reason = await safeParseError(response);
throw new Error(reason ?? 'Benachrichtigungen konnten nicht geladen werden.');
}
const body = (await response.json()) as GuestNotificationResponse;
const rows = Array.isArray(body.data) ? body.data : [];
const notifications = rows.map(mapNotification);
const unreadCount = typeof body.meta?.unread_count === 'number'
? body.meta.unread_count
: notifications.filter((item) => item.status === 'new').length;
return {
notifications,
unreadCount,
etag: response.headers.get('ETag'),
notModified: false,
};
}
export async function markGuestNotificationRead(eventToken: string, notificationId: number): Promise<void> {
await postNotificationAction(eventToken, notificationId, 'read');
}
export async function dismissGuestNotification(eventToken: string, notificationId: number): Promise<void> {
await postNotificationAction(eventToken, notificationId, 'dismiss');
}
async function postNotificationAction(eventToken: string, notificationId: number, action: 'read' | 'dismiss'): Promise<void> {
const response = await fetch(`/api/v1/events/${encodeURIComponent(eventToken)}/notifications/${notificationId}/${action}`, {
method: 'POST',
headers: buildHeaders(),
credentials: 'include',
});
if (!response.ok) {
const reason = await safeParseError(response);
throw new Error(reason ?? 'Aktion konnte nicht ausgeführt werden.');
}
}
async function safeParseError(response: Response): Promise<string | null> {
try {
const payload = await response.clone().json();
const message = payload?.error?.message ?? payload?.message;
if (typeof message === 'string' && message.trim() !== '') {
return message.trim();
}
} catch (error) {
console.warn('Failed to parse notification API error', error);
}
return null;
}

View File

@@ -0,0 +1,52 @@
import { getDeviceId } from '../lib/device';
export type PendingUpload = {
id: number;
status: 'pending' | 'approved' | 'rejected';
created_at?: string | null;
thumbnail_url?: string | null;
full_url?: string | null;
};
type PendingUploadsResponse = {
data: PendingUpload[];
meta?: {
total_count?: number;
};
};
async function handleResponse<T>(response: Response): Promise<T> {
const data = await response.json().catch(() => null);
if (!response.ok) {
const errorPayload = data as { error?: { message?: string; code?: unknown } } | null;
const error = new Error(errorPayload?.error?.message ?? 'Request failed') as Error & { code?: unknown };
error.code = errorPayload?.error?.code ?? response.status;
throw error;
}
return data as T;
}
export async function fetchPendingUploadsSummary(
token: string,
limit = 12
): Promise<{ items: PendingUpload[]; totalCount: number }> {
const params = new URLSearchParams();
params.set('limit', String(limit));
const response = await fetch(`/api/v1/events/${encodeURIComponent(token)}/pending-photos?${params.toString()}`, {
headers: {
'Accept': 'application/json',
'X-Device-Id': getDeviceId(),
},
credentials: 'omit',
});
const payload = await handleResponse<PendingUploadsResponse>(response);
return {
items: payload.data ?? [],
totalCount: payload.meta?.total_count ?? (payload.data?.length ?? 0),
};
}

View File

@@ -0,0 +1,333 @@
// @ts-nocheck
import { getDeviceId } from '../lib/device';
import { buildCsrfHeaders } from '../lib/csrf';
export type UploadError = Error & {
code?: string;
status?: number;
meta?: Record<string, unknown>;
};
export async function likePhoto(id: number): Promise<number> {
const headers = buildCsrfHeaders();
const res = await fetch(`/api/v1/photos/${id}/like`, {
method: 'POST',
credentials: 'include',
headers: {
...headers,
'Content-Type': 'application/json',
},
});
if (!res.ok) {
let payload: unknown = null;
try {
payload = await res.clone().json();
} catch (error) {
console.warn('Like photo: failed to parse error payload', error);
}
if (res.status === 419) {
const error: UploadError = new Error('CSRF token mismatch. Please refresh the page and try again.');
error.code = 'csrf_mismatch';
error.status = res.status;
throw error;
}
const error: UploadError = new Error(
(payload as { error?: { message?: string } } | null)?.error?.message ?? `Like failed: ${res.status}`
);
error.code = (payload as { error?: { code?: string } } | null)?.error?.code ?? 'like_failed';
error.status = res.status;
const meta = (payload as { error?: { meta?: Record<string, unknown> } } | null)?.error?.meta;
if (meta) {
error.meta = meta;
}
throw error;
}
const json = await res.json();
return json.likes_count ?? json.data?.likes_count ?? 0;
}
export async function unlikePhoto(id: number): Promise<number> {
const headers = buildCsrfHeaders();
const res = await fetch(`/api/v1/photos/${id}/like`, {
method: 'DELETE',
credentials: 'include',
headers: {
...headers,
'Content-Type': 'application/json',
},
});
if (!res.ok) {
let payload: unknown = null;
try {
payload = await res.clone().json();
} catch (error) {
console.warn('Unlike photo: failed to parse error payload', error);
}
if (res.status === 419) {
const error: UploadError = new Error('CSRF token mismatch. Please refresh the page and try again.');
error.code = 'csrf_mismatch';
error.status = res.status;
throw error;
}
const error: UploadError = new Error(
(payload as { error?: { message?: string } } | null)?.error?.message ?? `Unlike failed: ${res.status}`
);
error.code = (payload as { error?: { code?: string } } | null)?.error?.code ?? 'unlike_failed';
error.status = res.status;
const meta = (payload as { error?: { meta?: Record<string, unknown> } } | null)?.error?.meta;
if (meta) {
error.meta = meta;
}
throw error;
}
const json = await res.json();
return json.likes_count ?? json.data?.likes_count ?? 0;
}
export async function deletePhoto(eventToken: string, id: number): Promise<void> {
const headers = buildCsrfHeaders();
const url = `/api/v1/events/${encodeURIComponent(eventToken)}/photos/${id}`;
const res = await fetch(url, {
method: 'DELETE',
credentials: 'include',
headers: {
...headers,
'Content-Type': 'application/json',
},
});
if (!res.ok) {
let payload: unknown = null;
try {
payload = await res.clone().json();
} catch (error) {
console.warn('Delete photo: failed to parse error payload', error);
}
if (res.status === 419) {
const error: UploadError = new Error('CSRF token mismatch. Please refresh the page and try again.');
error.code = 'csrf_mismatch';
error.status = res.status;
throw error;
}
const error: UploadError = new Error(
(payload as { error?: { message?: string } } | null)?.error?.message ?? `Delete failed: ${res.status}`
);
error.code = (payload as { error?: { code?: string } } | null)?.error?.code ?? 'delete_failed';
error.status = res.status;
const meta = (payload as { error?: { meta?: Record<string, unknown> } } | null)?.error?.meta;
if (meta) {
error.meta = meta;
}
throw error;
}
}
type UploadOptions = {
guestName?: string;
onProgress?: (percent: number) => void;
signal?: AbortSignal;
maxRetries?: number;
onRetry?: (attempt: number) => void;
liveShowOptIn?: boolean;
};
export async function uploadPhoto(
eventToken: string,
file: File,
taskId?: number,
emotionSlug?: string,
options: UploadOptions = {}
): Promise<number> {
const formData = new FormData();
formData.append('photo', file, file.name || `photo-${Date.now()}.jpg`);
if (taskId) formData.append('task_id', taskId.toString());
if (emotionSlug) formData.append('emotion_slug', emotionSlug);
if (options.guestName) formData.append('guest_name', options.guestName);
if (typeof options.liveShowOptIn === 'boolean') {
formData.append('live_show_opt_in', options.liveShowOptIn ? '1' : '0');
}
formData.append('device_id', getDeviceId());
const maxRetries = options.maxRetries ?? 2;
const url = `/api/v1/events/${encodeURIComponent(eventToken)}/upload`;
const headers = buildCsrfHeaders();
const attemptUpload = (): Promise<Record<string, unknown>> =>
new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', url, true);
xhr.withCredentials = true;
xhr.responseType = 'json';
Object.entries(headers).forEach(([key, value]) => {
xhr.setRequestHeader(key, value);
});
if (options.signal) {
const onAbort = () => xhr.abort();
options.signal.addEventListener('abort', onAbort, { once: true });
}
xhr.upload.onprogress = (event) => {
if (event.lengthComputable && options.onProgress) {
const percent = Math.min(99, Math.round((event.loaded / event.total) * 100));
options.onProgress(percent);
}
};
xhr.onload = () => {
const status = xhr.status;
const payload = (xhr.response ?? null) as Record<string, unknown> | null;
if (status >= 200 && status < 300) {
resolve(payload);
return;
}
const error: UploadError = new Error(
(payload as { error?: { message?: string } } | null)?.error?.message ?? `Upload failed: ${status}`
);
error.code = (payload as { error?: { code?: string } } | null)?.error?.code ?? (status === 0 ? 'network_error' : 'upload_failed');
error.status = status;
const meta = (payload as { error?: { meta?: Record<string, unknown> } } | null)?.error?.meta;
if (meta) {
error.meta = meta;
}
reject(error);
};
xhr.onerror = () => {
const error: UploadError = new Error('Network error during upload');
error.code = 'network_error';
reject(error);
};
xhr.ontimeout = () => {
const error: UploadError = new Error('Upload timed out');
error.code = 'timeout';
reject(error);
};
xhr.send(formData);
});
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const json = await attemptUpload();
const payload = json as { photo_id?: number; id?: number; data?: { id?: number } };
return payload.photo_id ?? payload.id ?? payload.data?.id ?? 0;
} catch (error) {
const err = error as UploadError;
if (attempt < maxRetries && (err.code === 'network_error' || (err.status ?? 0) >= 500)) {
options.onRetry?.(attempt + 1);
const delay = 300 * (attempt + 1);
await new Promise((resolve) => setTimeout(resolve, delay));
continue;
}
// Map CSRF mismatch specifically for caller handling
if ((err.status ?? 0) === 419) {
err.code = 'csrf_mismatch';
}
// Flag common validation failure for file size/validation
if ((err.status ?? 0) === 422 && !err.code) {
err.code = 'validation_error';
}
throw err;
}
}
throw new Error('Upload failed after retries');
}
export async function createPhotoShareLink(eventToken: string, photoId: number): Promise<{ slug: string; url: string; expires_at?: string }> {
const headers = buildCsrfHeaders();
const res = await fetch(`/api/v1/events/${encodeURIComponent(eventToken)}/photos/${photoId}/share`, {
method: 'POST',
credentials: 'include',
headers,
});
if (!res.ok) {
let payload: unknown = null;
try {
payload = await res.clone().json();
} catch (error) {
console.warn('Share link error payload parse failed', error);
}
const errorPayload = payload as { error?: { message?: string; code?: string } } | null;
const error: UploadError = new Error(errorPayload?.error?.message ?? 'Share link creation failed');
error.code = errorPayload?.error?.code ?? 'share_failed';
error.status = res.status;
throw error;
}
return res.json();
}
export async function fetchPhotoShare(slug: string) {
const res = await fetch(`/api/v1/photo-shares/${encodeURIComponent(slug)}`, {
headers: { Accept: 'application/json' },
});
if (!res.ok) {
const payload = await res.json().catch(() => null);
const error: UploadError = new Error(payload?.error?.message ?? 'Share link unavailable');
error.code = payload?.error?.code ?? 'share_unavailable';
error.status = res.status;
throw error;
}
const payload = await res.json();
const normalize = (value: unknown, fallback: string): string => {
if (typeof value === 'string' && value.trim() !== '') {
return value;
}
if (value && typeof value === 'object') {
const obj = value as Record<string, unknown>;
const preferred = ['de', 'en'];
for (const key of preferred) {
const candidate = obj[key];
if (typeof candidate === 'string' && candidate.trim() !== '') {
return candidate;
}
}
const firstString = Object.values(obj).find((candidate) => typeof candidate === 'string' && candidate.trim() !== '');
if (typeof firstString === 'string') {
return firstString;
}
}
return fallback;
};
if (payload?.event) {
payload.event = {
...payload.event,
name: normalize(payload.event?.name, 'Fotospiel Event'),
};
}
return payload;
}

View File

@@ -0,0 +1,71 @@
import { getDeviceId } from '../lib/device';
type PushSubscriptionPayload = {
endpoint: string;
keys: {
p256dh: string;
auth: string;
};
expirationTime?: number | null;
contentEncoding?: string | null;
};
function buildHeaders(): HeadersInit {
return {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-Device-Id': getDeviceId(),
};
}
export async function registerPushSubscription(eventToken: string, subscription: PushSubscription): Promise<void> {
const json = subscription.toJSON() as PushSubscriptionPayload;
const body = {
endpoint: json.endpoint,
keys: json.keys,
expiration_time: json.expirationTime ?? null,
content_encoding: json.contentEncoding ?? null,
};
const response = await fetch(`/api/v1/events/${encodeURIComponent(eventToken)}/push-subscriptions`, {
method: 'POST',
headers: buildHeaders(),
credentials: 'include',
body: JSON.stringify(body),
});
if (!response.ok) {
const message = await parseError(response);
throw new Error(message ?? 'Push-Registrierung fehlgeschlagen.');
}
}
export async function unregisterPushSubscription(eventToken: string, endpoint: string): Promise<void> {
const response = await fetch(`/api/v1/events/${encodeURIComponent(eventToken)}/push-subscriptions`, {
method: 'DELETE',
headers: buildHeaders(),
credentials: 'include',
body: JSON.stringify({ endpoint }),
});
if (!response.ok) {
const message = await parseError(response);
throw new Error(message ?? 'Push konnte nicht deaktiviert werden.');
}
}
async function parseError(response: Response): Promise<string | null> {
try {
const payload = await response.clone().json();
const errorMessage = payload?.error?.message ?? payload?.message;
if (typeof errorMessage === 'string' && errorMessage.trim() !== '') {
return errorMessage;
}
} catch (error) {
console.warn('Failed to parse push API error', error);
}
return null;
}

View File

@@ -0,0 +1,37 @@
export interface EventBranding {
// Legacy/compat fields used across components
primaryColor: string;
secondaryColor: string;
backgroundColor: string;
fontFamily: string | null;
logoUrl: string | null;
welcomeMessage?: string | null;
// Extended branding shape
useDefaultBranding?: boolean;
palette?: {
primary: string;
secondary: string;
background: string;
surface?: string;
};
typography?: {
heading: string | null;
body: string | null;
sizePreset?: 's' | 'm' | 'l';
};
logo?: {
mode: 'emoticon' | 'upload';
value: string | null;
position?: 'left' | 'right' | 'center';
size?: 's' | 'm' | 'l';
};
buttons?: {
style?: 'filled' | 'outline';
radius?: number; // px
primary?: string;
secondary?: string;
linkColor?: string;
};
mode?: 'light' | 'dark' | 'auto';
}