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

@@ -1,215 +0,0 @@
import React from 'react';
import { NavLink, useParams, useLocation, Link } from 'react-router-dom';
import { CheckSquare, GalleryHorizontal, Home, Trophy, Camera } from 'lucide-react';
import { useEventData } from '../hooks/useEventData';
import { useTranslation } from '../i18n/useTranslation';
import { useEventBranding } from '../context/EventBrandingContext';
import { isTaskModeEnabled } from '../lib/engagement';
function TabLink({
to,
children,
isActive,
accentColor,
radius,
style,
compact = false,
}: {
to: string;
children: React.ReactNode;
isActive: boolean;
accentColor: string;
radius: number;
style?: React.CSSProperties;
compact?: boolean;
}) {
const activeStyle = isActive
? {
background: `linear-gradient(135deg, ${accentColor}, ${accentColor}cc)`,
color: '#ffffff',
boxShadow: `0 12px 30px ${accentColor}33`,
borderRadius: radius,
...style,
}
: { borderRadius: radius, ...style };
return (
<NavLink
to={to}
className={`
flex ${compact ? 'h-10 text-[10px]' : 'h-14 text-xs'} flex-col items-center justify-center gap-1 rounded-lg border border-transparent p-2 font-medium transition-all duration-200 ease-out
touch-manipulation backdrop-blur-md
${isActive ? 'scale-[1.04]' : 'text-white/70 hover:text-white'}
`}
style={activeStyle}
>
{children}
</NavLink>
);
}
export default function BottomNav() {
const { token } = useParams();
const location = useLocation();
const { event, status } = useEventData();
const { t } = useTranslation();
const { branding } = useEventBranding();
const navRef = React.useRef<HTMLDivElement | null>(null);
const radius = branding.buttons?.radius ?? 12;
const buttonStyle = branding.buttons?.style ?? 'filled';
const linkColor = branding.buttons?.linkColor ?? branding.secondaryColor;
const surface = branding.palette?.surface ?? branding.backgroundColor;
const isReady = status === 'ready' && !!event;
if (!token || !isReady) return null;
const base = `/e/${encodeURIComponent(token)}`;
const currentPath = location.pathname;
const tasksEnabled = isTaskModeEnabled(event);
const labels = {
home: t('navigation.home'),
tasks: t('navigation.tasks'),
achievements: t('navigation.achievements'),
gallery: t('navigation.gallery'),
upload: t('home.actions.items.upload.label'),
};
const isHomeActive = currentPath === base || currentPath === `/${token}`;
const isTasksActive = currentPath.startsWith(`${base}/tasks`);
const isAchievementsActive = currentPath.startsWith(`${base}/achievements`);
const isGalleryActive = currentPath.startsWith(`${base}/gallery`) || currentPath.startsWith(`${base}/photos`);
const isUploadActive = currentPath.startsWith(`${base}/upload`);
const compact = isUploadActive;
const navPaddingBottom = `calc(env(safe-area-inset-bottom, 0px) + ${compact ? 12 : 18}px)`;
const setBottomOffset = React.useCallback(() => {
if (typeof document === 'undefined' || !navRef.current) {
return;
}
const height = Math.ceil(navRef.current.getBoundingClientRect().height);
document.documentElement.style.setProperty('--guest-bottom-nav-offset', `${height}px`);
}, []);
React.useLayoutEffect(() => {
if (typeof window === 'undefined') {
return;
}
setBottomOffset();
const handleResize = () => setBottomOffset();
if (typeof ResizeObserver !== 'undefined' && navRef.current) {
const observer = new ResizeObserver(() => setBottomOffset());
observer.observe(navRef.current);
window.addEventListener('resize', handleResize);
return () => {
observer.disconnect();
window.removeEventListener('resize', handleResize);
document.documentElement.style.removeProperty('--guest-bottom-nav-offset');
};
}
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
document.documentElement.style.removeProperty('--guest-bottom-nav-offset');
};
}, [setBottomOffset, compact]);
return (
<div
ref={navRef}
className={`guest-bottom-nav fixed inset-x-0 bottom-0 z-30 border-t border-white/15 bg-gradient-to-t from-black/55 via-black/30 to-black/5 px-4 shadow-2xl backdrop-blur-xl transition-all duration-200 dark:border-white/10 dark:from-gray-950/85 dark:via-gray-900/55 dark:to-gray-900/20 ${
compact ? 'pt-1' : 'pt-2 pb-1'
}`}
style={{ paddingBottom: navPaddingBottom }}
>
<div className="pointer-events-none absolute -top-7 inset-x-0 h-7 bg-gradient-to-b from-black/0 via-black/30 to-black/60 dark:via-black/40 dark:to-black/70" aria-hidden />
<div className="mx-auto flex max-w-lg items-center gap-3">
<div className="flex flex-1 justify-evenly gap-2">
<TabLink
to={`${base}`}
isActive={isHomeActive}
accentColor={branding.primaryColor}
radius={radius}
compact={compact}
style={buttonStyle === 'outline' ? { background: 'transparent', color: linkColor, border: `1px solid ${linkColor}` } : undefined}
>
<div className="flex flex-col items-center gap-1">
<Home className="h-5 w-5" aria-hidden />
<span>{labels.home}</span>
</div>
</TabLink>
{tasksEnabled ? (
<TabLink
to={`${base}/tasks`}
isActive={isTasksActive}
accentColor={branding.primaryColor}
radius={radius}
compact={compact}
style={buttonStyle === 'outline' ? { background: 'transparent', color: linkColor, border: `1px solid ${linkColor}` } : undefined}
>
<div className="flex flex-col items-center gap-1">
<CheckSquare className="h-5 w-5" aria-hidden />
<span>{labels.tasks}</span>
</div>
</TabLink>
) : null}
</div>
<Link
to={`${base}/upload`}
aria-label={labels.upload}
className={`relative flex ${compact ? 'h-12 w-12' : 'h-16 w-16'} items-center justify-center rounded-full text-white shadow-2xl transition-all duration-300 ${
isUploadActive
? 'translate-y-6 scale-75 opacity-0 pointer-events-none'
: 'hover:scale-105'
}`}
style={{
background: `radial-gradient(circle at 20% 20%, ${branding.secondaryColor}, ${branding.primaryColor})`,
boxShadow: `0 20px 35px ${branding.primaryColor}44`,
borderRadius: radius,
}}
tabIndex={isUploadActive ? -1 : 0}
aria-hidden={isUploadActive}
>
<Camera className="h-6 w-6" aria-hidden />
</Link>
<div className="flex flex-1 justify-evenly gap-2">
<TabLink
to={`${base}/achievements`}
isActive={isAchievementsActive}
accentColor={branding.primaryColor}
radius={radius}
style={buttonStyle === 'outline' ? { background: 'transparent', color: linkColor, border: `1px solid ${linkColor}` } : undefined}
compact={compact}
>
<div className="flex flex-col items-center gap-1">
<Trophy className="h-5 w-5" aria-hidden />
<span>{labels.achievements}</span>
</div>
</TabLink>
<TabLink
to={`${base}/gallery`}
isActive={isGalleryActive}
accentColor={branding.primaryColor}
radius={radius}
style={buttonStyle === 'outline' ? { background: 'transparent', color: linkColor, border: `1px solid ${linkColor}` } : undefined}
compact={compact}
>
<div className="flex flex-col items-center gap-1">
<GalleryHorizontal className="h-5 w-5" aria-hidden />
<span>{labels.gallery}</span>
</div>
</TabLink>
</div>
</div>
</div>
);
}

View File

@@ -1,57 +0,0 @@
import React from 'react';
import { motion, type HTMLMotionProps } from 'framer-motion';
import { ZapOff } from 'lucide-react';
import { Button } from '@/components/ui/button';
export type DemoReadOnlyNoticeProps = {
title: string;
copy: string;
hint?: string;
ctaLabel?: string;
onCta?: () => void;
radius?: number;
bodyFont?: string;
motionProps?: HTMLMotionProps<'div'>;
};
export default function DemoReadOnlyNotice({
title,
copy,
hint,
ctaLabel,
onCta,
radius,
bodyFont,
motionProps,
}: DemoReadOnlyNoticeProps) {
return (
<motion.div
className="rounded-[28px] border border-white/15 bg-black/70 p-5 text-white shadow-2xl backdrop-blur"
style={{ borderRadius: radius, fontFamily: bodyFont }}
{...motionProps}
>
<div className="flex items-start gap-3">
<div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-white/10">
<ZapOff className="h-5 w-5 text-amber-200" />
</div>
<div className="space-y-1">
<p className="text-sm font-semibold">{title}</p>
<p className="text-xs text-white/80">{copy}</p>
{hint ? <p className="text-[11px] text-white/60">{hint}</p> : null}
</div>
</div>
{ctaLabel && onCta ? (
<div className="mt-4 flex flex-wrap gap-3">
<Button
size="sm"
variant="secondary"
className="rounded-full bg-white/90 text-slate-900 hover:bg-white"
onClick={onCta}
>
{ctaLabel}
</Button>
</div>
) : null}
</motion.div>
);
}

View File

@@ -1,199 +0,0 @@
import React, { useState, useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { ChevronRight } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useTranslation } from '../i18n/useTranslation';
interface Emotion {
id: number;
slug: string;
name: string;
emoji: string;
description?: string;
}
interface EmotionPickerProps {
onSelect?: (emotion: Emotion) => void;
variant?: 'standalone' | 'embedded';
title?: string;
subtitle?: string;
showSkip?: boolean;
}
export default function EmotionPicker({
onSelect,
variant = 'standalone',
title,
subtitle,
showSkip,
}: EmotionPickerProps) {
const { token } = useParams<{ token: string }>();
const eventKey = token ?? '';
const navigate = useNavigate();
const [emotions, setEmotions] = useState<Emotion[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const { locale } = useTranslation();
// Fallback emotions (when API not available yet)
const fallbackEmotions = React.useMemo<Emotion[]>(
() => [
{ id: 1, slug: 'happy', name: 'Glücklich', emoji: '😊' },
{ id: 2, slug: 'love', name: 'Verliebt', emoji: '❤️' },
{ id: 3, slug: 'excited', name: 'Aufgeregt', emoji: '🎉' },
{ id: 4, slug: 'relaxed', name: 'Entspannt', emoji: '😌' },
{ id: 5, slug: 'sad', name: 'Traurig', emoji: '😢' },
{ id: 6, slug: 'surprised', name: 'Überrascht', emoji: '😲' },
],
[]
);
useEffect(() => {
if (!eventKey) return;
async function fetchEmotions() {
try {
setLoading(true);
setError(null);
// Try API first
const response = await fetch(`/api/v1/events/${encodeURIComponent(eventKey)}/emotions?locale=${encodeURIComponent(locale)}`, {
headers: {
Accept: 'application/json',
'X-Locale': locale,
},
});
if (response.ok) {
const data = await response.json();
setEmotions(Array.isArray(data) ? data : fallbackEmotions);
} else {
// Fallback to predefined emotions
console.warn('Emotions API not available, using fallback');
setEmotions(fallbackEmotions);
}
} catch (err) {
console.error('Failed to fetch emotions:', err);
setError('Emotions konnten nicht geladen werden');
setEmotions(fallbackEmotions);
} finally {
setLoading(false);
}
}
fetchEmotions();
}, [eventKey, locale, fallbackEmotions]);
const handleEmotionSelect = (emotion: Emotion) => {
if (onSelect) {
onSelect(emotion);
} else {
// Default: Navigate to tasks with emotion filter
if (!eventKey) return;
navigate(`/e/${encodeURIComponent(eventKey)}/tasks?emotion=${emotion.slug}`);
}
};
const headingTitle = title ?? 'Wie fühlst du dich?';
const headingSubtitle = subtitle ?? '(optional)';
const shouldShowSkip = showSkip ?? variant === 'standalone';
const content = (
<div className="space-y-4">
{(variant === 'standalone' || title) && (
<div className="flex items-center justify-between">
<h3 className="text-base font-semibold">
{headingTitle}
{headingSubtitle && <span className="ml-2 text-xs text-muted-foreground dark:text-white/70">{headingSubtitle}</span>}
</h3>
{loading && <span className="text-xs text-muted-foreground dark:text-white/70">Lade Emotionen</span>}
</div>
)}
<div className="relative">
<div
className={cn(
'grid grid-rows-2 grid-flow-col auto-cols-[170px] sm:auto-cols-[190px] gap-3 overflow-x-auto pb-2 pr-12',
'scrollbar-thin scrollbar-thumb-muted scrollbar-track-transparent'
)}
aria-label="Emotions"
>
{emotions.map((emotion) => {
// Localize name and description if they are JSON
const localize = (value: string | object, defaultValue: string = ''): string => {
if (typeof value === 'string' && value.startsWith('{')) {
try {
const data = JSON.parse(value as string);
return data.de || data.en || defaultValue || '';
} catch {
return value as string;
}
}
return value as string;
};
const localizedName = localize(emotion.name, emotion.name);
const localizedDescription = localize(emotion.description || '', '');
return (
<button
key={emotion.id}
type="button"
onClick={() => handleEmotionSelect(emotion)}
className="group relative flex flex-col gap-2 rounded-2xl p-[1px] text-left shadow-sm transition hover:-translate-y-0.5 hover:shadow-lg active:scale-[0.98]"
style={{
backgroundImage: 'linear-gradient(135deg, color-mix(in oklch, var(--guest-primary) 45%, white), color-mix(in oklch, var(--guest-secondary) 40%, white))',
}}
>
<div className="relative flex flex-col gap-2 rounded-[0.95rem] border border-white/50 bg-white/80 px-4 py-3 shadow-sm backdrop-blur-xl dark:border-white/10 dark:bg-gray-900/70">
<div className="flex items-center gap-3">
<span className="text-2xl" aria-hidden>
{emotion.emoji}
</span>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-foreground dark:text-white">{localizedName}</div>
{localizedDescription && (
<div className="text-xs text-muted-foreground line-clamp-2 dark:text-white/60">{localizedDescription}</div>
)}
</div>
<ChevronRight className="h-4 w-4 text-muted-foreground opacity-0 transition group-hover:opacity-100 dark:text-white/60" />
</div>
</div>
</button>
);
})}
</div>
<div className="pointer-events-none absolute inset-y-0 right-0 w-10 bg-gradient-to-l from-[var(--guest-background)] via-[var(--guest-background)]/90 to-transparent dark:from-black dark:via-black/80" aria-hidden />
</div>
{/* Skip option */}
{shouldShowSkip && (
<div className="mt-4">
<Button
variant="ghost"
className="w-full text-sm text-gray-600 dark:text-gray-300 hover:text-pink-600 hover:bg-pink-50 dark:hover:bg-gray-800 border-t border-gray-200 dark:border-gray-700 pt-3 mt-3"
onClick={() => {
if (!eventKey) return;
navigate(`/e/${encodeURIComponent(eventKey)}/tasks`);
}}
>
Überspringen und Aufgabe wählen
</Button>
</div>
)}
</div>
);
if (error) {
return (
<div className="rounded-3xl border border-red-200 bg-red-50 p-4 text-sm text-red-700">
{error}
</div>
);
}
if (variant === 'embedded') {
return content;
}
return <div className="rounded-3xl border border-muted/40 bg-gradient-to-br from-white to-white/70 p-4 shadow-sm backdrop-blur">{content}</div>;
}

View File

@@ -1,74 +0,0 @@
import React from 'react';
import { cn } from '@/lib/utils';
import { Sparkles, Flame, UserRound, Camera } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { useTranslation } from '../i18n/useTranslation';
export type GalleryFilter = 'latest' | 'popular' | 'mine' | 'photobooth';
type FilterConfig = Array<{ value: GalleryFilter; labelKey: string; icon: LucideIcon }>;
const baseFilters: FilterConfig = [
{ value: 'latest', labelKey: 'galleryPage.filters.latest', icon: Sparkles },
{ value: 'popular', labelKey: 'galleryPage.filters.popular', icon: Flame },
{ value: 'mine', labelKey: 'galleryPage.filters.mine', icon: UserRound },
];
export default function FiltersBar({
value,
onChange,
className,
showPhotobooth = true,
styleOverride,
}: {
value: GalleryFilter;
onChange: (v: GalleryFilter) => void;
className?: string;
showPhotobooth?: boolean;
styleOverride?: React.CSSProperties;
}) {
const { t } = useTranslation();
const filters: FilterConfig = React.useMemo(
() => (showPhotobooth
? [...baseFilters, { value: 'photobooth', labelKey: 'galleryPage.filters.photobooth', icon: Camera }]
: baseFilters),
[showPhotobooth],
);
return (
<div
className={cn(
'flex overflow-x-auto px-1 pb-2 text-xs font-semibold [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
className,
)}
style={styleOverride}
>
<div className="inline-flex items-center rounded-full border border-border/70 bg-white/80 p-1 shadow-sm backdrop-blur dark:border-white/10 dark:bg-slate-950/70">
{filters.map((filter, index) => {
const isActive = value === filter.value;
const Icon = filter.icon;
return (
<div key={filter.value} className="flex items-center">
<button
type="button"
onClick={() => onChange(filter.value)}
className={cn(
'inline-flex items-center gap-1 rounded-full px-3 py-1.5 transition',
isActive
? 'bg-pink-500 text-white shadow'
: 'text-muted-foreground hover:bg-pink-50 hover:text-pink-600 dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white',
)}
>
<Icon className="h-3.5 w-3.5" aria-hidden />
<span className="whitespace-nowrap">{t(filter.labelKey)}</span>
</button>
{index < filters.length - 1 && (
<span className="mx-1 h-4 w-px bg-border/60 dark:bg-white/10" aria-hidden />
)}
</div>
);
})}
</div>
</div>
);
}

View File

@@ -1,195 +0,0 @@
// @ts-nocheck
import React from 'react';
import { Link } from 'react-router-dom';
import { Card, CardContent } from '@/components/ui/card';
import { getDeviceId } from '../lib/device';
import { usePollGalleryDelta } from '../polling/usePollGalleryDelta';
import { Heart } from 'lucide-react';
import { useTranslation } from '../i18n/useTranslation';
import { useEventBranding } from '../context/EventBrandingContext';
import { cn } from '@/lib/utils';
import { motion } from 'framer-motion';
type Props = { token: string };
type PreviewFilter = 'latest' | 'popular' | 'mine' | 'photobooth';
type PreviewPhoto = {
id: number;
session_id?: string | null;
ingest_source?: string | null;
likes_count?: number | null;
created_at?: string | null;
task_id?: number | null;
task_title?: string | null;
emotion_id?: number | null;
emotion_name?: string | null;
thumbnail_path?: string | null;
file_path?: string | null;
title?: string | null;
};
export default function GalleryPreview({ token }: Props) {
const { locale } = useTranslation();
const { branding } = useEventBranding();
const { photos, loading } = usePollGalleryDelta(token, locale);
const [mode, setMode] = React.useState<PreviewFilter>('latest');
const typedPhotos = React.useMemo(() => photos as PreviewPhoto[], [photos]);
const hasPhotobooth = React.useMemo(() => typedPhotos.some((p) => p.ingest_source === 'photobooth'), [typedPhotos]);
const radius = branding.buttons?.radius ?? 12;
const linkColor = branding.buttons?.linkColor ?? branding.secondaryColor;
const headingFont = branding.typography?.heading ?? branding.fontFamily ?? undefined;
const bodyFont = branding.typography?.body ?? branding.fontFamily ?? undefined;
const items = React.useMemo(() => {
let arr = typedPhotos.slice();
// MyPhotos filter (requires session_id matching)
if (mode === 'mine') {
const deviceId = getDeviceId();
arr = arr.filter((photo) => photo.session_id === deviceId);
} else if (mode === 'photobooth') {
arr = arr.filter((photo) => photo.ingest_source === 'photobooth');
}
// Sorting
if (mode === 'popular') {
arr.sort((a, b) => (b.likes_count ?? 0) - (a.likes_count ?? 0));
} else {
arr.sort((a, b) => new Date(b.created_at ?? 0).getTime() - new Date(a.created_at ?? 0).getTime());
}
return arr.slice(0, 9); // up to 3x3 preview
}, [typedPhotos, mode]);
React.useEffect(() => {
if (mode === 'photobooth' && !hasPhotobooth) {
setMode('latest');
}
}, [mode, hasPhotobooth]);
// Helper function to generate photo title (must be before return)
function getPhotoTitle(photo: PreviewPhoto): string {
if (photo.task_id) {
return `Task: ${photo.task_title || 'Unbekannte Aufgabe'}`;
}
if (photo.emotion_id) {
return `Emotion: ${photo.emotion_name || 'Gefühl'}`;
}
// Fallback based on creation time or placeholder
const now = new Date();
const created = new Date(photo.created_at || now);
const hours = created.getHours();
if (hours < 12) return 'Morgenmoment';
if (hours < 18) return 'Nachmittagslicht';
return 'Abendstimmung';
}
const filters: { value: PreviewFilter; label: string }[] = [
{ value: 'latest', label: 'Newest' },
{ value: 'popular', label: 'Popular' },
{ value: 'mine', label: 'My Photos' },
...(hasPhotobooth ? [{ value: 'photobooth', label: 'Fotobox' } as const] : []),
];
return (
<Card
className="border border-muted/30 bg-[var(--guest-surface)] shadow-sm dark:border-slate-800/70 dark:bg-slate-950/70"
data-testid="gallery-preview"
style={{ borderRadius: radius, fontFamily: bodyFont }}
>
<CardContent className="space-y-3 p-3">
<div className="flex items-center justify-between gap-3">
<div>
<div className="mb-1 inline-flex items-center rounded-full border border-white/50 bg-white/80 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.3em] text-muted-foreground shadow-sm backdrop-blur dark:border-white/10 dark:bg-slate-950/70" style={headingFont ? { fontFamily: headingFont } : undefined}>
Live-Galerie
</div>
<h3 className="text-lg font-semibold text-foreground" style={headingFont ? { fontFamily: headingFont } : undefined}>Alle Uploads auf einen Blick</h3>
</div>
<Link
to={`/e/${encodeURIComponent(token)}/gallery?mode=${mode}`}
className="rounded-full border border-white/40 bg-white/70 px-3 py-1 text-sm font-semibold shadow-sm backdrop-blur transition hover:bg-white/90 dark:border-white/10 dark:bg-slate-950/70 dark:hover:bg-slate-950"
style={{ color: linkColor }}
>
Alle ansehen
</Link>
</div>
<div className="flex overflow-x-auto pb-1 text-xs font-semibold [-ms-overflow-style:none] [scrollbar-width:none]">
<div className="inline-flex items-center gap-1 rounded-full border border-border/70 bg-white/80 p-1 shadow-sm backdrop-blur dark:border-white/10 dark:bg-slate-950/70">
{filters.map((filter) => {
const isActive = mode === filter.value;
return (
<button
key={filter.value}
type="button"
onClick={() => setMode(filter.value)}
className={cn(
'relative inline-flex items-center rounded-full px-3 py-1.5 transition',
isActive
? 'text-white'
: 'text-muted-foreground hover:text-pink-600 dark:text-white/70 dark:hover:text-white',
)}
>
{isActive && (
<motion.span
layoutId="gallery-filter-pill"
className="absolute inset-0 rounded-full bg-gradient-to-r from-pink-500 to-rose-500 shadow"
transition={{ type: 'spring', stiffness: 380, damping: 30 }}
/>
)}
<span className="relative z-10 whitespace-nowrap">{filter.label}</span>
</button>
);
})}
</div>
</div>
{loading && <p className="text-sm text-muted-foreground">Lädt</p>}
{!loading && items.length === 0 && (
<div className="flex items-center gap-3 rounded-xl border border-muted/30 bg-[var(--guest-surface)] p-3 text-sm text-muted-foreground dark:border-slate-800/60 dark:bg-slate-950/60">
<Heart className="h-4 w-4" style={{ color: branding.secondaryColor }} aria-hidden />
Noch keine Fotos. Starte mit deinem ersten Upload!
</div>
)}
<div className="grid gap-3 grid-cols-2 md:grid-cols-3">
{items.map((p: PreviewPhoto) => (
<Link
key={p.id}
to={`/e/${encodeURIComponent(token)}/gallery?photoId=${p.id}`}
className="group flex flex-col overflow-hidden border border-border/60 bg-white shadow-sm ring-1 ring-black/5 transition duration-300 hover:-translate-y-0.5 hover:shadow-lg dark:border-white/10 dark:bg-slate-950 dark:ring-white/10"
style={{ borderRadius: radius }}
>
<div className="relative">
<img
src={p.thumbnail_path || p.file_path}
alt={p.title || 'Foto'}
className="aspect-[3/4] w-full object-cover transition duration-300 group-hover:scale-105"
loading="lazy"
/>
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-black/50 via-black/0 to-transparent" aria-hidden />
</div>
<div className="space-y-2 px-3 pb-3 pt-3">
<p className="text-sm font-semibold leading-tight line-clamp-2 text-foreground" style={headingFont ? { fontFamily: headingFont } : undefined}>
{p.title || getPhotoTitle(p)}
</p>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Heart className="h-3.5 w-3.5 text-pink-500" aria-hidden />
{p.likes_count ?? 0}
</div>
</div>
</Link>
))}
</div>
<p className="text-center text-sm text-muted-foreground">
Lust auf mehr?{' '}
<Link to={`/e/${encodeURIComponent(token)}/gallery`} className="font-semibold transition" style={{ color: linkColor }}>
Zur Galerie
</Link>
</p>
</CardContent>
</Card>
);
}

View File

@@ -1,236 +0,0 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { useConsent } from '@/contexts/consent';
import { useTranslation } from '../i18n/useTranslation';
import { isUploadPath, shouldShowAnalyticsNudge } from '../lib/analyticsConsent';
const PROMPT_STORAGE_KEY = 'fotospiel.guest.analyticsPrompt';
const SNOOZE_MS = 60 * 60 * 1000;
const ACTIVE_IDLE_LIMIT_MS = 20_000;
type PromptStorage = {
snoozedUntil?: number | null;
};
function readSnoozedUntil(): number | null {
if (typeof window === 'undefined') {
return null;
}
try {
const raw = window.localStorage.getItem(PROMPT_STORAGE_KEY);
if (!raw) {
return null;
}
const parsed = JSON.parse(raw) as PromptStorage;
return typeof parsed.snoozedUntil === 'number' ? parsed.snoozedUntil : null;
} catch {
return null;
}
}
function writeSnoozedUntil(value: number | null) {
if (typeof window === 'undefined') {
return;
}
try {
const payload: PromptStorage = { snoozedUntil: value };
window.localStorage.setItem(PROMPT_STORAGE_KEY, JSON.stringify(payload));
} catch {
// ignore storage failures
}
}
function randomInt(min: number, max: number): number {
const low = Math.ceil(min);
const high = Math.floor(max);
return Math.floor(Math.random() * (high - low + 1)) + low;
}
export default function GuestAnalyticsNudge({
enabled,
pathname,
}: {
enabled: boolean;
pathname: string;
}) {
const { t } = useTranslation();
const { decisionMade, preferences, savePreferences } = useConsent();
const analyticsConsent = Boolean(preferences?.analytics);
const [thresholdSeconds] = React.useState(() => randomInt(60, 120));
const [thresholdRoutes] = React.useState(() => randomInt(2, 3));
const [activeSeconds, setActiveSeconds] = React.useState(0);
const [routeCount, setRouteCount] = React.useState(0);
const [isOpen, setIsOpen] = React.useState(false);
const [snoozedUntil, setSnoozedUntil] = React.useState<number | null>(() => readSnoozedUntil());
const lastPathRef = React.useRef(pathname);
const lastActivityAtRef = React.useRef(Date.now());
const visibleRef = React.useRef(typeof document === 'undefined' ? true : document.visibilityState === 'visible');
const isUpload = isUploadPath(pathname);
React.useEffect(() => {
const previousPath = lastPathRef.current;
const currentPath = pathname;
lastPathRef.current = currentPath;
if (previousPath === currentPath) {
return;
}
if (isUploadPath(previousPath) || isUploadPath(currentPath)) {
return;
}
setRouteCount((count) => count + 1);
}, [pathname]);
React.useEffect(() => {
if (typeof window === 'undefined') {
return undefined;
}
const handleActivity = () => {
lastActivityAtRef.current = Date.now();
};
const events: Array<keyof WindowEventMap> = [
'pointerdown',
'pointermove',
'keydown',
'scroll',
'touchstart',
];
events.forEach((event) => window.addEventListener(event, handleActivity, { passive: true }));
return () => {
events.forEach((event) => window.removeEventListener(event, handleActivity));
};
}, []);
React.useEffect(() => {
if (typeof document === 'undefined') {
return undefined;
}
const handleVisibility = () => {
visibleRef.current = document.visibilityState === 'visible';
};
document.addEventListener('visibilitychange', handleVisibility);
return () => document.removeEventListener('visibilitychange', handleVisibility);
}, []);
React.useEffect(() => {
if (typeof window === 'undefined') {
return undefined;
}
const interval = window.setInterval(() => {
const now = Date.now();
if (!visibleRef.current) {
return;
}
if (isUploadPath(lastPathRef.current)) {
return;
}
if (now - lastActivityAtRef.current > ACTIVE_IDLE_LIMIT_MS) {
return;
}
setActiveSeconds((seconds) => seconds + 1);
}, 1000);
return () => window.clearInterval(interval);
}, []);
React.useEffect(() => {
if (!enabled || analyticsConsent || decisionMade) {
setIsOpen(false);
return;
}
const shouldOpen = shouldShowAnalyticsNudge({
decisionMade,
analyticsConsent,
snoozedUntil,
now: Date.now(),
activeSeconds,
routeCount,
thresholdSeconds,
thresholdRoutes,
isUpload,
});
if (shouldOpen) {
setIsOpen(true);
}
}, [
enabled,
analyticsConsent,
decisionMade,
snoozedUntil,
activeSeconds,
routeCount,
thresholdSeconds,
thresholdRoutes,
isUpload,
]);
React.useEffect(() => {
if (isUpload) {
setIsOpen(false);
}
}, [isUpload]);
if (!enabled || decisionMade || analyticsConsent || !isOpen || isUpload) {
return null;
}
const handleSnooze = () => {
const until = Date.now() + SNOOZE_MS;
setSnoozedUntil(until);
writeSnoozedUntil(until);
setIsOpen(false);
};
const handleAllow = () => {
savePreferences({ analytics: true });
writeSnoozedUntil(null);
setIsOpen(false);
};
return (
<div
className="pointer-events-none fixed inset-x-0 z-40 px-4"
style={{ bottom: 'calc(env(safe-area-inset-bottom, 0px) + 96px)' }}
>
<div className="pointer-events-auto mx-auto max-w-lg rounded-2xl border border-slate-200/80 bg-white/95 p-4 shadow-xl backdrop-blur dark:border-slate-700/60 dark:bg-slate-900/95">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<p className="text-sm font-semibold text-foreground">
{t('consent.analytics.title')}
</p>
<p className="text-xs text-muted-foreground">
{t('consent.analytics.body')}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button type="button" size="sm" variant="ghost" onClick={handleSnooze}>
{t('consent.analytics.later')}
</Button>
<Button type="button" size="sm" onClick={handleAllow}>
{t('consent.analytics.allow')}
</Button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,813 +0,0 @@
import React from 'react';
import { createPortal } from 'react-dom';
import { Link } from 'react-router-dom';
import AppearanceToggleDropdown from '@/components/appearance-dropdown';
import {
User,
Heart,
Users,
PartyPopper,
Camera,
Bell,
ArrowUpRight,
Clock,
MessageSquare,
Sparkles,
LifeBuoy,
UploadCloud,
AlertCircle,
Check,
X,
RefreshCw,
} from 'lucide-react';
import { useEventData } from '../hooks/useEventData';
import { useOptionalEventStats } from '../context/EventStatsContext';
import { SettingsSheet } from './settings-sheet';
import { useTranslation, type TranslateFn } from '../i18n/useTranslation';
import { DEFAULT_EVENT_BRANDING, useOptionalEventBranding } from '../context/EventBrandingContext';
import { useOptionalNotificationCenter, type NotificationCenterValue } from '../context/NotificationCenterContext';
import { usePushSubscription } from '../hooks/usePushSubscription';
import { getContrastingTextColor, relativeLuminance, hexToRgb } from '../lib/color';
import { isTaskModeEnabled } from '../lib/engagement';
const EVENT_ICON_COMPONENTS: Record<string, React.ComponentType<{ className?: string }>> = {
heart: Heart,
guests: Users,
party: PartyPopper,
camera: Camera,
};
type LogoSize = 's' | 'm' | 'l';
const LOGO_SIZE_CLASSES: Record<LogoSize, { container: string; image: string; emoji: string; icon: string; initials: string }> = {
s: { container: 'h-8 w-8', image: 'h-7 w-7', emoji: 'text-lg', icon: 'h-4 w-4', initials: 'text-[11px]' },
m: { container: 'h-10 w-10', image: 'h-9 w-9', emoji: 'text-xl', icon: 'h-5 w-5', initials: 'text-sm' },
l: { container: 'h-12 w-12', image: 'h-11 w-11', emoji: 'text-2xl', icon: 'h-6 w-6', initials: 'text-base' },
};
function getLogoClasses(size?: LogoSize) {
return LOGO_SIZE_CLASSES[size ?? 'm'];
}
const NOTIFICATION_ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
broadcast: MessageSquare,
feedback_request: MessageSquare,
achievement_major: Sparkles,
support_tip: LifeBuoy,
upload_alert: UploadCloud,
photo_activity: Camera,
};
function isLikelyEmoji(value: string): boolean {
if (!value) {
return false;
}
const characters = Array.from(value.trim());
if (characters.length === 0 || characters.length > 2) {
return false;
}
return characters.some((char) => {
const codePoint = char.codePointAt(0) ?? 0;
return codePoint > 0x2600;
});
}
function getInitials(name: string): string {
const words = name.split(' ').filter(Boolean);
if (words.length >= 2) {
return `${words[0][0]}${words[1][0]}`.toUpperCase();
}
return name.substring(0, 2).toUpperCase();
}
function toRgba(value: string, alpha: number): string {
const rgb = hexToRgb(value);
if (!rgb) {
return `rgba(255, 255, 255, ${alpha})`;
}
return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${alpha})`;
}
function EventAvatar({
name,
icon,
accentColor,
textColor,
logo,
}: {
name: string;
icon: unknown;
accentColor: string;
textColor: string;
logo?: { mode: 'emoticon' | 'upload'; value: string | null; size?: LogoSize };
}) {
const logoValue = logo?.mode === 'upload' ? (logo.value?.trim() || null) : null;
const [logoFailed, setLogoFailed] = React.useState(false);
React.useEffect(() => {
setLogoFailed(false);
}, [logoValue]);
const sizes = getLogoClasses(logo?.size);
if (logo?.mode === 'upload' && logoValue && !logoFailed) {
return (
<div className={`flex items-center justify-center rounded-full bg-white shadow-sm ${sizes.container}`}>
<img
src={logoValue}
alt={name}
className={`rounded-full object-contain ${sizes.image}`}
onError={() => setLogoFailed(true)}
/>
</div>
);
}
if (logo?.mode === 'emoticon' && logo.value && isLikelyEmoji(logo.value)) {
return (
<div
className={`flex items-center justify-center rounded-full shadow-sm ${sizes.container} ${sizes.emoji}`}
style={{ backgroundColor: accentColor, color: textColor }}
>
<span aria-hidden>{logo.value}</span>
<span className="sr-only">{name}</span>
</div>
);
}
if (typeof icon === 'string') {
const trimmed = icon.trim();
if (trimmed) {
const normalized = trimmed.toLowerCase();
const IconComponent = EVENT_ICON_COMPONENTS[normalized];
if (IconComponent) {
return (
<div
className={`flex items-center justify-center rounded-full shadow-sm ${sizes.container}`}
style={{ backgroundColor: accentColor, color: textColor }}
>
<IconComponent className={sizes.icon} aria-hidden />
</div>
);
}
if (isLikelyEmoji(trimmed)) {
return (
<div
className={`flex items-center justify-center rounded-full shadow-sm ${sizes.container} ${sizes.emoji}`}
style={{ backgroundColor: accentColor, color: textColor }}
>
<span aria-hidden>{trimmed}</span>
<span className="sr-only">{name}</span>
</div>
);
}
}
}
return (
<div
className={`flex items-center justify-center rounded-full font-semibold shadow-sm ${sizes.container} ${sizes.initials}`}
style={{ backgroundColor: accentColor, color: textColor }}
>
{getInitials(name)}
</div>
);
}
export default function Header({ eventToken, title = '' }: { eventToken?: string; title?: string }) {
const statsContext = useOptionalEventStats();
const { t } = useTranslation();
const brandingContext = useOptionalEventBranding();
const branding = brandingContext?.branding ?? DEFAULT_EVENT_BRANDING;
const headerTextColor = React.useMemo(() => {
const primaryLum = relativeLuminance(branding.primaryColor);
const secondaryLum = relativeLuminance(branding.secondaryColor);
const avgLum = (primaryLum + secondaryLum) / 2;
if (avgLum > 0.55) {
return getContrastingTextColor(branding.primaryColor, '#0f172a', '#ffffff');
}
return '#ffffff';
}, [branding.primaryColor, branding.secondaryColor]);
const { event, status } = useEventData();
const notificationCenter = useOptionalNotificationCenter();
const [notificationsOpen, setNotificationsOpen] = React.useState(false);
const tasksEnabled = isTaskModeEnabled(event);
const panelRef = React.useRef<HTMLDivElement | null>(null);
const notificationButtonRef = React.useRef<HTMLButtonElement | null>(null);
React.useEffect(() => {
if (!notificationsOpen) {
return;
}
const handler = (event: MouseEvent) => {
if (notificationButtonRef.current?.contains(event.target as Node)) {
return;
}
if (!panelRef.current) return;
if (panelRef.current.contains(event.target as Node)) return;
setNotificationsOpen(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [notificationsOpen]);
const headerFont = branding.typography?.heading ?? branding.fontFamily ?? undefined;
const bodyFont = branding.typography?.body ?? branding.fontFamily ?? undefined;
const logoPosition = branding.logo?.position ?? 'left';
const headerStyle: React.CSSProperties = {
background: `linear-gradient(135deg, ${branding.primaryColor}, ${branding.secondaryColor})`,
color: headerTextColor,
fontFamily: headerFont,
};
const headerGlowPrimary = toRgba(branding.primaryColor, 0.35);
const headerGlowSecondary = toRgba(branding.secondaryColor, 0.35);
const headerShimmer = `linear-gradient(120deg, ${toRgba(branding.primaryColor, 0.28)}, transparent 45%, ${toRgba(branding.secondaryColor, 0.32)})`;
const headerHairline = `linear-gradient(90deg, transparent, ${toRgba(headerTextColor, 0.4)}, transparent)`;
if (!eventToken) {
return (
<div
className="guest-header sticky top-0 z-30 relative overflow-hidden border-b border-white/20 bg-white/70 px-4 py-2 shadow-[0_14px_40px_-30px_rgba(15,23,42,0.6)] backdrop-blur-2xl dark:border-white/10 dark:bg-black/40"
style={branding.fontFamily ? { fontFamily: branding.fontFamily } : undefined}
>
<div className="pointer-events-none absolute inset-0 opacity-70 guest-aurora-soft" style={{ backgroundImage: headerShimmer }} aria-hidden />
<div className="pointer-events-none absolute -top-8 right-0 h-24 w-24 rounded-full bg-white/60 blur-3xl dark:bg-white/10" aria-hidden />
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-px bg-gradient-to-r from-transparent via-white/40 to-transparent dark:via-white/15" aria-hidden />
<div className="relative z-10 flex w-full items-center gap-3 flex-nowrap">
<div className="flex min-w-0 flex-col">
<div className="font-semibold">{title}</div>
</div>
<div className="ml-auto flex items-center justify-end gap-2">
<AppearanceToggleDropdown />
<SettingsSheet />
</div>
</div>
</div>
);
}
const accentColor = branding.secondaryColor;
if (status === 'loading') {
return (
<div className="guest-header sticky top-0 z-30 relative overflow-hidden border-b border-white/20 px-4 py-2 shadow-[0_18px_45px_-30px_rgba(15,23,42,0.65)] backdrop-blur-2xl" style={headerStyle}>
<div className="pointer-events-none absolute inset-0 opacity-70 guest-aurora" style={{ backgroundImage: headerShimmer }} aria-hidden />
<div className="pointer-events-none absolute -top-10 right-[-32px] h-28 w-28 rounded-full blur-3xl" style={{ background: headerGlowSecondary }} aria-hidden />
<div className="pointer-events-none absolute -bottom-8 left-1/3 h-20 w-40 -translate-x-1/2 rounded-full blur-3xl" style={{ background: headerGlowPrimary }} aria-hidden />
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-px" style={{ background: headerHairline }} aria-hidden />
<div className="relative z-10 flex w-full items-center gap-3 flex-nowrap">
<div className="font-semibold" style={branding.fontFamily ? { fontFamily: branding.fontFamily } : undefined}>{t('header.loading')}</div>
<div className="ml-auto flex items-center justify-end gap-2">
<AppearanceToggleDropdown />
<SettingsSheet />
</div>
</div>
</div>
);
}
if (status !== 'ready' || !event) {
return null;
}
const stats =
statsContext && statsContext.eventKey === eventToken ? statsContext : undefined;
return (
<div
className="guest-header sticky top-0 z-30 relative flex flex-nowrap items-center gap-3 overflow-hidden border-b border-white/20 px-4 py-2 shadow-[0_18px_45px_-30px_rgba(15,23,42,0.65)] backdrop-blur-2xl"
style={headerStyle}
>
<div className="pointer-events-none absolute inset-0 opacity-70 guest-aurora" style={{ backgroundImage: headerShimmer }} aria-hidden />
<div className="pointer-events-none absolute -top-12 right-[-40px] h-32 w-32 rounded-full blur-3xl" style={{ background: headerGlowSecondary }} aria-hidden />
<div className="pointer-events-none absolute -bottom-10 left-1/3 h-24 w-44 -translate-x-1/2 rounded-full blur-3xl" style={{ background: headerGlowPrimary }} aria-hidden />
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-px" style={{ background: headerHairline }} aria-hidden />
<div
className={
`relative z-10 flex min-w-0 flex-1 ${logoPosition === 'center'
? 'flex-col items-center gap-1 text-center'
: logoPosition === 'right'
? 'flex-row-reverse items-center gap-3'
: 'items-center gap-3'}`
}
>
<EventAvatar
name={event.name}
icon={event.type?.icon}
accentColor={accentColor}
textColor={headerTextColor}
logo={branding.logo}
/>
<div
className={`flex flex-col${logoPosition === 'center' ? ' items-center text-center' : ''}`}
style={headerFont ? { fontFamily: headerFont } : undefined}
>
<div className="truncate text-base font-semibold sm:text-lg">{event.name}</div>
<div className="flex items-center gap-2 text-xs opacity-70" style={bodyFont ? { fontFamily: bodyFont } : undefined}>
{stats && tasksEnabled && (
<>
<span className="flex items-center gap-1">
<User className="h-3 w-3" />
<span>{`${stats.onlineGuests} ${t('header.stats.online')}`}</span>
</span>
<span className="opacity-50">|</span>
<span className="flex items-center gap-1">
<span className="font-medium">{stats.tasksSolved}</span>{' '}
{t('header.stats.tasksSolved')}
</span>
</>
)}
</div>
</div>
</div>
<div className="relative z-10 ml-auto flex shrink-0 items-center justify-end gap-2">
{notificationCenter && eventToken && (
<NotificationButton
eventToken={eventToken}
center={notificationCenter}
open={notificationsOpen}
onToggle={() => setNotificationsOpen((prev) => !prev)}
panelRef={panelRef}
buttonRef={notificationButtonRef}
t={t}
/>
)}
<AppearanceToggleDropdown />
<SettingsSheet />
</div>
</div>
);
}
type NotificationButtonProps = {
center: NotificationCenterValue;
eventToken: string;
open: boolean;
onToggle: () => void;
panelRef: React.RefObject<HTMLDivElement | null>;
buttonRef: React.RefObject<HTMLButtonElement | null>;
t: TranslateFn;
};
type PushState = ReturnType<typeof usePushSubscription>;
function NotificationButton({ center, eventToken, open, onToggle, panelRef, buttonRef, t }: NotificationButtonProps) {
const badgeCount = center.unreadCount;
const [activeTab, setActiveTab] = React.useState<'unread' | 'all' | 'uploads'>(center.unreadCount > 0 ? 'unread' : 'all');
const [scopeFilter, setScopeFilter] = React.useState<'all' | 'tips' | 'general'>('all');
const pushState = usePushSubscription(eventToken);
React.useEffect(() => {
if (!open) {
setActiveTab(center.unreadCount > 0 ? 'unread' : 'all');
}
}, [open, center.unreadCount]);
const uploadNotifications = React.useMemo(
() => center.notifications.filter((item) => item.type === 'upload_alert'),
[center.notifications]
);
const unreadNotifications = React.useMemo(
() => center.notifications.filter((item) => item.status === 'new'),
[center.notifications]
);
const filteredNotifications = React.useMemo(() => {
let base: typeof center.notifications = [];
switch (activeTab) {
case 'unread':
base = unreadNotifications;
break;
case 'uploads':
base = uploadNotifications;
break;
default:
base = center.notifications;
}
return base;
}, [activeTab, center.notifications, unreadNotifications, uploadNotifications]);
const scopedNotifications = React.useMemo(() => {
if (activeTab === 'uploads' || scopeFilter === 'all') {
return filteredNotifications;
}
return filteredNotifications.filter((item) => {
if (scopeFilter === 'tips') {
return item.type === 'support_tip' || item.type === 'achievement_major';
}
return item.type === 'broadcast' || item.type === 'feedback_request';
});
}, [filteredNotifications, scopeFilter]);
return (
<div className="relative z-50">
<button
ref={buttonRef}
type="button"
onClick={onToggle}
className="relative flex h-10 w-10 items-center justify-center rounded-2xl border border-white/25 bg-white/15 text-current shadow-lg shadow-black/20 backdrop-blur transition hover:border-white/40 hover:bg-white/25 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/60"
aria-label={open ? t('header.notifications.close', 'Benachrichtigungen schließen') : t('header.notifications.open', 'Benachrichtigungen anzeigen')}
>
<Bell className="h-5 w-5" aria-hidden />
{badgeCount > 0 && (
<span className="absolute -right-1 -top-1 min-h-[18px] min-w-[18px] rounded-full bg-pink-500 px-1.5 text-[11px] font-semibold leading-[18px] text-white shadow-lg">
{badgeCount > 9 ? '9+' : badgeCount}
</span>
)}
</button>
{open && createPortal(
<div
ref={panelRef}
className="fixed right-4 top-16 z-[2147483000] w-80 rounded-2xl border border-white/30 bg-white/95 p-4 text-slate-900 shadow-2xl"
>
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-semibold text-slate-900">{t('header.notifications.title', 'Updates')}</p>
<p className="text-xs text-slate-500">
{center.unreadCount > 0
? t('header.notifications.unread', { defaultValue: '{count} neu', count: center.unreadCount })
: t('header.notifications.allRead', 'Alles gelesen')}
</p>
</div>
<button
type="button"
onClick={() => center.refresh()}
disabled={center.loading}
className="flex items-center gap-1 rounded-full border border-slate-200 px-2 py-1 text-xs font-semibold text-slate-600 transition hover:border-pink-300 disabled:cursor-not-allowed"
>
<RefreshCw className={`h-3.5 w-3.5 ${center.loading ? 'animate-spin' : ''}`} aria-hidden />
{t('header.notifications.refresh', 'Aktualisieren')}
</button>
</div>
<NotificationTabs
tabs={[
{ key: 'unread', label: t('header.notifications.tabUnread', 'Nachrichten'), badge: unreadNotifications.length },
{ key: 'uploads', label: t('header.notifications.tabUploads', 'Uploads'), badge: uploadNotifications.length },
{ key: 'all', label: t('header.notifications.tabAll', 'Alle Updates'), badge: center.notifications.length },
]}
activeTab={activeTab}
onTabChange={(next) => setActiveTab(next as typeof activeTab)}
/>
{activeTab !== 'uploads' && (
<div className="mt-3">
<div className="flex gap-2 overflow-x-auto text-xs whitespace-nowrap pb-1">
{(
[
{ key: 'all', label: t('header.notifications.scope.all', 'Alle') },
{ key: 'tips', label: t('header.notifications.scope.tips', 'Tipps & Achievements') },
{ key: 'general', label: t('header.notifications.scope.general', 'Allgemein') },
] as const
).map((option) => (
<button
key={option.key}
type="button"
onClick={() => {
setScopeFilter(option.key);
center.setFilters({ scope: option.key });
}}
className={`rounded-full border px-3 py-1 font-semibold transition ${
scopeFilter === option.key
? 'border-pink-200 bg-pink-50 text-pink-700'
: 'border-slate-200 bg-white text-slate-600 hover:border-pink-200 hover:text-pink-700'
}`}
>
{option.label}
</button>
))}
</div>
</div>
)}
{activeTab === 'uploads' && (center.pendingCount > 0 || center.queueCount > 0) && (
<div className="mt-3 space-y-2">
{center.pendingCount > 0 && (
<div className="flex items-center justify-between rounded-xl bg-amber-50/90 px-3 py-2 text-xs text-amber-900">
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-amber-500" aria-hidden />
<span>{t('header.notifications.pendingLabel', 'Uploads in Prüfung')}</span>
<span className="font-semibold text-amber-900">{center.pendingCount}</span>
</div>
<Link
to={`/e/${encodeURIComponent(eventToken)}/queue`}
className="inline-flex items-center gap-1 font-semibold text-amber-700"
onClick={() => {
if (center.unreadCount > 0) {
void center.refresh();
}
}}
>
{t('header.notifications.pendingCta', 'Details')}
<ArrowUpRight className="h-4 w-4" aria-hidden />
</Link>
</div>
)}
{center.queueCount > 0 && (
<div className="flex items-center justify-between rounded-xl bg-slate-50/90 px-3 py-2 text-xs text-slate-600">
<div className="flex items-center gap-2">
<UploadCloud className="h-4 w-4 text-slate-400" aria-hidden />
<span>{t('header.notifications.queueLabel', 'Upload-Warteschlange (offline)')}</span>
<span className="font-semibold text-slate-900">{center.queueCount}</span>
</div>
</div>
)}
</div>
)}
<div className="mt-3 max-h-80 space-y-2 overflow-y-auto pr-1">
{center.loading ? (
<NotificationSkeleton />
) : scopedNotifications.length === 0 ? (
<NotificationEmptyState
t={t}
message={
activeTab === 'unread'
? t('header.notifications.emptyUnread', 'Du bist auf dem neuesten Stand!')
: activeTab === 'uploads'
? t('header.notifications.emptyStatus', 'Keine Upload-Hinweise oder Wartungen aktiv.')
: undefined
}
/>
) : (
scopedNotifications.map((item) => (
<NotificationListItem
key={item.id}
item={item}
onMarkRead={() => center.markAsRead(item.id)}
onDismiss={() => center.dismiss(item.id)}
t={t}
/>
))
)}
</div>
<NotificationStatusBar
lastFetchedAt={center.lastFetchedAt}
isOffline={center.isOffline}
push={pushState}
t={t}
/>
</div>,
(typeof document !== 'undefined' ? document.body : null) as any
)}
</div>
);
}
function NotificationListItem({
item,
onMarkRead,
onDismiss,
t,
}: {
item: NotificationCenterValue['notifications'][number];
onMarkRead: () => void;
onDismiss: () => void;
t: TranslateFn;
}) {
const IconComponent = NOTIFICATION_ICON_MAP[item.type] ?? Bell;
const isNew = item.status === 'new';
const createdLabel = item.createdAt ? formatRelativeTime(item.createdAt) : '';
return (
<div
className={`rounded-2xl border px-3 py-2.5 transition ${isNew ? 'border-pink-200 bg-pink-50/70' : 'border-slate-200 bg-white/90'}`}
onClick={() => {
if (isNew) {
onMarkRead();
}
}}
>
<div className="flex items-start gap-3">
<div className={`rounded-full p-1.5 ${isNew ? 'bg-white text-pink-600' : 'bg-slate-100 text-slate-500'}`}>
<IconComponent className="h-4 w-4" aria-hidden />
</div>
<div className="flex-1 space-y-1">
<div className="flex items-start justify-between gap-2">
<div>
<p className="text-sm font-semibold text-slate-900">{item.title}</p>
{item.body && <p className="text-xs text-slate-600">{item.body}</p>}
</div>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onDismiss();
}}
className="rounded-full p-1 text-slate-400 transition hover:text-slate-700"
aria-label={t('header.notifications.dismiss', 'Ausblenden')}
>
<X className="h-3.5 w-3.5" aria-hidden />
</button>
</div>
<div className="flex items-center gap-2 text-[11px] text-slate-400">
{createdLabel && <span>{createdLabel}</span>}
{isNew && (
<span className="inline-flex items-center gap-1 rounded-full bg-pink-100 px-1.5 py-0.5 text-[10px] font-semibold text-pink-600">
<Sparkles className="h-3 w-3" aria-hidden />
{t('header.notifications.badge.new', 'Neu')}
</span>
)}
</div>
{item.cta && (
<NotificationCta cta={item.cta} onFollow={onMarkRead} />
)}
{!isNew && item.status !== 'dismissed' && (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onMarkRead();
}}
className="inline-flex items-center gap-1 text-[11px] font-semibold text-pink-600"
>
<Check className="h-3 w-3" aria-hidden />
{t('header.notifications.markRead', 'Als gelesen markieren')}
</button>
)}
</div>
</div>
</div>
);
}
function NotificationCta({ cta, onFollow }: { cta: { label?: string; href?: string }; onFollow: () => void }) {
const href = cta.href ?? '#';
const label = cta.label ?? '';
const isInternal = /^\//.test(href);
const content = (
<span className="inline-flex items-center gap-1">
{label}
<ArrowUpRight className="h-3.5 w-3.5" aria-hidden />
</span>
);
if (isInternal) {
return (
<Link
to={href}
className="inline-flex items-center gap-1 text-sm font-semibold text-pink-600"
onClick={onFollow}
>
{content}
</Link>
);
}
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-sm font-semibold text-pink-600"
onClick={onFollow}
>
{content}
</a>
);
}
function NotificationEmptyState({ t, message }: { t: TranslateFn; message?: string }) {
return (
<div className="rounded-2xl border border-dashed border-slate-200 bg-white/70 p-4 text-center text-sm text-slate-500">
<AlertCircle className="mx-auto mb-2 h-5 w-5 text-slate-400" aria-hidden />
<p>{message ?? t('header.notifications.empty', 'Gerade gibt es keine neuen Hinweise.')}</p>
</div>
);
}
function NotificationSkeleton() {
return (
<div className="space-y-2">
{[0, 1, 2].map((index) => (
<div key={index} className="animate-pulse rounded-2xl border border-slate-200 bg-slate-100/60 p-3">
<div className="flex items-center gap-3">
<div className="h-8 w-8 rounded-full bg-slate-200" />
<div className="flex-1 space-y-2">
<div className="h-3 w-3/4 rounded bg-slate-200" />
<div className="h-3 w-1/2 rounded bg-slate-200" />
</div>
</div>
</div>
))}
</div>
);
}
function formatRelativeTime(value: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '';
}
const diffMs = Date.now() - date.getTime();
const diffMinutes = Math.max(0, Math.round(diffMs / 60000));
if (diffMinutes < 1) {
return 'Gerade eben';
}
if (diffMinutes < 60) {
return `${diffMinutes} min`;
}
const diffHours = Math.round(diffMinutes / 60);
if (diffHours < 24) {
return `${diffHours} h`;
}
const diffDays = Math.round(diffHours / 24);
return `${diffDays} d`;
}
function NotificationTabs({
tabs,
activeTab,
onTabChange,
}: {
tabs: Array<{ key: string; label: string; badge?: number }>;
activeTab: string;
onTabChange: (key: string) => void;
}) {
return (
<div className="mt-3 flex gap-2 rounded-full bg-slate-100/80 p-1 text-xs font-semibold text-slate-600">
{tabs.map((tab) => (
<button
key={tab.key}
type="button"
className={`flex flex-1 items-center justify-center gap-1 rounded-full px-3 py-1 transition ${
activeTab === tab.key ? 'bg-white text-pink-600 shadow' : 'text-slate-500'
}`}
onClick={() => onTabChange(tab.key)}
>
{tab.label}
{typeof tab.badge === 'number' && tab.badge > 0 && (
<span className="rounded-full bg-pink-100 px-2 text-[11px] text-pink-600">{tab.badge}</span>
)}
</button>
))}
</div>
);
}
function NotificationStatusBar({
lastFetchedAt,
isOffline,
push,
t,
}: {
lastFetchedAt: Date | null;
isOffline: boolean;
push: PushState;
t: TranslateFn;
}) {
const label = lastFetchedAt ? formatRelativeTime(lastFetchedAt.toISOString()) : t('header.notifications.never', 'Noch keine Aktualisierung');
const pushDescription = React.useMemo(() => {
if (!push.supported) {
return t('header.notifications.pushUnsupported', 'Push wird nicht unterstützt');
}
if (push.permission === 'denied') {
return t('header.notifications.pushDenied', 'Browser blockiert Benachrichtigungen');
}
if (push.subscribed) {
return t('header.notifications.pushActive', 'Push aktiv');
}
return t('header.notifications.pushInactive', 'Push deaktiviert');
}, [push.permission, push.subscribed, push.supported, t]);
const buttonLabel = push.subscribed
? t('header.notifications.pushDisable', 'Deaktivieren')
: t('header.notifications.pushEnable', 'Aktivieren');
const pushButtonDisabled = push.loading || !push.supported || push.permission === 'denied';
return (
<div className="mt-4 space-y-2 border-t border-slate-200 pt-3 text-[11px] text-slate-500">
<div className="flex items-center justify-between">
<span>
{t('header.notifications.lastSync', 'Zuletzt aktualisiert')}: {label}
</span>
{isOffline && (
<span className="inline-flex items-center gap-1 rounded-full bg-amber-100 px-2 py-0.5 font-semibold text-amber-700">
<AlertCircle className="h-3 w-3" aria-hidden />
{t('header.notifications.offline', 'Offline')}
</span>
)}
</div>
<div className="flex items-center justify-between gap-2 rounded-full bg-slate-100/80 px-3 py-1 text-[11px] font-semibold text-slate-600">
<div className="flex items-center gap-1">
<Bell className="h-3.5 w-3.5" aria-hidden />
<span>{pushDescription}</span>
</div>
<button
type="button"
onClick={() => (push.subscribed ? push.disable() : push.enable())}
disabled={pushButtonDisabled}
className="rounded-full bg-white/80 px-3 py-0.5 text-[11px] font-semibold text-pink-600 shadow disabled:cursor-not-allowed disabled:opacity-60"
>
{push.loading ? t('header.notifications.pushLoading', '…') : buttonLabel}
</button>
</div>
{push.error && (
<p className="text-[11px] font-semibold text-rose-600">
{push.error}
</p>
)}
</div>
);
}

View File

@@ -1,69 +0,0 @@
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

@@ -1,80 +0,0 @@
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

@@ -1,160 +0,0 @@
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

@@ -1,77 +0,0 @@
import React from 'react';
import { registerSW } from 'virtual:pwa-register';
import { useTranslation } from '../i18n/useTranslation';
import { useToast } from './ToastHost';
export default function PwaManager() {
const toast = useToast();
const { t } = useTranslation();
const toastRef = React.useRef(toast);
const tRef = React.useRef(t);
const updatePromptedRef = React.useRef(false);
React.useEffect(() => {
toastRef.current = toast;
}, [toast]);
React.useEffect(() => {
tRef.current = t;
}, [t]);
React.useEffect(() => {
if (!('serviceWorker' in navigator)) {
return;
}
const updateSW = registerSW({
immediate: true,
onNeedRefresh() {
if (updatePromptedRef.current) {
return;
}
updatePromptedRef.current = true;
toastRef.current.push({
text: tRef.current('common.updateAvailable'),
type: 'info',
durationMs: 0,
action: {
label: tRef.current('common.updateAction'),
onClick: () => updateSW(true),
},
});
},
onOfflineReady() {
toastRef.current.push({
text: tRef.current('common.offlineReady'),
type: 'success',
});
},
onRegisterError(error) {
console.warn('Guest PWA registration failed', error);
},
});
const runQueue = () => {
void import('../queue/queue')
.then((m) => m.processQueue().catch(() => {}))
.catch(() => {});
};
const handleMessage = (event: MessageEvent) => {
if (event.data?.type === 'sync-queue') {
runQueue();
}
};
navigator.serviceWorker.addEventListener('message', handleMessage);
window.addEventListener('online', runQueue);
runQueue();
return () => {
navigator.serviceWorker.removeEventListener('message', handleMessage);
window.removeEventListener('online', runQueue);
};
}, []);
return null;
}

View File

@@ -1,100 +0,0 @@
import React from 'react';
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion';
import { Outlet, useLocation, useNavigationType } from 'react-router-dom';
const TAB_SECTIONS = new Set(['home', 'tasks', 'achievements', 'gallery']);
export function getTabKey(pathname: string): string | null {
const match = pathname.match(/^\/e\/[^/]+(?:\/([^/]+))?$/);
if (!match) {
return null;
}
const section = match[1];
if (!section) {
return 'home';
}
return TAB_SECTIONS.has(section) ? section : null;
}
export function getTransitionKind(prevPath: string, nextPath: string): 'tab' | 'stack' {
const prevTab = getTabKey(prevPath);
const nextTab = getTabKey(nextPath);
if (prevTab && nextTab && prevTab !== nextTab) {
return 'tab';
}
return 'stack';
}
export function isTransitionDisabled(pathname: string): boolean {
if (pathname.startsWith('/share/')) {
return true;
}
return /^\/e\/[^/]+\/upload(?:\/|$)/.test(pathname);
}
export default function RouteTransition({ children }: { children?: React.ReactNode }) {
const location = useLocation();
const navigationType = useNavigationType();
const prefersReducedMotion = useReducedMotion();
const prevPathRef = React.useRef(location.pathname);
const prevPath = prevPathRef.current;
const direction = navigationType === 'POP' ? 'back' : 'forward';
const kind = getTransitionKind(prevPath, location.pathname);
const disableTransitions = prefersReducedMotion
|| isTransitionDisabled(prevPath)
|| isTransitionDisabled(location.pathname);
React.useEffect(() => {
prevPathRef.current = location.pathname;
}, [location.pathname]);
const content = children ?? <Outlet />;
if (disableTransitions) {
return <>{content}</>;
}
const stackVariants = {
enter: ({ direction }: { direction: 'forward' | 'back' }) => ({
x: direction === 'back' ? -28 : 28,
opacity: 0,
}),
center: { x: 0, opacity: 1 },
exit: ({ direction }: { direction: 'forward' | 'back' }) => ({
x: direction === 'back' ? 28 : -28,
opacity: 0,
}),
};
const tabVariants = {
enter: { opacity: 0, y: 8 },
center: { opacity: 1, y: 0 },
exit: { opacity: 0, y: -8 },
};
const transition = kind === 'tab'
? { duration: 0.22, ease: [0.22, 0.61, 0.36, 1] }
: { duration: 0.28, ease: [0.25, 0.8, 0.25, 1] };
return (
<AnimatePresence initial={false} mode="wait">
<motion.div
key={location.pathname}
custom={{ direction }}
variants={kind === 'tab' ? tabVariants : stackVariants}
initial="enter"
animate="center"
exit="exit"
transition={transition as any}
style={{ willChange: 'transform, opacity' }}
>
{content}
</motion.div>
</AnimatePresence>
);
}

View File

@@ -1,140 +0,0 @@
import React from 'react';
import { Share2, MessageSquare, Copy } from 'lucide-react';
import { useTranslation } from '../i18n/useTranslation';
type ShareSheetProps = {
open: boolean;
photoId?: number | null;
eventName?: string | null;
url?: string | null;
loading?: boolean;
onClose: () => void;
onShareNative: () => void;
onShareWhatsApp: () => void;
onShareMessages: () => void;
onCopyLink: () => void;
radius?: number;
bodyFont?: string | null;
headingFont?: string | null;
};
const WhatsAppIcon = (props: React.SVGProps<SVGSVGElement>) => (
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden focusable="false" {...props}>
<path
fill="currentColor"
d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z"
/>
</svg>
);
export function ShareSheet({
open,
photoId,
eventName,
url,
loading = false,
onClose,
onShareNative,
onShareWhatsApp,
onShareMessages,
onCopyLink,
radius = 12,
bodyFont,
headingFont,
}: ShareSheetProps) {
const { t } = useTranslation();
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/70 backdrop-blur-sm">
<div
className="w-full max-w-md rounded-t-3xl border border-border bg-white/98 p-4 text-slate-900 shadow-2xl ring-1 ring-black/10 backdrop-blur-md dark:border-white/10 dark:bg-slate-900/98 dark:text-white"
style={{ ...(bodyFont ? { fontFamily: bodyFont } : {}), borderRadius: radius }}
>
<div className="mb-4 flex items-start justify-between gap-3">
<div className="space-y-1">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{t('share.title', 'Geteiltes Foto')}
</p>
<p className="text-base font-semibold text-foreground" style={headingFont ? { fontFamily: headingFont } : undefined}>
{photoId ? `#${photoId}` : ''}
</p>
{eventName ? <p className="text-xs text-muted-foreground line-clamp-2">{eventName}</p> : null}
</div>
<button
type="button"
className="rounded-full border border-muted px-3 py-1 text-xs font-semibold text-foreground transition hover:bg-muted/80 dark:border-white/20 dark:text-white"
style={{ borderRadius: radius }}
onClick={onClose}
>
{t('lightbox.close', 'Schließen')}
</button>
</div>
<div className="grid grid-cols-2 gap-3">
<button
type="button"
className="flex items-center gap-3 rounded-2xl border border-slate-200 bg-white px-3 py-3 text-left text-sm font-semibold text-slate-900 shadow-sm transition hover:bg-slate-50 disabled:border-slate-200 disabled:bg-slate-50 disabled:text-slate-800 disabled:opacity-100 dark:border-white/15 dark:bg-white/10 dark:text-white dark:disabled:bg-white/10 dark:disabled:text-white/80"
onClick={onShareNative}
disabled={loading}
style={{ borderRadius: radius }}
>
<Share2 className="h-4 w-4" aria-hidden />
<div>
<div>{t('share.button', 'Teilen')}</div>
<div className="text-xs text-slate-600 dark:text-white/70">{t('share.title', 'Geteiltes Foto')}</div>
</div>
</button>
<button
type="button"
className="flex items-center gap-3 rounded-2xl border border-emerald-200 bg-emerald-500/90 px-3 py-3 text-left text-sm font-semibold text-white shadow transition hover:bg-emerald-600 disabled:opacity-60 dark:border-emerald-400/40"
onClick={onShareWhatsApp}
disabled={loading}
style={{ borderRadius: radius }}
>
<WhatsAppIcon className="h-5 w-5" />
<div>
<div>{t('share.whatsapp', 'WhatsApp')}</div>
<div className="text-xs text-white/80">{loading ? '…' : ''}</div>
</div>
</button>
<button
type="button"
className="flex items-center gap-3 rounded-2xl border border-sky-200 bg-sky-500/90 px-3 py-3 text-left text-sm font-semibold text-white shadow transition hover:bg-sky-600 disabled:opacity-60 dark:border-sky-400/40"
onClick={onShareMessages}
disabled={loading}
style={{ borderRadius: radius }}
>
<MessageSquare className="h-5 w-5" />
<div>
<div>{t('share.imessage', 'Nachrichten')}</div>
<div className="text-xs text-white/80">{loading ? '…' : ''}</div>
</div>
</button>
<button
type="button"
className="flex items-center gap-3 rounded-2xl border border-slate-200 bg-white px-3 py-3 text-left text-sm font-semibold text-slate-900 shadow-sm transition hover:bg-slate-50 disabled:border-slate-200 disabled:bg-slate-100 disabled:text-slate-500 dark:border-white/15 dark:bg-white/10 dark:text-white dark:disabled:bg-white/5 dark:disabled:text-white/50"
onClick={onCopyLink}
disabled={loading}
style={{ borderRadius: radius }}
>
<Copy className="h-4 w-4" aria-hidden />
<div>
<div className="text-slate-900 dark:text-white">{t('share.copyLink', 'Link kopieren')}</div>
<div className="text-xs text-slate-600 dark:text-white/80">{loading ? t('share.loading', 'Lädt…') : ''}</div>
</div>
</button>
</div>
{url ? (
<p className="mt-3 truncate text-xs text-slate-700 dark:text-white/80" title={url}>
{url}
</p>
) : null}
</div>
</div>
);
}
export default ShareSheet;

View File

@@ -1,79 +0,0 @@
// @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

@@ -1,104 +0,0 @@
import React from 'react';
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
import { render, waitFor } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import BottomNav from '../BottomNav';
const originalGetBoundingClientRect = HTMLElement.prototype.getBoundingClientRect;
const originalResizeObserver = globalThis.ResizeObserver;
vi.mock('../../hooks/useEventData', () => ({
useEventData: () => ({
status: 'ready',
event: {
id: 1,
default_locale: 'de',
},
}),
}));
vi.mock('../../i18n/useTranslation', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
vi.mock('../../context/EventBrandingContext', () => ({
useEventBranding: () => ({
branding: {
primaryColor: '#0f172a',
secondaryColor: '#38bdf8',
backgroundColor: '#ffffff',
palette: {
surface: '#ffffff',
},
buttons: {
radius: 12,
style: 'filled',
linkColor: '#0f172a',
},
},
}),
}));
vi.mock('../../lib/engagement', () => ({
isTaskModeEnabled: () => false,
}));
describe('BottomNav', () => {
beforeEach(() => {
HTMLElement.prototype.getBoundingClientRect = () =>
({
x: 0,
y: 0,
width: 0,
height: 80,
top: 0,
left: 0,
right: 0,
bottom: 80,
toJSON: () => ({}),
}) as DOMRect;
(globalThis as unknown as { ResizeObserver: typeof ResizeObserver }).ResizeObserver = class {
private callback: () => void;
constructor(callback: () => void) {
this.callback = callback;
}
observe() {
this.callback();
}
disconnect() {}
};
document.documentElement.style.removeProperty('--guest-bottom-nav-offset');
});
afterEach(() => {
HTMLElement.prototype.getBoundingClientRect = originalGetBoundingClientRect;
document.documentElement.style.removeProperty('--guest-bottom-nav-offset');
if (originalResizeObserver) {
globalThis.ResizeObserver = originalResizeObserver;
} else {
delete (globalThis as unknown as { ResizeObserver?: typeof ResizeObserver }).ResizeObserver;
}
});
it('sets the bottom nav offset CSS variable', async () => {
render(
<MemoryRouter initialEntries={['/e/demo']}>
<Routes>
<Route path="/e/:token/*" element={<BottomNav />} />
</Routes>
</MemoryRouter>
);
await waitFor(() => {
expect(document.documentElement.style.getPropertyValue('--guest-bottom-nav-offset')).toBe('80px');
});
});
});

View File

@@ -1,48 +0,0 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import GalleryPreview from '../GalleryPreview';
vi.mock('../../polling/usePollGalleryDelta', () => ({
usePollGalleryDelta: () => ({
photos: [],
loading: false,
}),
}));
vi.mock('../../i18n/useTranslation', () => ({
useTranslation: () => ({
locale: 'de',
}),
}));
vi.mock('../../context/EventBrandingContext', () => ({
useEventBranding: () => ({
branding: {
primaryColor: '#FF5A5F',
secondaryColor: '#FFF8F5',
buttons: { radius: 12, linkColor: '#FFF8F5' },
typography: {},
fontFamily: 'Montserrat',
},
}),
}));
describe('GalleryPreview', () => {
it('renders dark mode-ready surfaces', () => {
render(
<MemoryRouter>
<GalleryPreview token="demo" />
</MemoryRouter>,
);
const card = screen.getByTestId('gallery-preview');
expect(card.className).toContain('bg-[var(--guest-surface)]');
expect(card.className).toContain('dark:bg-slate-950/70');
const emptyState = screen.getByText(/Noch keine Fotos/i).closest('div');
expect(emptyState).not.toBeNull();
expect(emptyState?.className).toContain('dark:bg-slate-950/60');
});
});

View File

@@ -1,105 +0,0 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import Header from '../Header';
vi.mock('../settings-sheet', () => ({
SettingsSheet: () => <div data-testid="settings-sheet" />,
}));
vi.mock('@/components/appearance-dropdown', () => ({
default: () => <div data-testid="appearance-toggle" />,
}));
vi.mock('../../hooks/useEventData', () => ({
useEventData: () => ({
status: 'ready',
event: {
name: 'Demo Event',
type: { icon: 'heart' },
engagement_mode: 'photo_only',
},
}),
}));
vi.mock('../../context/EventStatsContext', () => ({
useOptionalEventStats: () => null,
}));
vi.mock('../../context/GuestIdentityContext', () => ({
useOptionalGuestIdentity: () => null,
}));
vi.mock('../../context/NotificationCenterContext', () => ({
useOptionalNotificationCenter: () => ({
notifications: [],
unreadCount: 0,
queueItems: [],
queueCount: 0,
pendingCount: 0,
loading: false,
pendingLoading: false,
refresh: vi.fn(),
setFilters: vi.fn(),
markAsRead: vi.fn(),
dismiss: vi.fn(),
eventToken: 'demo',
lastFetchedAt: null,
isOffline: false,
}),
}));
vi.mock('../../hooks/useGuestTaskProgress', () => ({
useGuestTaskProgress: () => ({
hydrated: false,
completedCount: 0,
}),
TASK_BADGE_TARGET: 10,
}));
vi.mock('../../hooks/usePushSubscription', () => ({
usePushSubscription: () => ({
supported: false,
permission: 'default',
subscribed: false,
loading: false,
error: null,
enable: vi.fn(),
disable: vi.fn(),
refresh: vi.fn(),
}),
}));
vi.mock('../../i18n/useTranslation', () => ({
useTranslation: () => ({
t: (_key: string, fallback?: string | { defaultValue?: string }) => {
if (typeof fallback === 'string') {
return fallback;
}
if (fallback && typeof fallback.defaultValue === 'string') {
return fallback.defaultValue;
}
return _key;
},
}),
}));
describe('Header notifications toggle', () => {
it('closes the panel when clicking the bell again', () => {
render(
<MemoryRouter>
<Header eventToken="demo" title="Demo" />
</MemoryRouter>,
);
const bellButton = screen.getByLabelText('Benachrichtigungen anzeigen');
fireEvent.click(bellButton);
expect(screen.getByText('Updates')).toBeInTheDocument();
fireEvent.click(bellButton);
expect(screen.queryByText('Updates')).not.toBeInTheDocument();
});
});

View File

@@ -1,22 +0,0 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import PullToRefresh from '../PullToRefresh';
describe('PullToRefresh', () => {
it('renders children and labels', () => {
render(
<PullToRefresh
onRefresh={vi.fn()}
pullLabel="Pull"
releaseLabel="Release"
refreshingLabel="Refreshing"
>
<div>Content</div>
</PullToRefresh>
);
expect(screen.getByText('Content')).toBeInTheDocument();
expect(screen.getByText('Pull')).toBeInTheDocument();
});
});

View File

@@ -1,23 +0,0 @@
import { describe, expect, it } from 'vitest';
import { getTabKey, getTransitionKind, isTransitionDisabled } from '../RouteTransition';
describe('RouteTransition helpers', () => {
it('detects top-level tabs', () => {
expect(getTabKey('/e/demo')).toBe('home');
expect(getTabKey('/e/demo/tasks')).toBe('tasks');
expect(getTabKey('/e/demo/achievements')).toBe('achievements');
expect(getTabKey('/e/demo/gallery')).toBe('gallery');
expect(getTabKey('/e/demo/tasks/123')).toBeNull();
});
it('detects tab vs stack transitions', () => {
expect(getTransitionKind('/e/demo', '/e/demo/gallery')).toBe('tab');
expect(getTransitionKind('/e/demo/tasks', '/e/demo/tasks/1')).toBe('stack');
});
it('disables transitions for excluded routes', () => {
expect(isTransitionDisabled('/e/demo/upload')).toBe(true);
expect(isTransitionDisabled('/share/demo-photo')).toBe(true);
expect(isTransitionDisabled('/e/demo/gallery')).toBe(false);
});
});

View File

@@ -1,26 +0,0 @@
import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { LocaleProvider } from '../../i18n/LocaleContext';
import { ConsentProvider } from '../../../contexts/consent';
import { SettingsSheet } from '../settings-sheet';
describe('SettingsSheet language section', () => {
it('does not render active badge or description text', () => {
render(
<MemoryRouter>
<ConsentProvider>
<LocaleProvider>
<SettingsSheet />
</LocaleProvider>
</ConsentProvider>
</MemoryRouter>
);
fireEvent.click(screen.getByRole('button', { name: 'Einstellungen öffnen' }));
expect(screen.getByText('Sprache')).toBeInTheDocument();
expect(screen.queryByText('Wähle deine bevorzugte Sprache für diese Veranstaltung.')).not.toBeInTheDocument();
expect(screen.queryByText('aktiv')).not.toBeInTheDocument();
});
});

View File

@@ -1,42 +0,0 @@
import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { vi } from 'vitest';
import { ToastProvider, useToast } from '../ToastHost';
function ToastTestHarness({ onAction }: { onAction: () => void }) {
const toast = useToast();
React.useEffect(() => {
toast.push({
text: 'Update ready',
type: 'info',
durationMs: 0,
action: {
label: 'Reload',
onClick: onAction,
},
});
}, [toast, onAction]);
return null;
}
describe('ToastHost', () => {
it('renders action toasts and dismisses after action click', async () => {
const onAction = vi.fn();
render(
<ToastProvider>
<ToastTestHarness onAction={onAction} />
</ToastProvider>
);
expect(screen.getByText('Update ready')).toBeInTheDocument();
const button = screen.getByRole('button', { name: 'Reload' });
fireEvent.click(button);
expect(onAction).toHaveBeenCalledTimes(1);
expect(screen.queryByText('Update ready')).not.toBeInTheDocument();
});
});

View File

@@ -1,31 +0,0 @@
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

@@ -1,555 +0,0 @@
import React from "react";
import { Link, useLocation, useParams } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import {
Sheet,
SheetTrigger,
SheetContent,
SheetTitle,
SheetDescription,
SheetFooter,
} from '@/components/ui/sheet';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Settings, ArrowLeft, FileText, RefreshCcw, ChevronRight, UserCircle, LifeBuoy } from 'lucide-react';
import { useOptionalGuestIdentity } from '../context/GuestIdentityContext';
import { LegalMarkdown } from './legal-markdown';
import { useLocale, type LocaleContextValue } from '../i18n/LocaleContext';
import { useTranslation } from '../i18n/useTranslation';
import type { LocaleCode } from '../i18n/messages';
import { useHapticsPreference } from '../hooks/useHapticsPreference';
import { triggerHaptic } from '../lib/haptics';
import { getHelpSlugForPathname } from '../lib/helpRouting';
import { useConsent } from '@/contexts/consent';
const legalPages = [
{ slug: 'impressum', translationKey: 'settings.legal.section.impressum' },
{ slug: 'datenschutz', translationKey: 'settings.legal.section.privacy' },
{ slug: 'agb', translationKey: 'settings.legal.section.terms' },
] as const;
type ViewState =
| { mode: 'home' }
| {
mode: 'legal';
slug: (typeof legalPages)[number]['slug'];
translationKey: (typeof legalPages)[number]['translationKey'];
};
type LegalDocumentState =
| { phase: 'idle'; title: string; markdown: string; html: string }
| { phase: 'loading'; title: string; markdown: string; html: string }
| { phase: 'ready'; title: string; markdown: string; html: string }
| { phase: 'error'; title: string; markdown: string; html: string };
type NameStatus = 'idle' | 'saved';
export function SettingsSheet() {
const [open, setOpen] = React.useState(false);
const [view, setView] = React.useState<ViewState>({ mode: 'home' });
const identity = useOptionalGuestIdentity();
const localeContext = useLocale();
const { t } = useTranslation();
const params = useParams<{ token?: string }>();
const location = useLocation();
const [nameDraft, setNameDraft] = React.useState(identity?.name ?? '');
const [nameStatus, setNameStatus] = React.useState<NameStatus>('idle');
const [savingName, setSavingName] = React.useState(false);
const isLegal = view.mode === 'legal';
const legalDocument = useLegalDocument(isLegal ? view.slug : null, localeContext.locale);
const helpSlug = getHelpSlugForPathname(location.pathname);
const helpBase = params?.token ? `/e/${encodeURIComponent(params.token)}/help` : '/help';
const helpHref = helpSlug ? `${helpBase}/${helpSlug}` : helpBase;
React.useEffect(() => {
if (open && identity?.hydrated) {
setNameDraft(identity.name ?? '');
setNameStatus('idle');
}
}, [open, identity?.hydrated, identity?.name]);
const handleBack = React.useCallback(() => {
setView({ mode: 'home' });
}, []);
const handleOpenLegal = React.useCallback(
(
slug: (typeof legalPages)[number]['slug'],
translationKey: (typeof legalPages)[number]['translationKey'],
) => {
setView({ mode: 'legal', slug, translationKey });
},
[],
);
const handleOpenChange = React.useCallback((next: boolean) => {
setOpen(next);
if (!next) {
setView({ mode: 'home' });
setNameStatus('idle');
}
}, []);
const canSaveName = Boolean(
identity?.hydrated && nameDraft.trim() && nameDraft.trim() !== (identity?.name ?? '')
);
const handleSaveName = React.useCallback(() => {
if (!identity || !canSaveName) {
return;
}
setSavingName(true);
try {
identity.setName(nameDraft);
setNameStatus('saved');
window.setTimeout(() => setNameStatus('idle'), 2000);
} finally {
setSavingName(false);
}
}, [identity, nameDraft, canSaveName]);
const handleResetName = React.useCallback(() => {
if (!identity) return;
identity.clearName();
setNameDraft('');
setNameStatus('idle');
}, [identity]);
return (
<Sheet open={open} onOpenChange={handleOpenChange}>
<SheetTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-10 w-10 rounded-2xl border border-white/25 bg-white/15 text-current shadow-lg shadow-black/20 backdrop-blur transition hover:border-white/40 hover:bg-white/25 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/60 dark:border-white/10 dark:bg-white/10 dark:hover:bg-white/15"
>
<Settings className="h-5 w-5" />
<span className="sr-only">{t('settings.sheet.openLabel')}</span>
</Button>
</SheetTrigger>
<SheetContent side="right" className="sm:max-w-md">
<div className="flex h-full flex-col">
<header className="border-b bg-background px-6 py-4">
{isLegal ? (
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="icon"
className="h-9 w-9"
onClick={handleBack}
>
<ArrowLeft className="h-5 w-5" />
<span className="sr-only">{t('settings.sheet.backLabel')}</span>
</Button>
<div className="min-w-0">
<SheetTitle className="truncate">
{legalDocument.phase === 'ready' && legalDocument.title
? legalDocument.title
: t(view.translationKey)}
</SheetTitle>
<SheetDescription>
{legalDocument.phase === 'loading'
? t('common.actions.loading')
: t('settings.sheet.legalDescription')}
</SheetDescription>
</div>
</div>
) : (
<div>
<SheetTitle>{t('settings.title')}</SheetTitle>
<SheetDescription>{t('settings.subtitle')}</SheetDescription>
</div>
)}
</header>
<main className="flex-1 overflow-y-auto px-6 py-4">
{isLegal ? (
<LegalView
document={legalDocument}
onClose={() => handleOpenChange(false)}
translationKey={view.mode === 'legal' ? view.translationKey : null}
/>
) : (
<HomeView
identity={identity}
nameDraft={nameDraft}
onNameChange={setNameDraft}
onSaveName={handleSaveName}
onResetName={handleResetName}
canSaveName={canSaveName}
savingName={savingName}
nameStatus={nameStatus}
localeContext={localeContext}
onOpenLegal={handleOpenLegal}
helpHref={helpHref}
/>
)}
</main>
<SheetFooter className="border-t bg-muted/40 px-6 py-3 text-xs text-muted-foreground">
<div>{t('settings.footer.notice')}</div>
</SheetFooter>
</div>
</SheetContent>
</Sheet>
);
}
function LegalView({
document,
onClose,
translationKey,
}: {
document: LegalDocumentState;
onClose: () => void;
translationKey: string | null;
}) {
const { t } = useTranslation();
if (document.phase === 'error') {
return (
<div className="space-y-4">
<Alert variant="destructive">
<AlertDescription>
{t('settings.legal.error')}
</AlertDescription>
</Alert>
<Button variant="secondary" onClick={onClose}>
{t('common.actions.close')}
</Button>
</div>
);
}
if (document.phase === 'loading' || document.phase === 'idle') {
return <div className="text-sm text-muted-foreground">{t('settings.legal.loading')}</div>;
}
return (
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle>{document.title || t(translationKey ?? 'settings.legal.fallbackTitle')}</CardTitle>
</CardHeader>
<CardContent 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">
<LegalMarkdown markdown={document.markdown} html={document.html} />
</CardContent>
</Card>
</div>
);
}
interface HomeViewProps {
identity: ReturnType<typeof useOptionalGuestIdentity>;
nameDraft: string;
onNameChange: (value: string) => void;
onSaveName: () => void;
onResetName: () => void;
canSaveName: boolean;
savingName: boolean;
nameStatus: NameStatus;
localeContext: LocaleContextValue;
onOpenLegal: (
slug: (typeof legalPages)[number]['slug'],
translationKey: (typeof legalPages)[number]['translationKey'],
) => void;
helpHref: string;
}
function HomeView({
identity,
nameDraft,
onNameChange,
onSaveName,
onResetName,
canSaveName,
savingName,
nameStatus,
localeContext,
onOpenLegal,
helpHref,
}: HomeViewProps) {
const { t } = useTranslation();
const { enabled: hapticsEnabled, setEnabled: setHapticsEnabled, supported: hapticsSupported } = useHapticsPreference();
const { preferences, savePreferences } = useConsent();
const matomoEnabled = typeof window !== 'undefined' && Boolean((window as any).__MATOMO_GUEST__?.enabled);
const legalLinks = React.useMemo(
() =>
legalPages.map((page) => ({
slug: page.slug,
translationKey: page.translationKey,
label: t(page.translationKey),
})),
[t],
);
return (
<div className="space-y-6">
<Card>
<CardHeader className="pb-3">
<CardTitle>{t('settings.language.title')}</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-2">
{localeContext.availableLocales.map((option) => {
const isActive = localeContext.locale === option.code;
return (
<Button
key={option.code}
type="button"
variant={isActive ? 'default' : 'outline'}
className={`flex h-12 flex-col justify-center gap-1 rounded-lg border text-sm ${
isActive ? 'bg-pink-500 text-white hover:bg-pink-600' : 'bg-background'
}`}
onClick={() => localeContext.setLocale(option.code)}
aria-pressed={isActive}
disabled={!localeContext.hydrated}
>
<span aria-hidden className="text-lg leading-none">{option.flag}</span>
<span className="font-medium">{t(`settings.language.option.${option.code}`)}</span>
</Button>
);
})}
</div>
</CardContent>
</Card>
{identity && (
<Card>
<CardHeader className="pb-3">
<CardTitle>{t('settings.name.title')}</CardTitle>
<CardDescription>{t('settings.name.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-pink-100 text-pink-600">
<UserCircle className="h-6 w-6" />
</div>
<div className="flex-1 space-y-2">
<Label htmlFor="guest-name" className="text-sm font-medium">
{t('settings.name.label')}
</Label>
<Input
id="guest-name"
value={nameDraft}
placeholder={t('settings.name.placeholder')}
onChange={(event) => onNameChange(event.target.value)}
autoComplete="name"
disabled={!identity.hydrated || savingName}
/>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button onClick={onSaveName} disabled={!canSaveName || savingName}>
{savingName ? t('settings.name.saving') : t('settings.name.save')}
</Button>
<Button type="button" variant="ghost" onClick={onResetName} disabled={savingName}>
{t('settings.name.reset')}
</Button>
{nameStatus === 'saved' && (
<span className="text-xs text-muted-foreground">{t('settings.name.saved')}</span>
)}
{!identity.hydrated && (
<span className="text-xs text-muted-foreground">{t('settings.name.loading')}</span>
)}
</div>
</CardContent>
</Card>
)}
<Card>
<CardHeader className="pb-3">
<CardTitle>{t('settings.haptics.title')}</CardTitle>
<CardDescription>{t('settings.haptics.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
<div className="flex items-center justify-between gap-4">
<span className="text-sm font-medium">{t('settings.haptics.label')}</span>
<Switch
checked={hapticsEnabled}
onCheckedChange={(checked) => {
setHapticsEnabled(checked);
if (checked) {
triggerHaptic('selection');
}
}}
disabled={!hapticsSupported}
aria-label={t('settings.haptics.label')}
/>
</div>
{!hapticsSupported && (
<div className="text-xs text-muted-foreground">{t('settings.haptics.unsupported')}</div>
)}
</CardContent>
</Card>
{matomoEnabled ? (
<Card>
<CardHeader className="pb-3">
<CardTitle>{t('settings.analytics.title')}</CardTitle>
<CardDescription>{t('settings.analytics.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
<div className="flex items-center justify-between gap-4">
<span className="text-sm font-medium">{t('settings.analytics.label')}</span>
<Switch
checked={Boolean(preferences?.analytics)}
onCheckedChange={(checked) => savePreferences({ analytics: checked })}
aria-label={t('settings.analytics.label')}
/>
</div>
<div className="text-xs text-muted-foreground">{t('settings.analytics.note')}</div>
</CardContent>
</Card>
) : null}
<Card>
<CardHeader className="pb-3">
<CardTitle>
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-pink-500" />
{t('settings.legal.title')}
</div>
</CardTitle>
<CardDescription>{t('settings.legal.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
{legalLinks.map((page) => (
<Button
key={page.slug}
variant="ghost"
className="w-full justify-between px-3"
onClick={() => onOpenLegal(page.slug, page.translationKey)}
>
<span className="text-left text-sm">{page.label}</span>
<ChevronRight className="h-4 w-4" />
</Button>
))}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle>
<div className="flex items-center gap-2">
<LifeBuoy className="h-4 w-4 text-pink-500" />
{t('settings.help.title')}
</div>
</CardTitle>
<CardDescription>{t('settings.help.description')}</CardDescription>
</CardHeader>
<CardContent>
<Button asChild className="w-full">
<Link to={helpHref}>{t('settings.help.cta')}</Link>
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t('settings.cache.title')}</CardTitle>
<CardDescription>{t('settings.cache.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<ClearCacheButton />
<div className="flex items-start gap-2 text-xs text-muted-foreground">
<RefreshCcw className="mt-0.5 h-3.5 w-3.5" />
<span>{t('settings.cache.note')}</span>
</div>
</CardContent>
</Card>
</div>
);
}
function useLegalDocument(slug: string | null, locale: LocaleCode): LegalDocumentState {
const [state, setState] = React.useState<LegalDocumentState>({
phase: 'idle',
title: '',
markdown: '',
html: '',
});
React.useEffect(() => {
if (!slug) {
setState({ phase: 'idle', title: '', markdown: '', html: '' });
return;
}
const controller = new AbortController();
setState({ phase: 'loading', title: '', markdown: '', html: '' });
const langParam = encodeURIComponent(locale);
fetch(`/api/v1/legal/${encodeURIComponent(slug)}?lang=${langParam}`, {
headers: { 'Cache-Control': 'no-store' },
signal: controller.signal,
})
.then(async (res) => {
if (!res.ok) {
throw new Error('failed');
}
const payload = await res.json();
setState({
phase: 'ready',
title: payload.title ?? '',
markdown: payload.body_markdown ?? '',
html: payload.body_html ?? '',
});
})
.catch((error) => {
if (controller.signal.aborted) {
return;
}
console.error('Failed to load legal page', error);
setState({ phase: 'error', title: '', markdown: '', html: '' });
});
return () => controller.abort();
}, [slug, locale]);
return state;
}
function ClearCacheButton() {
const [busy, setBusy] = React.useState(false);
const [done, setDone] = React.useState(false);
const { t } = useTranslation();
async function clearAll() {
setBusy(true);
setDone(false);
try {
if ('caches' in window) {
const keys = await caches.keys();
await Promise.all(keys.map((key) => caches.delete(key)));
}
if ('indexedDB' in window) {
try {
await new Promise((resolve) => {
const request = indexedDB.deleteDatabase('upload-queue');
request.onsuccess = () => resolve(null);
request.onerror = () => resolve(null);
});
} catch (error) {
console.warn('IndexedDB cleanup failed', error);
}
}
setDone(true);
} finally {
setBusy(false);
window.setTimeout(() => setDone(false), 2500);
}
}
return (
<div className="space-y-2">
<Button variant="secondary" onClick={clearAll} disabled={busy} className="w-full">
{busy ? t('settings.cache.clearing') : t('settings.cache.clear')}
</Button>
{done && <div className="text-xs text-muted-foreground">{t('settings.cache.cleared')}</div>}
</div>
);
}

View File

@@ -1,349 +0,0 @@
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

@@ -1,30 +0,0 @@
import React from 'react';
import { usePollStats } from '../polling/usePollStats';
type EventStatsContextValue = ReturnType<typeof usePollStats> & {
eventKey: string;
slug: string;
};
const EventStatsContext = React.createContext<EventStatsContextValue | undefined>(undefined);
export function EventStatsProvider({ eventKey, children }: { eventKey: string; children: React.ReactNode }) {
const stats = usePollStats(eventKey);
const value = React.useMemo<EventStatsContextValue>(
() => ({ eventKey, slug: eventKey, ...stats }),
[eventKey, stats]
);
return <EventStatsContext.Provider value={value}>{children}</EventStatsContext.Provider>;
}
export function useEventStats() {
const ctx = React.useContext(EventStatsContext);
if (!ctx) {
throw new Error('useEventStats must be used within an EventStatsProvider');
}
return ctx;
}
export function useOptionalEventStats() {
return React.useContext(EventStatsContext);
}

View File

@@ -1,111 +0,0 @@
import React from 'react';
type GuestIdentityContextValue = {
eventKey: string;
slug: string; // backward-compatible alias
name: string;
hydrated: boolean;
setName: (nextName: string) => void;
clearName: () => void;
reload: () => void;
};
const GuestIdentityContext = React.createContext<GuestIdentityContextValue | undefined>(undefined);
function storageKey(eventKey: string) {
return `guestName_${eventKey}`;
}
export function readGuestName(eventKey: string) {
if (!eventKey || typeof window === 'undefined') {
return '';
}
try {
return window.localStorage.getItem(storageKey(eventKey)) ?? '';
} catch (error) {
console.warn('Failed to read guest name', error);
return '';
}
}
export function GuestIdentityProvider({ eventKey, children }: { eventKey: string; children: React.ReactNode }) {
const [name, setNameState] = React.useState('');
const [hydrated, setHydrated] = React.useState(false);
const loadFromStorage = React.useCallback(() => {
if (!eventKey) {
setHydrated(true);
setNameState('');
return;
}
try {
const stored = window.localStorage.getItem(storageKey(eventKey));
setNameState(stored ?? '');
} catch (error) {
console.warn('Failed to read guest name from storage', error);
setNameState('');
} finally {
setHydrated(true);
}
}, [eventKey]);
React.useEffect(() => {
setHydrated(false);
loadFromStorage();
}, [loadFromStorage]);
const persistName = React.useCallback(
(nextName: string) => {
const trimmed = nextName.trim();
setNameState(trimmed);
try {
if (trimmed) {
window.localStorage.setItem(storageKey(eventKey), trimmed);
} else {
window.localStorage.removeItem(storageKey(eventKey));
}
} catch (error) {
console.warn('Failed to persist guest name', error);
}
},
[eventKey]
);
const clearName = React.useCallback(() => {
setNameState('');
try {
window.localStorage.removeItem(storageKey(eventKey));
} catch (error) {
console.warn('Failed to clear guest name', error);
}
}, [eventKey]);
const value = React.useMemo<GuestIdentityContextValue>(
() => ({
eventKey,
slug: eventKey,
name,
hydrated,
setName: persistName,
clearName,
reload: loadFromStorage,
}),
[eventKey, name, hydrated, persistName, clearName, loadFromStorage]
);
return <GuestIdentityContext.Provider value={value}>{children}</GuestIdentityContext.Provider>;
}
export function useGuestIdentity() {
const ctx = React.useContext(GuestIdentityContext);
if (!ctx) {
throw new Error('useGuestIdentity must be used within a GuestIdentityProvider');
}
return ctx;
}
export function useOptionalGuestIdentity() {
return React.useContext(GuestIdentityContext);
}

View File

@@ -1,304 +0,0 @@
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

@@ -1,96 +0,0 @@
import React from 'react';
import { render, waitFor } from '@testing-library/react';
import { EventBrandingProvider } from '../EventBrandingContext';
import { AppearanceProvider } from '@/hooks/use-appearance';
import type { EventBranding } from '../../types/event-branding';
const sampleBranding: EventBranding = {
primaryColor: '#ff3366',
secondaryColor: '#ff99aa',
backgroundColor: '#fef2f2',
fontFamily: 'Montserrat, sans-serif',
logoUrl: null,
typography: {
heading: null,
body: null,
sizePreset: 'l',
},
mode: 'dark',
};
describe('EventBrandingProvider', () => {
afterEach(() => {
document.documentElement.classList.remove('guest-theme', 'dark');
document.documentElement.style.removeProperty('color-scheme');
document.documentElement.style.removeProperty('--guest-background');
document.documentElement.style.removeProperty('--guest-font-scale');
localStorage.removeItem('theme');
});
it('applies guest theme classes and variables', async () => {
const { unmount } = render(
<EventBrandingProvider branding={sampleBranding}>
<div>Guest</div>
</EventBrandingProvider>
);
await waitFor(() => {
expect(document.documentElement.classList.contains('guest-theme')).toBe(true);
expect(document.documentElement.classList.contains('dark')).toBe(true);
expect(document.documentElement.style.colorScheme).toBe('dark');
expect(document.documentElement.style.getPropertyValue('--guest-background')).toBe('#0f172a');
expect(document.documentElement.style.getPropertyValue('--guest-font-scale')).toBe('1.08');
expect(document.documentElement.style.getPropertyValue('--foreground')).toBe('#f8fafc');
});
unmount();
expect(document.documentElement.classList.contains('guest-theme')).toBe(false);
});
it('respects appearance override in auto mode', async () => {
localStorage.setItem('theme', 'dark');
const autoBranding: EventBranding = {
...sampleBranding,
mode: 'auto',
backgroundColor: '#fff7ed',
};
const { unmount } = render(
<AppearanceProvider>
<EventBrandingProvider branding={autoBranding}>
<div>Guest</div>
</EventBrandingProvider>
</AppearanceProvider>
);
await waitFor(() => {
expect(document.documentElement.classList.contains('dark')).toBe(true);
});
unmount();
});
it('prefers explicit appearance over branding mode', async () => {
localStorage.setItem('theme', 'light');
const darkBranding: EventBranding = {
...sampleBranding,
mode: 'dark',
backgroundColor: '#0f172a',
};
const { unmount } = render(
<AppearanceProvider>
<EventBrandingProvider branding={darkBranding}>
<div>Guest</div>
</EventBrandingProvider>
</AppearanceProvider>
);
await waitFor(() => {
expect(document.documentElement.classList.contains('dark')).toBe(false);
});
unmount();
});
});

View File

@@ -1,220 +0,0 @@
import { demoFixtures, type DemoFixtures } from './fixtures';
type DemoConfig = {
fixtures: DemoFixtures;
};
let enabled = false;
let originalFetch: typeof window.fetch | null = null;
const likeState = new Map<number, number>();
declare global {
interface Window {
__FOTOSPIEL_DEMO__?: boolean;
__FOTOSPIEL_DEMO_ACTIVE__?: boolean;
}
}
export function shouldEnableGuestDemoMode(): boolean {
if (typeof window === 'undefined') {
return false;
}
const params = new URLSearchParams(window.location.search);
if (params.get('demo') === '1') {
return true;
}
if (window.__FOTOSPIEL_DEMO__ === true) {
return true;
}
const attr = document.documentElement?.dataset?.guestDemo;
return attr === 'true';
}
export function enableGuestDemoMode(config: DemoConfig = { fixtures: demoFixtures }): void {
if (typeof window === 'undefined' || enabled) {
return;
}
originalFetch = window.fetch.bind(window);
window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
const request = new Request(input, init);
const url = new URL(request.url, window.location.origin);
const response = handleDemoRequest(url, request, config.fixtures);
if (response) {
return response;
}
return originalFetch!(request);
};
enabled = true;
window.__FOTOSPIEL_DEMO_ACTIVE__ = true;
notifyDemoToast();
}
function handleDemoRequest(url: URL, request: Request, fixtures: DemoFixtures): Promise<Response> | null {
if (!url.pathname.startsWith('/api/')) {
return null;
}
const eventMatch = url.pathname.match(/^\/api\/v1\/events\/([^/]+)(?:\/(.*))?/);
if (eventMatch) {
const token = decodeURIComponent(eventMatch[1]);
const remainder = eventMatch[2] ?? '';
if (token !== fixtures.token && token !== fixtures.event.slug && token !== 'demo') {
return null;
}
return Promise.resolve(handleDemoEventEndpoint(remainder, request, fixtures));
}
const galleryMatch = url.pathname.match(/^\/api\/v1\/gallery\/([^/]+)(?:\/(.*))?/);
if (galleryMatch) {
const token = decodeURIComponent(galleryMatch[1]);
if (token !== fixtures.token && token !== fixtures.event.slug && token !== 'demo') {
return null;
}
const resource = galleryMatch[2] ?? '';
if (!resource) {
return Promise.resolve(jsonResponse(fixtures.gallery.meta));
}
if (resource.startsWith('photos')) {
return Promise.resolve(
jsonResponse({ data: fixtures.gallery.photos, next_cursor: null }, { etag: '"demo-gallery"' })
);
}
}
if (url.pathname.startsWith('/api/v1/photo-shares/')) {
return Promise.resolve(jsonResponse(fixtures.share));
}
if (url.pathname.startsWith('/api/v1/photos/')) {
return Promise.resolve(handlePhotoAction(url, request, fixtures));
}
return null;
}
function handleDemoEventEndpoint(path: string, request: Request, fixtures: DemoFixtures): Response {
const [resource, ...rest] = path.split('/').filter(Boolean);
const method = request.method.toUpperCase();
switch (resource) {
case undefined:
return jsonResponse(fixtures.event);
case 'stats':
return jsonResponse(fixtures.stats);
case 'package':
return jsonResponse(fixtures.eventPackage);
case 'tasks':
if (method === 'GET') {
return jsonResponse(fixtures.tasks, { etag: '"demo-tasks"' });
}
return blockedResponse('Aufgaben können in der Demo nicht geändert werden.');
case 'photos':
if (method === 'GET') {
return jsonResponse({ data: fixtures.photos, latest_photo_at: fixtures.photos[0]?.created_at ?? null }, {
etag: '"demo-photos"',
});
}
if (method === 'POST') {
return blockedResponse('Uploads sind in der Demo deaktiviert.');
}
break;
case 'upload':
return blockedResponse('Uploads sind in der Demo deaktiviert.');
case 'achievements':
return jsonResponse(fixtures.achievements, { etag: '"demo-achievements"' });
case 'emotions':
return jsonResponse(fixtures.emotions, { etag: '"demo-emotions"' });
case 'notifications':
if (rest.length >= 2) {
return new Response(null, { status: 204 });
}
return jsonResponse({ data: fixtures.notifications, meta: { unread_count: 1 } }, { etag: '"demo-notifications"' });
case 'push-subscriptions':
return new Response(null, { status: 204 });
default:
break;
}
return jsonResponse({ demo: true });
}
function handlePhotoAction(url: URL, request: Request, fixtures: DemoFixtures): Response {
const pathname = url.pathname.replace('/api/v1/photos/', '');
const [photoIdPart, action] = pathname.split('/');
const photoId = Number(photoIdPart);
const targetPhoto = fixtures.photos.find((photo) => photo.id === photoId);
if (action === 'like') {
if (!targetPhoto) {
return new Response(JSON.stringify({ error: { message: 'Foto nicht gefunden' } }), { status: 404, headers: demoHeaders() });
}
const current = likeState.get(photoId) ?? targetPhoto.likes_count;
const next = current + 1;
likeState.set(photoId, next);
return jsonResponse({ likes_count: next });
}
if (action === 'share' && request.method.toUpperCase() === 'POST') {
return jsonResponse({ slug: fixtures.share.slug, url: `${window.location.origin}/share/${fixtures.share.slug}` });
}
return new Response(JSON.stringify({ error: { message: 'Demo-Endpunkt nicht verfügbar.' } }), {
status: 404,
headers: demoHeaders(),
});
}
function jsonResponse(data: unknown, options: { etag?: string } = {}): Response {
const headers = demoHeaders();
if (options.etag) {
headers.ETag = options.etag;
}
return new Response(JSON.stringify(data), {
status: 200,
headers,
});
}
function demoHeaders(): Record<string, string> {
return {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
};
}
function blockedResponse(message: string): Response {
return new Response(
JSON.stringify({
error: {
code: 'demo_read_only',
message,
},
}),
{
status: 403,
headers: demoHeaders(),
}
);
}
export function isGuestDemoModeEnabled(): boolean {
return enabled;
}
function notifyDemoToast(): void {
if (typeof document === 'undefined') {
return;
}
try {
const detail = { type: 'info', text: 'Demo-Modus aktiv. Änderungen werden nicht gespeichert.' };
window.setTimeout(() => {
window.dispatchEvent(new CustomEvent('guest-toast', { detail }));
}, 0);
} catch {
// ignore
}
}

View File

@@ -1,380 +0,0 @@
import type { AchievementsPayload } from '../services/achievementApi';
import type { EventData, EventPackage, EventStats } from '../services/eventApi';
import type { GalleryMetaResponse, GalleryPhotoResource } from '../services/galleryApi';
export type DemoTask = {
id: number;
title: string;
description: string;
duration?: number;
emotion?: {
slug: string;
name: string;
emoji?: string;
} | null;
category?: string | null;
};
export type DemoPhoto = {
id: number;
url: string;
thumbnail_url: string;
created_at: string;
uploader_name: string;
likes_count: number;
task_id?: number | null;
task_title?: string | null;
ingest_source?: string | null;
};
export type DemoEmotion = {
id: number;
slug: string;
name: string;
emoji: string;
description?: string;
};
export type DemoNotification = {
id: number;
type: string;
title: string;
body: string;
status: 'new' | 'read' | 'dismissed';
created_at: string;
cta?: { label: string; href: string } | null;
};
export type DemoSharePayload = {
slug: string;
expires_at?: string;
photo: {
id: number;
title: string;
likes_count: number;
emotion?: { name: string; emoji: string } | null;
image_urls: { full: string; thumbnail: string };
};
event?: { id: number; name: string } | null;
};
export interface DemoFixtures {
token: string;
event: EventData;
stats: EventStats;
eventPackage: EventPackage;
tasks: DemoTask[];
photos: DemoPhoto[];
gallery: {
meta: GalleryMetaResponse;
photos: GalleryPhotoResource[];
};
achievements: AchievementsPayload;
emotions: DemoEmotion[];
notifications: DemoNotification[];
share: DemoSharePayload;
}
const now = () => new Date().toISOString();
export const demoFixtures: DemoFixtures = {
token: 'demo',
event: {
id: 999,
slug: 'demo-wedding-2025',
name: 'Demo Wedding 2025',
default_locale: 'de',
created_at: '2025-01-10T12:00:00Z',
updated_at: now(),
branding: {
primary_color: '#FF6B6B',
secondary_color: '#FEB47B',
background_color: '#FFF7F5',
font_family: '"General Sans", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
logo_url: null,
},
join_token: 'demo',
type: {
slug: 'wedding',
name: 'Hochzeit',
icon: 'sparkles',
},
},
stats: {
onlineGuests: 42,
tasksSolved: 187,
guestCount: 128,
likesCount: 980,
latestPhotoAt: now(),
},
eventPackage: {
id: 501,
event_id: 999,
package_id: 301,
used_photos: 820,
used_guests: 95,
expires_at: '2025-12-31T23:59:59Z',
package: {
id: 301,
name: 'Soulmate Unlimited',
max_photos: 5000,
max_guests: 250,
gallery_days: 365,
},
limits: {
photos: {
limit: 5000,
used: 820,
remaining: 4180,
percentage: 0.164,
state: 'ok',
threshold_reached: null,
next_threshold: 0.5,
thresholds: [0.5, 0.8],
},
guests: {
limit: 250,
used: 95,
remaining: 155,
percentage: 0.38,
state: 'ok',
threshold_reached: null,
next_threshold: 0.6,
thresholds: [0.6, 0.9],
},
gallery: {
state: 'ok',
expires_at: '2025-12-31T23:59:59Z',
days_remaining: 320,
warning_thresholds: [30, 7],
warning_triggered: null,
warning_sent_at: null,
expired_notified_at: null,
},
can_upload_photos: true,
can_add_guests: true,
},
},
tasks: [
{
id: 101,
title: 'Der erste Blick',
description: 'Haltet den Moment fest, wenn sich das Paar zum ersten Mal sieht.',
duration: 4,
emotion: { slug: 'romance', name: 'Romantik', emoji: '💞' },
},
{
id: 102,
title: 'Dancefloor Close-Up',
description: 'Zoomt auf Hände, Schuhe oder Accessoires, die auf der Tanzfläche glänzen.',
duration: 3,
emotion: { slug: 'party', name: 'Party', emoji: '🎉' },
},
{
id: 103,
title: 'Tischgespräche',
description: 'Fotografiert zwei Personen, die heimlich lachen.',
duration: 2,
emotion: { slug: 'fun', name: 'Spaß', emoji: '😄' },
},
{
id: 104,
title: 'Team Selfie',
description: 'Mindestens fünf Gäste auf einem Selfie Bonus für wilde Posen.',
duration: 5,
emotion: { slug: 'squad', name: 'Squad Goals', emoji: '🤳' },
},
],
photos: [
{
id: 8801,
url: 'https://images.unsplash.com/photo-1520854223477-5e2c1a6610f0?auto=format&fit=crop&w=1600&q=80',
thumbnail_url: 'https://images.unsplash.com/photo-1520854223477-5e2c1a6610f0?auto=format&fit=crop&w=600&q=60',
created_at: '2025-05-10T18:45:00Z',
uploader_name: 'Lena',
likes_count: 24,
task_id: 101,
task_title: 'Der erste Blick',
ingest_source: 'guest',
},
{
id: 8802,
url: 'https://images.unsplash.com/photo-1502727135889-a63a201a02f9?auto=format&fit=crop&w=1600&q=80',
thumbnail_url: 'https://images.unsplash.com/photo-1502727135889-a63a201a02f9?auto=format&fit=crop&w=600&q=60',
created_at: '2025-05-10T19:12:00Z',
uploader_name: 'Nico',
likes_count: 31,
task_id: 102,
task_title: 'Dancefloor Close-Up',
ingest_source: 'guest',
},
{
id: 8803,
url: 'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=1600&q=80',
thumbnail_url: 'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=600&q=60',
created_at: '2025-05-10T19:40:00Z',
uploader_name: 'Aylin',
likes_count: 18,
task_id: 103,
task_title: 'Tischgespräche',
ingest_source: 'guest',
},
{
id: 8804,
url: 'https://images.unsplash.com/photo-1519741497674-611481863552?auto=format&fit=crop&w=1600&q=80',
thumbnail_url: 'https://images.unsplash.com/photo-1519741497674-611481863552?auto=format&fit=crop&w=600&q=60',
created_at: '2025-05-10T20:05:00Z',
uploader_name: 'Mara',
likes_count: 42,
task_id: 104,
task_title: 'Team Selfie',
ingest_source: 'guest',
},
],
gallery: {
meta: {
event: {
id: 999,
name: 'Demo Wedding 2025',
slug: 'demo-wedding-2025',
description: 'Erlebe die Story eines Demo-Events Fotos, Aufgaben und Emotionen live in der PWA.',
gallery_expires_at: '2025-12-31T23:59:59Z',
},
branding: {
primary_color: '#FF6B6B',
secondary_color: '#FEB47B',
background_color: '#FFF7F5',
},
},
photos: [
{
id: 9001,
thumbnail_url: 'https://images.unsplash.com/photo-1520854223477-5e2c1a6610f0?auto=format&fit=crop&w=400&q=60',
full_url: 'https://images.unsplash.com/photo-1520854223477-5e2c1a6610f0?auto=format&fit=crop&w=1600&q=80',
download_url: 'https://images.unsplash.com/photo-1520854223477-5e2c1a6610f0?auto=format&fit=crop&w=1600&q=80',
likes_count: 18,
guest_name: 'Leonie',
created_at: '2025-05-10T18:40:00Z',
},
{
id: 9002,
thumbnail_url: 'https://images.unsplash.com/photo-1502727135889-a63a201a02f9?auto=format&fit=crop&w=400&q=60',
full_url: 'https://images.unsplash.com/photo-1502727135889-a63a201a02f9?auto=format&fit=crop&w=1600&q=80',
download_url: 'https://images.unsplash.com/photo-1502727135889-a63a201a02f9?auto=format&fit=crop&w=1600&q=80',
likes_count: 25,
guest_name: 'Chris',
created_at: '2025-05-10T19:10:00Z',
},
],
},
achievements: {
summary: {
totalPhotos: 820,
uniqueGuests: 96,
tasksSolved: 312,
likesTotal: 2100,
},
personal: {
guestName: 'Demo Gast',
photos: 12,
tasks: 5,
likes: 38,
badges: [
{ id: 'starter', title: 'Warm-up', description: 'Deine ersten 3 Fotos', earned: true, progress: 3, target: 3 },
{ id: 'mission', title: 'Mission Master', description: '5 Aufgaben geschafft', earned: true, progress: 5, target: 5 },
{ id: 'marathon', title: 'Galerie-Profi', description: '50 Fotos hochladen', earned: false, progress: 12, target: 50 },
],
},
leaderboards: {
uploads: [
{ guest: 'Sven', photos: 35, likes: 120 },
{ guest: 'Lena', photos: 28, likes: 140 },
{ guest: 'Demo Gast', photos: 12, likes: 38 },
],
likes: [
{ guest: 'Mara', photos: 18, likes: 160 },
{ guest: 'Noah', photos: 22, likes: 150 },
{ guest: 'Sven', photos: 35, likes: 120 },
],
},
highlights: {
topPhoto: {
photoId: 8802,
guest: 'Nico',
likes: 31,
task: 'Dancefloor Close-Up',
createdAt: '2025-05-10T19:12:00Z',
thumbnail: 'https://images.unsplash.com/photo-1502727135889-a63a201a02f9?auto=format&fit=crop&w=600&q=60',
},
trendingEmotion: {
emotionId: 4,
name: 'Party',
count: 58,
},
timeline: [
{ date: '2025-05-08', photos: 120, guests: 25 },
{ date: '2025-05-09', photos: 240, guests: 40 },
{ date: '2025-05-10', photos: 460, guests: 55 },
],
},
feed: [
{
photoId: 8804,
guest: 'Mara',
task: 'Team Selfie',
likes: 42,
createdAt: '2025-05-10T20:05:00Z',
thumbnail: 'https://images.unsplash.com/photo-1519741497674-611481863552?auto=format&fit=crop&w=400&q=60',
},
{
photoId: 8803,
guest: 'Aylin',
task: 'Tischgespräche',
likes: 18,
createdAt: '2025-05-10T19:40:00Z',
thumbnail: 'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=400&q=60',
},
],
},
emotions: [
{ id: 1, slug: 'romance', name: 'Romantik', emoji: '💞', description: 'Samtweiche Szenen & verliebte Blicke' },
{ id: 2, slug: 'party', name: 'Party', emoji: '🎉', description: 'Alles, was knallt und funkelt' },
{ id: 3, slug: 'calm', name: 'Ruhepause', emoji: '🌙', description: 'Leise Momente zum Durchatmen' },
{ id: 4, slug: 'squad', name: 'Squad Goals', emoji: '🤳', description: 'Teams, Crews und wilde Selfies' },
],
notifications: [
{
id: 1,
type: 'broadcast',
title: 'Mission-Alarm',
body: 'Neue Spotlight-Aufgabe verfügbar: „Dancefloor Close-Up“. Schau gleich vorbei!'
+ ' ',
status: 'new',
created_at: now(),
cta: { label: 'Zur Aufgabe', href: '/e/demo/tasks' },
},
{
id: 2,
type: 'broadcast',
title: 'Galerie wächst',
body: '18 neue Uploads in den letzten 30 Minuten helft mit beim Kuratieren!',
status: 'read',
created_at: '2025-05-10T19:50:00Z',
},
],
share: {
slug: 'demo-share',
expires_at: undefined,
photo: {
id: 8801,
title: 'First Look',
likes_count: 24,
emotion: { name: 'Romantik', emoji: '💞' },
image_urls: {
full: 'https://images.unsplash.com/photo-1520854223477-5e2c1a6610f0?auto=format&fit=crop&w=1600&q=80',
thumbnail: 'https://images.unsplash.com/photo-1520854223477-5e2c1a6610f0?auto=format&fit=crop&w=600&q=60',
},
},
event: { id: 999, name: 'Demo Wedding 2025' },
},
};

View File

@@ -1,161 +0,0 @@
/// <reference lib="webworker" />
import { clientsClaim } from 'workbox-core';
import { CacheableResponsePlugin } from 'workbox-cacheable-response';
import { ExpirationPlugin } from 'workbox-expiration';
import { precacheAndRoute, cleanupOutdatedCaches } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { CacheFirst, NetworkFirst, StaleWhileRevalidate } from 'workbox-strategies';
import { shouldCacheResponse } from './lib/cachePolicy';
declare const self: ServiceWorkerGlobalScope & {
__WB_MANIFEST: Array<any>;
};
clientsClaim();
precacheAndRoute(self.__WB_MANIFEST);
cleanupOutdatedCaches();
const isGuestNavigation = (pathname: string) => {
if (pathname === '/event') {
return true;
}
if (pathname.startsWith('/e/')) {
return true;
}
if (pathname.startsWith('/g/')) {
return true;
}
if (pathname.startsWith('/share/')) {
return true;
}
if (pathname.startsWith('/help')) {
return true;
}
if (pathname.startsWith('/legal')) {
return true;
}
if (pathname.startsWith('/settings')) {
return true;
}
return false;
};
registerRoute(
({ request, url }) =>
request.mode === 'navigate' && url.origin === self.location.origin && isGuestNavigation(url.pathname),
new NetworkFirst({
cacheName: 'guest-pages',
networkTimeoutSeconds: 5,
plugins: [
new CacheableResponsePlugin({ statuses: [0, 200] }),
new ExpirationPlugin({ maxEntries: 40, maxAgeSeconds: 60 * 60 * 24 * 7 }),
],
})
);
registerRoute(
({ request, url }) =>
request.method === 'GET' &&
url.origin === self.location.origin &&
url.pathname.startsWith('/api/v1/'),
new NetworkFirst({
cacheName: 'guest-api',
networkTimeoutSeconds: 6,
plugins: [
{
cacheWillUpdate: async ({ response }) => (shouldCacheResponse(response) ? response : null),
},
new CacheableResponsePlugin({ statuses: [0, 200] }),
new ExpirationPlugin({ maxEntries: 80, maxAgeSeconds: 60 * 60 * 24 }),
],
})
);
registerRoute(
({ request, url }) => request.destination === 'image' && url.origin === self.location.origin,
new CacheFirst({
cacheName: 'guest-images',
plugins: [
new CacheableResponsePlugin({ statuses: [0, 200] }),
new ExpirationPlugin({ maxEntries: 200, maxAgeSeconds: 60 * 60 * 24 * 30 }),
],
})
);
registerRoute(
({ request, url }) => request.destination === 'font' && url.origin === self.location.origin,
new StaleWhileRevalidate({
cacheName: 'guest-fonts',
plugins: [
new CacheableResponsePlugin({ statuses: [0, 200] }),
new ExpirationPlugin({ maxEntries: 30, maxAgeSeconds: 60 * 60 * 24 * 365 }),
],
})
);
self.addEventListener('message', (event) => {
if (event.data?.type === 'SKIP_WAITING') {
self.skipWaiting();
}
});
self.addEventListener('sync', (event: any) => {
if (event.tag === 'upload-queue') {
event.waitUntil(
(async () => {
const clients = await self.clients.matchAll({ includeUncontrolled: true, type: 'window' });
clients.forEach((client) => client.postMessage({ type: 'sync-queue' }));
})()
);
}
});
self.addEventListener('push', (event) => {
const payload = event.data?.json?.() ?? {};
event.waitUntil(
(async () => {
const title = payload.title ?? 'Neue Nachricht';
const options = {
body: payload.body ?? '',
icon: '/apple-touch-icon.png',
badge: '/apple-touch-icon.png',
data: payload.data ?? {},
};
await self.registration.showNotification(title, options);
const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
clients.forEach((client) => client.postMessage({ type: 'guest-notification-refresh' }));
})()
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const targetUrl = event.notification.data?.url || '/';
event.waitUntil(
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
for (const client of clientList) {
if ('focus' in client) {
client.navigate(targetUrl);
return client.focus();
}
}
if (self.clients.openWindow) {
return self.clients.openWindow(targetUrl);
}
})
);
});
self.addEventListener('pushsubscriptionchange', (event) => {
event.waitUntil(
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
clientList.forEach((client) => client.postMessage({ type: 'push-subscription-change' }));
})
);
});

View File

@@ -1,37 +0,0 @@
import React from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react';
import { useHapticsPreference } from '../useHapticsPreference';
import { HAPTICS_STORAGE_KEY } from '../../lib/haptics';
function TestHarness() {
const { enabled, setEnabled } = useHapticsPreference();
return (
<button
type="button"
data-testid="toggle"
onClick={() => setEnabled(!enabled)}
>
{enabled ? 'on' : 'off'}
</button>
);
}
describe('useHapticsPreference', () => {
beforeEach(() => {
window.localStorage.removeItem(HAPTICS_STORAGE_KEY);
Object.defineProperty(navigator, 'vibrate', {
configurable: true,
value: vi.fn(),
});
});
it('toggles and persists preference', () => {
render(<TestHarness />);
const button = screen.getByTestId('toggle');
expect(button).toHaveTextContent('on');
fireEvent.click(button);
expect(button).toHaveTextContent('off');
expect(window.localStorage.getItem(HAPTICS_STORAGE_KEY)).toBe('0');
});
});

View File

@@ -1,84 +0,0 @@
import { describe, expect, it } from 'vitest';
import {
buildFramePhotos,
resolveIntervalMs,
resolveItemsPerFrame,
resolvePlaybackQueue,
} from '../useLiveShowPlayback';
import type { LiveShowPhoto, LiveShowSettings } from '../../services/liveShowApi';
const baseSettings: 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 photos: LiveShowPhoto[] = [
{
id: 1,
full_url: '/one.jpg',
thumb_url: '/one-thumb.jpg',
approved_at: '2025-01-01T10:00:00Z',
is_featured: false,
live_priority: 0,
},
{
id: 2,
full_url: '/two.jpg',
thumb_url: '/two-thumb.jpg',
approved_at: '2025-01-01T12:00:00Z',
is_featured: true,
live_priority: 2,
},
{
id: 3,
full_url: '/three.jpg',
thumb_url: '/three-thumb.jpg',
approved_at: '2025-01-01T11:00:00Z',
is_featured: false,
live_priority: 0,
},
];
describe('useLiveShowPlayback helpers', () => {
it('resolves items per frame per layout', () => {
expect(resolveItemsPerFrame('single')).toBe(1);
expect(resolveItemsPerFrame('split')).toBe(2);
expect(resolveItemsPerFrame('grid_burst')).toBe(4);
});
it('builds a curated queue when configured', () => {
const queue = resolvePlaybackQueue(photos, {
...baseSettings,
playback_mode: 'curated',
});
expect(queue[0].id).toBe(2);
expect(queue.every((photo) => photo.id === 2 || photo.live_priority > 0 || photo.is_featured)).toBe(true);
});
it('builds frame photos without duplicates when list is smaller', () => {
const frame = buildFramePhotos([photos[0]], 0, 4);
expect(frame).toHaveLength(1);
expect(frame[0].id).toBe(1);
});
it('uses fixed interval when configured', () => {
const interval = resolveIntervalMs(
{
...baseSettings,
pace_mode: 'fixed',
fixed_interval_seconds: 12,
},
photos.length
);
expect(interval).toBe(12_000);
});
});

View File

@@ -1,170 +0,0 @@
import { useCallback, useState } from 'react';
import { compressPhoto, formatBytes } from '../lib/image';
import { uploadPhoto, type UploadError } from '../services/photosApi';
import { useGuestIdentity } from '../context/GuestIdentityContext';
import { useGuestTaskProgress } from '../hooks/useGuestTaskProgress';
import { resolveUploadErrorDialog, type UploadErrorDialog } from '../lib/uploadErrorDialog';
import { notify } from '../queue/notify';
import { useTranslation } from '../i18n/useTranslation';
import { isGuestDemoModeEnabled } from '../demo/demoMode';
import { useEventData } from './useEventData';
import { triggerHaptic } from '../lib/haptics';
type DirectUploadResult = {
success: boolean;
photoId?: number;
warning?: string | null;
error?: string | null;
dialog?: UploadErrorDialog | null;
};
type UseDirectUploadOptions = {
eventToken: string;
taskId?: number | null;
emotionSlug?: string;
onCompleted?: (photoId: number) => void;
};
export function useDirectUpload({ eventToken, taskId, emotionSlug, onCompleted }: UseDirectUploadOptions) {
const { name } = useGuestIdentity();
const { markCompleted } = useGuestTaskProgress(eventToken);
const { event } = useEventData();
const { t } = useTranslation();
const [uploading, setUploading] = useState(false);
const [progress, setProgress] = useState(0);
const [warning, setWarning] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [errorDialog, setErrorDialog] = useState<UploadErrorDialog | null>(null);
const [canUpload, setCanUpload] = useState(true);
const reset = useCallback(() => {
setProgress(0);
setWarning(null);
setError(null);
setErrorDialog(null);
}, []);
const preparePhoto = useCallback(async (file: File) => {
reset();
let prepared = file;
try {
prepared = await compressPhoto(file, {
maxEdge: 2400,
targetBytes: 4_000_000,
qualityStart: 0.82,
});
if (prepared.size < file.size - 50_000) {
const saved = formatBytes(file.size - prepared.size);
setWarning(`Wir haben dein Foto verkleinert, damit der Upload schneller klappt. Eingespart: ${saved}`);
}
} catch (err) {
console.warn('Direct upload: optimization failed, using original', err);
setWarning('Optimierung nicht möglich wir laden das Original hoch.');
}
if (prepared.size > 12_000_000) {
setError('Das Foto war zu groß. Bitte erneut versuchen wir verkleinern es automatisch.');
return { ok: false as const };
}
return { ok: true as const, prepared };
}, [reset]);
const upload = useCallback(
async (file: File): Promise<DirectUploadResult> => {
if (!canUpload || uploading) return { success: false, warning, error };
if (isGuestDemoModeEnabled() || event?.demo_read_only) {
const demoMessage = t('upload.demoReadOnly', 'Uploads sind in der Demo deaktiviert.');
setError(demoMessage);
setWarning(null);
notify(demoMessage, 'error');
return { success: false, warning, error: demoMessage };
}
const preparedResult = await preparePhoto(file);
if (!preparedResult.ok) {
return { success: false, warning, error };
}
const prepared = preparedResult.prepared;
setUploading(true);
setProgress(2);
setError(null);
setErrorDialog(null);
try {
const photoId = await uploadPhoto(eventToken, prepared, taskId ?? undefined, emotionSlug || undefined, {
maxRetries: 2,
guestName: name || undefined,
onProgress: (percent) => {
setProgress(Math.max(10, Math.min(98, percent)));
},
onRetry: (attempt) => {
setWarning(`Verbindung holperig neuer Versuch (${attempt}).`);
},
});
setProgress(100);
if (taskId) {
markCompleted(taskId);
}
triggerHaptic('success');
try {
const raw = localStorage.getItem('my-photo-ids');
const arr: number[] = raw ? JSON.parse(raw) : [];
if (photoId && !arr.includes(photoId)) {
localStorage.setItem('my-photo-ids', JSON.stringify([photoId, ...arr]));
}
} catch (persistErr) {
console.warn('Direct upload: persist my-photo-ids failed', persistErr);
}
onCompleted?.(photoId);
return { success: true, photoId, warning };
} catch (err) {
console.error('Direct upload failed', err);
triggerHaptic('error');
const uploadErr = err as UploadError;
const meta = uploadErr.meta as Record<string, unknown> | undefined;
const dialog = resolveUploadErrorDialog(uploadErr.code, meta, (v: string) => v);
setErrorDialog(dialog);
setError(dialog?.description ?? uploadErr.message ?? 'Upload fehlgeschlagen.');
setWarning(null);
if (uploadErr.code === 'demo_read_only') {
notify(t('upload.demoReadOnly', 'Uploads sind in der Demo deaktiviert.'), 'error');
}
if (
uploadErr.code === 'photo_limit_exceeded'
|| uploadErr.code === 'upload_device_limit'
|| uploadErr.code === 'event_package_missing'
|| uploadErr.code === 'event_not_found'
|| uploadErr.code === 'gallery_expired'
) {
setCanUpload(false);
}
if (uploadErr.status === 422 || uploadErr.code === 'validation_error') {
setWarning('Das Foto war zu groß. Bitte erneut versuchen wir verkleinern es automatisch.');
}
return { success: false, warning, error: dialog?.description ?? uploadErr.message, dialog };
} finally {
setUploading(false);
setProgress((p) => (p === 100 ? p : 0));
}
},
[canUpload, emotionSlug, eventToken, markCompleted, name, preparePhoto, taskId, uploading, warning, onCompleted]
);
return {
upload,
uploading,
progress,
warning,
error,
errorDialog,
reset,
};
}

View File

@@ -1,101 +0,0 @@
import { useState, useEffect } from 'react';
import { useParams } from 'react-router-dom';
import {
fetchEvent,
EventData,
FetchEventError,
FetchEventErrorCode,
} from '../services/eventApi';
type EventDataStatus = 'loading' | 'ready' | 'error';
interface UseEventDataResult {
event: EventData | null;
status: EventDataStatus;
loading: boolean;
error: string | null;
errorCode: FetchEventErrorCode | null;
token: string | null;
}
const NO_TOKEN_ERROR_MESSAGE = 'Es wurde kein Einladungscode übergeben.';
const eventCache = new Map<string, EventData>();
export function useEventData(): UseEventDataResult {
const { token } = useParams<{ token: string }>();
const cachedEvent = token ? eventCache.get(token) ?? null : null;
const [event, setEvent] = useState<EventData | null>(cachedEvent);
const [status, setStatus] = useState<EventDataStatus>(token ? (cachedEvent ? 'ready' : 'loading') : 'error');
const [errorMessage, setErrorMessage] = useState<string | null>(token ? null : NO_TOKEN_ERROR_MESSAGE);
const [errorCode, setErrorCode] = useState<FetchEventErrorCode | null>(token ? null : 'invalid_token');
useEffect(() => {
if (!token) {
setEvent(null);
setStatus('error');
setErrorCode('invalid_token');
setErrorMessage(NO_TOKEN_ERROR_MESSAGE);
return;
}
let cancelled = false;
const loadEvent = async () => {
const cached = eventCache.get(token) ?? null;
if (!cached) {
setStatus('loading');
}
setErrorCode(null);
setErrorMessage(null);
try {
const eventData = await fetchEvent(token);
if (cancelled) {
return;
}
eventCache.set(token, eventData);
setEvent(eventData);
setStatus('ready');
} catch (err) {
if (cancelled) {
return;
}
if (cached) {
setEvent(cached);
setStatus('ready');
return;
}
setEvent(null);
setStatus('error');
if (err instanceof FetchEventError) {
setErrorCode(err.code);
setErrorMessage(err.message);
} else if (err instanceof Error) {
setErrorCode('unknown');
setErrorMessage(err.message || 'Event konnte nicht geladen werden.');
} else {
setErrorCode('unknown');
setErrorMessage('Event konnte nicht geladen werden.');
}
}
};
loadEvent();
return () => {
cancelled = true;
};
}, [token]);
return {
event,
status,
loading: status === 'loading',
error: errorMessage,
errorCode,
token: token ?? null,
};
}

View File

@@ -1,93 +0,0 @@
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

@@ -1,19 +0,0 @@
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

@@ -1,229 +0,0 @@
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

@@ -1,317 +0,0 @@
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

@@ -1,168 +0,0 @@
import React from 'react';
import { getPushConfig } from '../lib/runtime-config';
import { registerPushSubscription, unregisterPushSubscription } from '../services/pushApi';
type PushSubscriptionState = {
supported: boolean;
permission: NotificationPermission;
subscribed: boolean;
loading: boolean;
error: string | null;
enable: () => Promise<void>;
disable: () => Promise<void>;
refresh: () => Promise<void>;
};
export function usePushSubscription(eventToken?: string): PushSubscriptionState {
const pushConfig = React.useMemo(() => getPushConfig(), []);
const supported = React.useMemo(() => {
return typeof window !== 'undefined'
&& typeof navigator !== 'undefined'
&& typeof Notification !== 'undefined'
&& 'serviceWorker' in navigator
&& 'PushManager' in window
&& pushConfig.enabled;
}, [pushConfig.enabled]);
const [permission, setPermission] = React.useState<NotificationPermission>(() => {
if (typeof Notification === 'undefined') {
return 'default';
}
return Notification.permission;
});
const [subscription, setSubscription] = React.useState<PushSubscription | null>(null);
const [loading, setLoading] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const refresh = React.useCallback(async () => {
if (!supported || !eventToken) {
return;
}
try {
const registration = await navigator.serviceWorker.ready;
const current = await registration.pushManager.getSubscription();
setSubscription(current);
} catch (err) {
console.warn('Unable to refresh push subscription', err);
setSubscription(null);
}
}, [eventToken, supported]);
React.useEffect(() => {
if (!supported) {
return;
}
void refresh();
const handleMessage = (event: MessageEvent) => {
if (event.data?.type === 'push-subscription-change') {
void refresh();
}
};
navigator.serviceWorker?.addEventListener('message', handleMessage);
return () => {
navigator.serviceWorker?.removeEventListener('message', handleMessage);
};
}, [refresh, supported]);
const enable = React.useCallback(async () => {
if (!supported || !eventToken) {
setError('Push-Benachrichtigungen werden auf diesem Gerät nicht unterstützt.');
return;
}
setLoading(true);
setError(null);
try {
const permissionResult = await Notification.requestPermission();
setPermission(permissionResult);
if (permissionResult !== 'granted') {
throw new Error('Bitte erlaube Benachrichtigungen, um Push zu aktivieren.');
}
const registration = await navigator.serviceWorker.ready;
const existing = await registration.pushManager.getSubscription();
if (existing) {
await registerPushSubscription(eventToken, existing);
setSubscription(existing);
return;
}
if (!pushConfig.vapidPublicKey) {
throw new Error('Push-Konfiguration ist nicht vollständig.');
}
const newSubscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(pushConfig.vapidPublicKey).buffer as ArrayBuffer,
});
await registerPushSubscription(eventToken, newSubscription);
setSubscription(newSubscription);
} catch (err) {
const message = err instanceof Error ? err.message : 'Push konnte nicht aktiviert werden.';
setError(message);
console.error(err);
await refresh();
} finally {
setLoading(false);
}
}, [eventToken, pushConfig.vapidPublicKey, refresh, supported]);
const disable = React.useCallback(async () => {
if (!supported || !eventToken || !subscription) {
return;
}
setLoading(true);
setError(null);
try {
await unregisterPushSubscription(eventToken, subscription.endpoint);
await subscription.unsubscribe();
setSubscription(null);
} catch (err) {
const message = err instanceof Error ? err.message : 'Push konnte nicht deaktiviert werden.';
setError(message);
console.error(err);
} finally {
setLoading(false);
}
}, [eventToken, subscription, supported]);
return {
supported,
permission,
subscribed: Boolean(subscription),
loading,
error,
enable,
disable,
refresh,
};
}
function urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = typeof window !== 'undefined'
? window.atob(base64)
: Buffer.from(base64, 'base64').toString('binary');
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; i += 1) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}

View File

@@ -1,124 +0,0 @@
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

@@ -1,49 +0,0 @@
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

@@ -1,44 +0,0 @@
import { describe, expect, it } from 'vitest';
import { isUploadPath, shouldShowAnalyticsNudge } from '../analyticsConsent';
describe('isUploadPath', () => {
it('detects upload routes', () => {
expect(isUploadPath('/e/abc/upload')).toBe(true);
expect(isUploadPath('/e/abc/upload/queue')).toBe(true);
});
it('ignores non-upload routes', () => {
expect(isUploadPath('/e/abc/gallery')).toBe(false);
expect(isUploadPath('/settings')).toBe(false);
});
});
describe('shouldShowAnalyticsNudge', () => {
const baseState = {
decisionMade: false,
analyticsConsent: false,
snoozedUntil: null,
now: 1000,
activeSeconds: 60,
routeCount: 2,
thresholdSeconds: 60,
thresholdRoutes: 2,
isUpload: false,
};
it('returns true when thresholds are met', () => {
expect(shouldShowAnalyticsNudge(baseState)).toBe(true);
});
it('returns false when consent decision is made', () => {
expect(shouldShowAnalyticsNudge({ ...baseState, decisionMade: true })).toBe(false);
});
it('returns false when snoozed', () => {
expect(shouldShowAnalyticsNudge({ ...baseState, snoozedUntil: 2000 })).toBe(false);
});
it('returns false on upload routes', () => {
expect(shouldShowAnalyticsNudge({ ...baseState, isUpload: true })).toBe(false);
});
});

View File

@@ -1,52 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { supportsBadging, updateAppBadge } from '../badges';
const originalSet = (navigator as any).setAppBadge;
const originalClear = (navigator as any).clearAppBadge;
const hadSet = 'setAppBadge' in navigator;
const hadClear = 'clearAppBadge' in navigator;
function restoreNavigator() {
if (hadSet) {
Object.defineProperty(navigator, 'setAppBadge', { configurable: true, value: originalSet });
} else {
delete (navigator as any).setAppBadge;
}
if (hadClear) {
Object.defineProperty(navigator, 'clearAppBadge', { configurable: true, value: originalClear });
} else {
delete (navigator as any).clearAppBadge;
}
}
describe('badges', () => {
afterEach(() => {
restoreNavigator();
});
it('sets the badge count when supported', async () => {
const setAppBadge = vi.fn();
Object.defineProperty(navigator, 'setAppBadge', { configurable: true, value: setAppBadge });
Object.defineProperty(navigator, 'clearAppBadge', { configurable: true, value: vi.fn() });
expect(supportsBadging()).toBe(true);
await updateAppBadge(4);
expect(setAppBadge).toHaveBeenCalledWith(4);
});
it('clears the badge when count is zero', async () => {
const clearAppBadge = vi.fn();
Object.defineProperty(navigator, 'setAppBadge', { configurable: true, value: vi.fn() });
Object.defineProperty(navigator, 'clearAppBadge', { configurable: true, value: clearAppBadge });
await updateAppBadge(0);
expect(clearAppBadge).toHaveBeenCalled();
});
it('no-ops when unsupported', async () => {
delete (navigator as any).setAppBadge;
delete (navigator as any).clearAppBadge;
expect(supportsBadging()).toBe(false);
await updateAppBadge(3);
});
});

View File

@@ -1,24 +0,0 @@
import { describe, expect, it } from 'vitest';
import { shouldCacheResponse } from '../cachePolicy';
describe('shouldCacheResponse', () => {
it('returns false when Cache-Control is no-store', () => {
const response = new Response('ok', { headers: { 'Cache-Control': 'no-store' } });
expect(shouldCacheResponse(response)).toBe(false);
});
it('returns false when Cache-Control is private', () => {
const response = new Response('ok', { headers: { 'Cache-Control': 'private, max-age=0' } });
expect(shouldCacheResponse(response)).toBe(false);
});
it('returns false when Pragma is no-cache', () => {
const response = new Response('ok', { headers: { Pragma: 'no-cache' } });
expect(shouldCacheResponse(response)).toBe(false);
});
it('returns true for cacheable responses', () => {
const response = new Response('ok', { headers: { 'Cache-Control': 'public, max-age=60' } });
expect(shouldCacheResponse(response)).toBe(true);
});
});

View File

@@ -1,36 +0,0 @@
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
import { buildCsrfHeaders } from '../csrf';
describe('buildCsrfHeaders', () => {
beforeEach(() => {
localStorage.setItem('device-id', 'device-123');
});
afterEach(() => {
localStorage.clear();
document.head.querySelectorAll('meta[name="csrf-token"]').forEach((node) => node.remove());
document.cookie = 'XSRF-TOKEN=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/';
});
it('reads token from meta tag', () => {
const meta = document.createElement('meta');
meta.setAttribute('name', 'csrf-token');
meta.setAttribute('content', 'meta-token');
document.head.appendChild(meta);
const headers = buildCsrfHeaders('device-xyz');
expect(headers['X-CSRF-TOKEN']).toBe('meta-token');
expect(headers['X-XSRF-TOKEN']).toBe('meta-token');
expect(headers['X-Device-Id']).toBe('device-xyz');
});
it('falls back to cookie token', () => {
const raw = btoa('cookie-token');
document.cookie = `XSRF-TOKEN=${raw}; path=/`;
const headers = buildCsrfHeaders();
expect(headers['X-CSRF-TOKEN']).toBe('cookie-token');
expect(headers['X-XSRF-TOKEN']).toBe('cookie-token');
expect(headers['X-Device-Id']).toBe('device-123');
});
});

View File

@@ -1,13 +0,0 @@
import { shouldShowPhotoboothFilter } from '../galleryFilters';
describe('shouldShowPhotoboothFilter', () => {
it('returns true when photobooth is enabled', () => {
expect(shouldShowPhotoboothFilter({ photobooth_enabled: true } as any)).toBe(true);
});
it('returns false when photobooth is disabled or missing', () => {
expect(shouldShowPhotoboothFilter({ photobooth_enabled: false } as any)).toBe(false);
expect(shouldShowPhotoboothFilter(null)).toBe(false);
expect(shouldShowPhotoboothFilter(undefined)).toBe(false);
});
});

View File

@@ -1,35 +0,0 @@
import { describe, expect, it, afterEach } from 'vitest';
import { applyGuestTheme } from '../guestTheme';
const baseTheme = {
primary: '#ff3366',
secondary: '#ff99aa',
background: '#111111',
surface: '#222222',
mode: 'dark' as const,
};
describe('applyGuestTheme', () => {
afterEach(() => {
const root = document.documentElement;
root.classList.remove('guest-theme', 'dark');
root.style.removeProperty('color-scheme');
root.style.removeProperty('--guest-primary');
root.style.removeProperty('--guest-secondary');
root.style.removeProperty('--guest-background');
root.style.removeProperty('--guest-surface');
});
it('applies and restores guest theme settings', () => {
const cleanup = applyGuestTheme(baseTheme);
expect(document.documentElement.classList.contains('guest-theme')).toBe(true);
expect(document.documentElement.classList.contains('dark')).toBe(true);
expect(document.documentElement.style.colorScheme).toBe('dark');
expect(document.documentElement.style.getPropertyValue('--guest-background')).toBe('#111111');
cleanup();
expect(document.documentElement.classList.contains('guest-theme')).toBe(false);
});
});

View File

@@ -1,63 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { HAPTICS_STORAGE_KEY, getHapticsPreference, isHapticsEnabled, setHapticsPreference, supportsHaptics, triggerHaptic } from '../haptics';
describe('haptics', () => {
afterEach(() => {
window.localStorage.removeItem(HAPTICS_STORAGE_KEY);
});
it('returns false when vibrate is unavailable', () => {
const original = navigator.vibrate;
Object.defineProperty(navigator, 'vibrate', { configurable: true, value: undefined });
expect(supportsHaptics()).toBe(false);
Object.defineProperty(navigator, 'vibrate', { configurable: true, value: original });
});
it('returns stored preference when set', () => {
window.localStorage.removeItem(HAPTICS_STORAGE_KEY);
expect(getHapticsPreference()).toBe(true);
setHapticsPreference(false);
expect(getHapticsPreference()).toBe(false);
});
it('reports disabled when reduced motion is enabled', () => {
const originalMatchMedia = window.matchMedia;
const vibrate = vi.fn();
Object.defineProperty(window, 'matchMedia', {
configurable: true,
value: vi.fn().mockReturnValue({ matches: true }),
});
Object.defineProperty(navigator, 'vibrate', { configurable: true, value: vibrate });
setHapticsPreference(true);
expect(isHapticsEnabled()).toBe(false);
Object.defineProperty(window, 'matchMedia', {
configurable: true,
value: originalMatchMedia,
});
});
it('triggers vibration only when enabled', () => {
const originalMatchMedia = window.matchMedia;
const vibrate = vi.fn();
Object.defineProperty(window, 'matchMedia', {
configurable: true,
value: vi.fn().mockReturnValue({ matches: false }),
});
Object.defineProperty(navigator, 'vibrate', { configurable: true, value: vibrate });
triggerHaptic('selection');
expect(vibrate).toHaveBeenCalled();
setHapticsPreference(false);
triggerHaptic('selection');
expect(vibrate).toHaveBeenCalledTimes(1);
Object.defineProperty(window, 'matchMedia', {
configurable: true,
value: originalMatchMedia,
});
});
});

View File

@@ -1,32 +0,0 @@
import { describe, expect, it } from 'vitest';
import { getHelpSlugForPathname } from '../helpRouting';
describe('getHelpSlugForPathname', () => {
it('returns a getting-started slug for home paths', () => {
expect(getHelpSlugForPathname('/')).toBe('getting-started');
expect(getHelpSlugForPathname('/e/demo')).toBe('getting-started');
});
it('returns null for help pages', () => {
expect(getHelpSlugForPathname('/help')).toBeNull();
expect(getHelpSlugForPathname('/help/gallery-and-sharing')).toBeNull();
expect(getHelpSlugForPathname('/e/demo/help/gallery-and-sharing')).toBeNull();
});
it('maps gallery related pages', () => {
expect(getHelpSlugForPathname('/e/demo/gallery')).toBe('gallery-and-sharing');
expect(getHelpSlugForPathname('/e/demo/photo/123')).toBe('gallery-and-sharing');
expect(getHelpSlugForPathname('/e/demo/slideshow')).toBe('gallery-and-sharing');
});
it('maps upload related pages', () => {
expect(getHelpSlugForPathname('/e/demo/upload')).toBe('uploading-photos');
expect(getHelpSlugForPathname('/e/demo/queue')).toBe('upload-troubleshooting');
});
it('maps tasks and achievements', () => {
expect(getHelpSlugForPathname('/e/demo/tasks')).toBe('tasks-and-missions');
expect(getHelpSlugForPathname('/e/demo/tasks/12')).toBe('tasks-and-missions');
expect(getHelpSlugForPathname('/e/demo/achievements')).toBe('achievements-and-badges');
});
});

View File

@@ -1,86 +0,0 @@
import { describe, expect, it } from 'vitest';
import type { EventPackageLimits } from '../../services/eventApi';
import { buildLimitSummaries } from '../limitSummaries';
const translations = new Map<string, string>([
['upload.limitSummary.cards.photos.title', 'Fotos'],
['upload.limitSummary.cards.photos.remaining', 'Noch {remaining} von {limit}'],
['upload.limitSummary.cards.photos.unlimited', 'Unbegrenzte Uploads'],
['upload.limitSummary.cards.guests.title', 'Gäste'],
['upload.limitSummary.cards.guests.remaining', '{remaining} Gäste frei (max. {limit})'],
['upload.limitSummary.cards.guests.unlimited', 'Unbegrenzte Gäste'],
['upload.limitSummary.badges.ok', 'OK'],
['upload.limitSummary.badges.warning', 'Warnung'],
['upload.limitSummary.badges.limit_reached', 'Limit erreicht'],
['upload.limitSummary.badges.unlimited', 'Unbegrenzt'],
]);
const t = (key: string) => translations.get(key) ?? key;
describe('buildLimitSummaries', () => {
it('builds photo summary with progress and warning tone', () => {
const limits: EventPackageLimits = {
photos: {
limit: 100,
used: 80,
remaining: 20,
percentage: 80,
state: 'warning',
threshold_reached: 80,
next_threshold: 95,
thresholds: [80, 95],
},
guests: null,
gallery: null,
can_upload_photos: true,
can_add_guests: true,
};
const cards = buildLimitSummaries(limits, t);
expect(cards).toHaveLength(1);
const card = cards[0];
expect(card.id).toBe('photos');
expect(card.tone).toBe('warning');
expect(card.progress).toBe(80);
expect(card.valueLabel).toBe('80 / 100');
expect(card.description).toBe('Noch 20 von 100');
expect(card.badgeLabel).toBe('Warnung');
});
it('builds unlimited guest summary without progress', () => {
const limits: EventPackageLimits = {
photos: null,
guests: {
limit: null,
used: 5,
remaining: null,
percentage: null,
state: 'unlimited',
threshold_reached: null,
next_threshold: null,
thresholds: [],
},
gallery: null,
can_upload_photos: true,
can_add_guests: true,
};
const cards = buildLimitSummaries(limits, t);
expect(cards).toHaveLength(1);
const card = cards[0];
expect(card.id).toBe('guests');
expect(card.progress).toBeNull();
expect(card.tone).toBe('neutral');
expect(card.valueLabel).toBe('Unbegrenzt');
expect(card.description).toBe('Unbegrenzte Gäste');
expect(card.badgeLabel).toBe('Unbegrenzt');
});
it('returns empty list when no limits are provided', () => {
expect(buildLimitSummaries(null, t)).toEqual([]);
expect(buildLimitSummaries(undefined, t)).toEqual([]);
});
});

View File

@@ -1,22 +0,0 @@
import { describe, expect, it } from 'vitest';
import { resolveLiveShowEffect } from '../liveShowEffects';
describe('resolveLiveShowEffect', () => {
it('adds flash overlay for shutter flash preset', () => {
const effect = resolveLiveShowEffect('shutter_flash', 80, false);
expect(effect.flash).toBeDefined();
expect(effect.frame.initial).toBeDefined();
expect(effect.frame.animate).toBeDefined();
});
it('keeps light effects simple without flash', () => {
const effect = resolveLiveShowEffect('light_effects', 80, false);
expect(effect.flash).toBeUndefined();
});
it('honors reduced motion with basic fade', () => {
const effect = resolveLiveShowEffect('film_cut', 80, true);
expect(effect.flash).toBeUndefined();
expect(effect.frame.initial).toEqual({ opacity: 0 });
});
});

View File

@@ -1,47 +0,0 @@
import { getMotionContainerPropsForNavigation, getMotionItemPropsForNavigation, STAGGER_FAST, FADE_UP } from '../motion';
describe('getMotionContainerPropsForNavigation', () => {
it('returns initial hidden for POP navigation', () => {
expect(getMotionContainerPropsForNavigation(true, STAGGER_FAST, 'POP')).toEqual({
variants: STAGGER_FAST,
initial: 'hidden',
animate: 'show',
});
});
it('skips initial animation for PUSH navigation', () => {
expect(getMotionContainerPropsForNavigation(true, STAGGER_FAST, 'PUSH')).toEqual({
variants: STAGGER_FAST,
initial: false,
animate: 'show',
});
});
it('disables motion when not enabled', () => {
expect(getMotionContainerPropsForNavigation(false, STAGGER_FAST, 'POP')).toEqual({
initial: false,
});
});
});
describe('getMotionItemPropsForNavigation', () => {
it('returns animate props for POP navigation', () => {
expect(getMotionItemPropsForNavigation(true, FADE_UP, 'POP')).toEqual({
variants: FADE_UP,
initial: 'hidden',
animate: 'show',
});
});
it('skips initial animation for PUSH navigation', () => {
expect(getMotionItemPropsForNavigation(true, FADE_UP, 'PUSH')).toEqual({
variants: FADE_UP,
initial: false,
animate: 'show',
});
});
it('returns empty props when motion disabled', () => {
expect(getMotionItemPropsForNavigation(false, FADE_UP, 'POP')).toEqual({});
});
});

View File

@@ -1,22 +0,0 @@
import { dedupeTasksById } from '../taskUtils';
describe('dedupeTasksById', () => {
it('returns empty array for empty input', () => {
expect(dedupeTasksById([])).toEqual([]);
});
it('keeps the first occurrence and preserves order', () => {
const tasks = [
{ id: 1, title: 'A' },
{ id: 2, title: 'B' },
{ id: 1, title: 'A-dup' },
{ id: 3, title: 'C' },
];
expect(dedupeTasksById(tasks)).toEqual([
{ id: 1, title: 'A' },
{ id: 2, title: 'B' },
{ id: 3, title: 'C' },
]);
});
});

View File

@@ -1,49 +0,0 @@
import { describe, expect, it } from 'vitest';
import { resolveUploadErrorDialog } from '../uploadErrorDialog';
const translations = new Map<string, string>([
['upload.dialogs.photoLimit.title', 'Upload-Limit erreicht'],
['upload.dialogs.photoLimit.description', 'Es wurden {used} von {limit} Fotos hochgeladen. Es bleiben {remaining}.'],
['upload.dialogs.photoLimit.hint', 'Wende dich an das Team.'],
['upload.dialogs.deviceLimit.title', 'Dieses Gerät ist voll'],
['upload.dialogs.deviceLimit.description', 'Du hast das Geräte-Limit erreicht.'],
['upload.dialogs.deviceLimit.hint', 'Nutze ein anderes Gerät oder kontaktiere das Team.'],
['upload.dialogs.packageMissing.title', 'Event nicht bereit'],
['upload.dialogs.packageMissing.description', 'Das Event akzeptiert aktuell keine Uploads.'],
['upload.dialogs.packageMissing.hint', 'Frag die Veranstalter:innen nach dem Status.'],
['upload.dialogs.galleryExpired.title', 'Galerie abgelaufen'],
['upload.dialogs.galleryExpired.description', 'Uploads sind nicht mehr möglich.'],
['upload.dialogs.galleryExpired.hint', 'Bitte wende dich an die Veranstalter:innen.'],
['upload.dialogs.csrf.title', 'Sicherheitsabgleich erforderlich'],
['upload.dialogs.csrf.description', 'Bitte lade die Seite neu und versuche es erneut.'],
['upload.dialogs.csrf.hint', 'Aktualisiere die Seite.'],
['upload.dialogs.generic.title', 'Upload fehlgeschlagen'],
['upload.dialogs.generic.description', 'Der Upload konnte nicht abgeschlossen werden.'],
['upload.dialogs.generic.hint', 'Versuche es später erneut.'],
]);
const t = (key: string) => translations.get(key) ?? key;
describe('resolveUploadErrorDialog', () => {
it('renders photo limit dialog with placeholders', () => {
const dialog = resolveUploadErrorDialog(
'photo_limit_exceeded',
{ used: 120, limit: 120, remaining: 0 },
t
);
expect(dialog.title).toBe('Upload-Limit erreicht');
expect(dialog.description).toBe('Es wurden 120 von 120 Fotos hochgeladen. Es bleiben 0.');
expect(dialog.hint).toBe('Wende dich an das Team.');
expect(dialog.tone).toBe('danger');
});
it('falls back to generic dialog when code is unknown', () => {
const dialog = resolveUploadErrorDialog('something_else', undefined, t);
expect(dialog.tone).toBe('info');
expect(dialog.title).toBe('Upload fehlgeschlagen');
expect(dialog.description).toBe('Der Upload konnte nicht abgeschlossen werden.');
});
});

View File

@@ -1,34 +0,0 @@
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

@@ -1,46 +0,0 @@
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

@@ -1,20 +0,0 @@
export function shouldCacheResponse(response: Response | null): boolean {
if (!response) {
return false;
}
const cacheControl = response.headers.get('Cache-Control') ?? '';
const pragma = response.headers.get('Pragma') ?? '';
const normalizedCacheControl = cacheControl.toLowerCase();
const normalizedPragma = pragma.toLowerCase();
if (normalizedCacheControl.includes('no-store') || normalizedCacheControl.includes('private')) {
return false;
}
if (normalizedPragma.includes('no-cache')) {
return false;
}
return true;
}

View File

@@ -1,50 +0,0 @@
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

@@ -1,49 +0,0 @@
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

@@ -1,19 +0,0 @@
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

@@ -1,128 +0,0 @@
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

@@ -1,8 +0,0 @@
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

@@ -1,5 +0,0 @@
import type { EventData } from '../services/eventApi';
export function shouldShowPhotoboothFilter(event?: EventData | null): boolean {
return Boolean(event?.photobooth_enabled);
}

View File

@@ -1,103 +0,0 @@
export type GuestThemePayload = {
primary: string;
secondary: string;
background: string;
surface: string;
mode?: 'light' | 'dark' | 'auto';
};
type GuestThemeCleanup = () => void;
const prefersDarkScheme = (): boolean => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
return false;
}
return window.matchMedia('(prefers-color-scheme: dark)').matches;
};
const applyColorScheme = (root: HTMLElement, theme: 'light' | 'dark') => {
if (theme === 'dark') {
root.classList.add('dark');
root.style.colorScheme = 'dark';
} else {
root.classList.remove('dark');
root.style.colorScheme = 'light';
}
};
export function applyGuestTheme(payload: GuestThemePayload): GuestThemeCleanup {
if (typeof document === 'undefined') {
return () => {};
}
const root = document.documentElement;
const hadGuestTheme = root.classList.contains('guest-theme');
const wasDark = root.classList.contains('dark');
const previousColorScheme = root.style.colorScheme;
const previousVars = {
primary: root.style.getPropertyValue('--guest-primary'),
secondary: root.style.getPropertyValue('--guest-secondary'),
background: root.style.getPropertyValue('--guest-background'),
surface: root.style.getPropertyValue('--guest-surface'),
};
root.classList.add('guest-theme');
root.style.setProperty('--guest-primary', payload.primary);
root.style.setProperty('--guest-secondary', payload.secondary);
root.style.setProperty('--guest-background', payload.background);
root.style.setProperty('--guest-surface', payload.surface);
const mode = payload.mode ?? 'auto';
if (mode === 'dark') {
applyColorScheme(root, 'dark');
} else if (mode === 'light') {
applyColorScheme(root, 'light');
} else {
applyColorScheme(root, prefersDarkScheme() ? 'dark' : 'light');
}
return () => {
if (hadGuestTheme) {
root.classList.add('guest-theme');
} else {
root.classList.remove('guest-theme');
}
if (wasDark) {
root.classList.add('dark');
} else {
root.classList.remove('dark');
}
if (previousColorScheme) {
root.style.colorScheme = previousColorScheme;
} else {
root.style.removeProperty('color-scheme');
}
if (previousVars.primary) {
root.style.setProperty('--guest-primary', previousVars.primary);
} else {
root.style.removeProperty('--guest-primary');
}
if (previousVars.secondary) {
root.style.setProperty('--guest-secondary', previousVars.secondary);
} else {
root.style.removeProperty('--guest-secondary');
}
if (previousVars.background) {
root.style.setProperty('--guest-background', previousVars.background);
} else {
root.style.removeProperty('--guest-background');
}
if (previousVars.surface) {
root.style.setProperty('--guest-surface', previousVars.surface);
} else {
root.style.removeProperty('--guest-surface');
}
};
}

View File

@@ -1,62 +0,0 @@
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

@@ -1,44 +0,0 @@
export function getHelpSlugForPathname(pathname: string): string | null {
if (!pathname) {
return null;
}
const normalized = pathname
.replace(/^\/e\/[^/]+/, '')
.replace(/\/+$/g, '')
.toLowerCase();
if (!normalized || normalized === '/') {
return 'getting-started';
}
if (normalized.startsWith('/help')) {
return null;
}
if (normalized.startsWith('/gallery') || normalized.startsWith('/photo') || normalized.startsWith('/slideshow')) {
return 'gallery-and-sharing';
}
if (normalized.startsWith('/upload')) {
return 'uploading-photos';
}
if (normalized.startsWith('/queue')) {
return 'upload-troubleshooting';
}
if (normalized.startsWith('/tasks')) {
return 'tasks-and-missions';
}
if (normalized.startsWith('/achievements')) {
return 'achievements-and-badges';
}
if (normalized.startsWith('/settings')) {
return 'settings-and-cache';
}
return 'how-fotospiel-works';
}

View File

@@ -1,97 +0,0 @@
// @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

@@ -1,107 +0,0 @@
import type { EventPackageLimits, LimitUsageSummary } from '../services/eventApi';
export type LimitTone = 'neutral' | 'warning' | 'danger';
export type LimitSummaryCard = {
id: 'photos' | 'guests';
label: string;
state: LimitUsageSummary['state'];
tone: LimitTone;
used: number;
limit: number | null;
remaining: number | null;
progress: number | null;
valueLabel: string;
description: string;
badgeLabel: string;
};
type TranslateFn = (key: string, fallback?: string) => string;
function resolveTone(state: LimitUsageSummary['state']): LimitTone {
if (state === 'limit_reached') {
return 'danger';
}
if (state === 'warning') {
return 'warning';
}
return 'neutral';
}
function buildCard(
id: 'photos' | 'guests',
summary: LimitUsageSummary,
t: TranslateFn
): LimitSummaryCard {
const labelKey = id === 'photos' ? 'upload.limitSummary.cards.photos.title' : 'upload.limitSummary.cards.guests.title';
const remainingKey = id === 'photos'
? 'upload.limitSummary.cards.photos.remaining'
: 'upload.limitSummary.cards.guests.remaining';
const unlimitedKey = id === 'photos'
? 'upload.limitSummary.cards.photos.unlimited'
: 'upload.limitSummary.cards.guests.unlimited';
const tone = resolveTone(summary.state);
const progress = typeof summary.limit === 'number' && summary.limit > 0
? Math.min(100, Math.round((summary.used / summary.limit) * 100))
: null;
const valueLabel = typeof summary.limit === 'number' && summary.limit > 0
? `${summary.used.toLocaleString()} / ${summary.limit.toLocaleString()}`
: t('upload.limitSummary.badges.unlimited');
const description = summary.state === 'unlimited'
? t(unlimitedKey)
: summary.remaining !== null && summary.limit !== null
? t(remainingKey)
.replace('{remaining}', `${Math.max(0, summary.remaining)}`)
.replace('{limit}', `${summary.limit}`)
: valueLabel;
const badgeKey = (() => {
switch (summary.state) {
case 'limit_reached':
return 'upload.limitSummary.badges.limit_reached';
case 'warning':
return 'upload.limitSummary.badges.warning';
case 'unlimited':
return 'upload.limitSummary.badges.unlimited';
default:
return 'upload.limitSummary.badges.ok';
}
})();
return {
id,
label: t(labelKey),
state: summary.state,
tone,
used: summary.used,
limit: summary.limit,
remaining: summary.remaining,
progress,
valueLabel,
description,
badgeLabel: t(badgeKey),
};
}
export function buildLimitSummaries(limits: EventPackageLimits | null | undefined, t: TranslateFn): LimitSummaryCard[] {
if (!limits) {
return [];
}
const cards: LimitSummaryCard[] = [];
if (limits.photos) {
cards.push(buildCard('photos', limits.photos, t));
}
if (limits.guests) {
cards.push(buildCard('guests', limits.guests, t));
}
return cards;
}

View File

@@ -1,107 +0,0 @@
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

@@ -1,51 +0,0 @@
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

@@ -1,86 +0,0 @@
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

@@ -1,24 +0,0 @@
type PushConfig = {
enabled: boolean;
vapidPublicKey: string | null;
};
type RuntimeConfig = {
push: PushConfig;
};
export function getRuntimeConfig(): RuntimeConfig {
const raw = typeof window !== 'undefined' ? window.__GUEST_RUNTIME_CONFIG__ : undefined;
return {
push: {
enabled: Boolean(raw?.push?.enabled),
vapidPublicKey: raw?.push?.vapidPublicKey ?? null,
},
};
}
export function getPushConfig(): PushConfig {
return getRuntimeConfig().push;
}

View File

@@ -1,59 +0,0 @@
import { createPhotoShareLink } from '../services/photosApi';
type ShareOptions = {
token: string;
photoId: number;
title?: string;
text?: string;
};
async function copyToClipboard(text: string): Promise<boolean> {
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
return true;
}
} catch {
// ignore and fallback
}
try {
const input = document.createElement('input');
input.value = text;
document.body.appendChild(input);
input.select();
document.execCommand('copy');
document.body.removeChild(input);
return true;
} catch {
return false;
}
}
export async function sharePhotoLink(options: ShareOptions): Promise<{ url: string; method: 'native' | 'clipboard' | 'manual' }>
{
const payload = await createPhotoShareLink(options.token, options.photoId);
const shareData: ShareData = {
title: options.title ?? 'Fotospiel Moment',
text: options.text ?? '',
url: payload.url,
};
if (navigator.share && (!navigator.canShare || navigator.canShare(shareData))) {
try {
await navigator.share(shareData);
return { url: payload.url, method: 'native' };
} catch (error: unknown) {
if (error && typeof error === 'object' && 'name' in error && (error as { name?: string }).name === 'AbortError') {
return { url: payload.url, method: 'native' };
}
// fall through to clipboard
}
}
if (await copyToClipboard(payload.url)) {
return { url: payload.url, method: 'clipboard' };
}
return { url: payload.url, method: 'manual' };
}

View File

@@ -1,18 +0,0 @@
export type TaskIdentity = {
id: number;
};
export function dedupeTasksById<T extends TaskIdentity>(tasks: T[]): T[] {
const seen = new Set<number>();
const unique: T[] = [];
tasks.forEach((task) => {
if (seen.has(task.id)) {
return;
}
seen.add(task.id);
unique.push(task);
});
return unique;
}

View File

@@ -1,94 +0,0 @@
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

@@ -1,61 +0,0 @@
import React, { Suspense } from 'react';
import { createRoot } from 'react-dom/client';
import '../../css/app.css';
import { enableGuestDemoMode, shouldEnableGuestDemoMode } from './demo/demoMode';
import { Sentry, initSentry } from '@/lib/sentry';
import { AppearanceProvider, initializeTheme } from '@/hooks/use-appearance';
import { ConsentProvider } from '@/contexts/consent';
const GuestFallback: React.FC<{ message: string }> = ({ message }) => (
<div className="flex min-h-screen items-center justify-center text-sm text-muted-foreground">
{message}
</div>
);
initSentry('guest');
initializeTheme();
if (shouldEnableGuestDemoMode()) {
enableGuestDemoMode();
}
const rootEl = document.getElementById('root')!;
const appRoot = async () => {
const { RouterProvider } = await import('react-router-dom');
const { router } = await import('./router');
const { ToastProvider } = await import('./components/ToastHost');
const { default: PwaManager } = await import('./components/PwaManager');
const { LocaleProvider } = await import('./i18n/LocaleContext');
const { default: MatomoTracker } = await import('@/components/analytics/MatomoTracker');
const rawMatomo = (window as any).__MATOMO_GUEST__ as { enabled?: boolean; url?: string; siteId?: string } | undefined;
const matomoConfig = rawMatomo
? {
enabled: Boolean(rawMatomo.enabled),
url: rawMatomo.url ?? '',
siteId: rawMatomo.siteId ?? '',
}
: undefined;
createRoot(rootEl).render(
<Sentry.ErrorBoundary fallback={<GuestFallback message="Erlebnisse können nicht geladen werden." />}>
<React.StrictMode>
<AppearanceProvider>
<ConsentProvider>
<LocaleProvider>
<ToastProvider>
<MatomoTracker config={matomoConfig} />
<PwaManager />
<Suspense fallback={<GuestFallback message="Erlebnisse werden geladen …" />}>
<RouterProvider router={router} />
</Suspense>
</ToastProvider>
</LocaleProvider>
</ConsentProvider>
</AppearanceProvider>
</React.StrictMode>
</Sentry.ErrorBoundary>
);
};
appRoot().catch(() => {
createRoot(rootEl).render(<GuestFallback message="Erlebnisse können nicht geladen werden." />);
});

View File

@@ -1,572 +0,0 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Link, useNavigationType, useParams } from 'react-router-dom';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Separator } from '@/components/ui/separator';
import { AnimatePresence, motion } from 'framer-motion';
import {
AchievementBadge,
AchievementsPayload,
FeedEntry,
LeaderboardEntry,
TimelinePoint,
TopPhotoHighlight,
TrendingEmotionHighlight,
fetchAchievements,
} from '../services/achievementApi';
import { useGuestIdentity } from '../context/GuestIdentityContext';
import { Sparkles, Award, Trophy, Camera, Users, BarChart2, Flame } from 'lucide-react';
import { useTranslation, type TranslateFn } from '../i18n/useTranslation';
import type { LocaleCode } from '../i18n/messages';
import { localizeTaskLabel } from '../lib/localizeTaskLabel';
import { useEventData } from '../hooks/useEventData';
import { isTaskModeEnabled } from '../lib/engagement';
import { FADE_SCALE, FADE_UP, STAGGER_FAST, getMotionContainerPropsForNavigation, getMotionItemProps, prefersReducedMotion } from '../lib/motion';
import PullToRefresh from '../components/PullToRefresh';
const GENERIC_ERROR = 'GENERIC_ERROR';
function formatRelativeTimestamp(input: string, formatter: Intl.RelativeTimeFormat): string {
const date = new Date(input);
if (Number.isNaN(date.getTime())) return '';
const diff = date.getTime() - Date.now();
const minute = 60_000;
const hour = 60 * minute;
const day = 24 * hour;
const abs = Math.abs(diff);
if (abs < minute) return formatter.format(0, 'second');
if (abs < hour) return formatter.format(Math.round(diff / minute), 'minute');
if (abs < day) return formatter.format(Math.round(diff / hour), 'hour');
return formatter.format(Math.round(diff / day), 'day');
}
type LeaderboardProps = {
title: string;
description: string;
icon: React.ElementType;
entries: LeaderboardEntry[];
emptyCopy: string;
formatNumber: (value: number) => string;
t: TranslateFn;
};
function Leaderboard({ title, description, icon: Icon, entries, emptyCopy, formatNumber, t }: LeaderboardProps) {
return (
<Card>
<CardHeader className="flex flex-row items-center gap-2 pb-4">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-pink-500/10 text-pink-500">
<Icon className="h-5 w-5" />
</div>
<div>
<CardTitle className="text-base font-semibold">{title}</CardTitle>
<CardDescription className="text-xs">{description}</CardDescription>
</div>
</CardHeader>
<CardContent className="space-y-2">
{entries.length === 0 ? (
<p className="text-sm text-muted-foreground">{emptyCopy}</p>
) : (
<ol className="space-y-2 text-sm">
{entries.map((entry, index) => (
<li key={`${entry.guest}-${index}`} className="flex items-center justify-between rounded-lg border border-border/60 bg-card/70 px-3 py-2">
<div className="flex items-center gap-3">
<span className="text-xs font-semibold text-muted-foreground">#{index + 1}</span>
<span className="font-medium text-foreground">{entry.guest || t('achievements.leaderboard.guestFallback')}</span>
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span>{t('achievements.leaderboard.item.photos', { count: formatNumber(entry.photos) })}</span>
<span>{t('achievements.leaderboard.item.likes', { count: formatNumber(entry.likes) })}</span>
</div>
</li>
))}
</ol>
)}
</CardContent>
</Card>
);
}
type BadgesGridProps = {
badges: AchievementBadge[];
t: TranslateFn;
};
export function BadgesGrid({ badges, t }: BadgesGridProps) {
if (badges.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle>{t('achievements.badges.title')}</CardTitle>
<CardDescription>{t('achievements.badges.description')}</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">{t('achievements.badges.empty')}</p>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle>{t('achievements.badges.title')}</CardTitle>
<CardDescription>{t('achievements.badges.description')}</CardDescription>
</CardHeader>
<CardContent className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{badges.map((badge) => {
const target = badge.target ?? 0;
const progress = badge.progress ?? 0;
const ratio = target > 0 ? Math.min(1, progress / target) : 0;
const percentage = Math.round((badge.earned ? 1 : ratio) * 100);
return (
<div
key={badge.id}
data-testid={`badge-card-${badge.id}`}
className={cn(
'relative overflow-hidden rounded-2xl border px-4 py-3 shadow-sm transition',
badge.earned
? 'border-emerald-400/40 bg-gradient-to-br from-emerald-500/20 via-emerald-500/5 to-white text-emerald-900 dark:border-emerald-400/30 dark:from-emerald-400/20 dark:via-emerald-400/10 dark:to-slate-950/70 dark:text-emerald-50'
: 'border-border/60 bg-card/90',
)}
>
<div className="flex items-start justify-between gap-2">
<div>
<p className="text-sm font-semibold text-foreground">{badge.title}</p>
<p className="text-xs text-muted-foreground">{badge.description}</p>
</div>
<span className="text-xs font-semibold text-muted-foreground">{percentage}%</span>
</div>
<div className="mt-3 h-2 rounded-full bg-muted">
<div className="h-2 rounded-full bg-gradient-to-r from-pink-500 to-purple-500" style={{ width: `${percentage}%` }} />
</div>
</div>
);
})}
</CardContent>
</Card>
);
}
type TimelineProps = {
points: TimelinePoint[];
t: TranslateFn;
formatNumber: (value: number) => string;
};
function Timeline({ points, t, formatNumber }: TimelineProps) {
return (
<Card>
<CardHeader>
<CardTitle>{t('achievements.timeline.title')}</CardTitle>
<CardDescription>{t('achievements.timeline.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-2 text-sm">
{points.map((point) => (
<div key={point.date} className="flex items-center justify-between rounded-lg border border-border/60 bg-card/70 px-3 py-2">
<span className="font-medium text-foreground">{point.date}</span>
<span className="text-muted-foreground">
{t('achievements.timeline.row', { photos: formatNumber(point.photos), guests: formatNumber(point.guests) })}
</span>
</div>
))}
</CardContent>
</Card>
);
}
type FeedProps = {
feed: FeedEntry[];
t: TranslateFn;
formatRelativeTime: (value: string) => string;
locale: LocaleCode;
formatNumber: (value: number) => string;
};
function Feed({ feed, t, formatRelativeTime, locale, formatNumber }: FeedProps) {
if (feed.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle>{t('achievements.feed.title')}</CardTitle>
<CardDescription>{t('achievements.feed.description')}</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">{t('achievements.feed.empty')}</p>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle>{t('achievements.feed.title')}</CardTitle>
<CardDescription>{t('achievements.feed.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{feed.map((item) => {
const taskLabel = localizeTaskLabel(item.task ?? null, locale);
return (
<div key={item.photoId} className="flex items-center gap-3 rounded-lg border border-border/60 bg-card/70 p-3">
{item.thumbnail ? (
<img src={item.thumbnail} alt={t('achievements.feed.thumbnailAlt')} className="h-16 w-16 rounded-md object-cover" />
) : (
<div className="flex h-16 w-16 items-center justify-center rounded-md bg-muted text-muted-foreground">
<Camera className="h-6 w-6" aria-hidden />
</div>
)}
<div className="flex-1 text-sm">
<p className="font-semibold text-foreground">{item.guest || t('achievements.leaderboard.guestFallback')}</p>
{taskLabel && <p className="text-xs text-muted-foreground">{t('achievements.feed.taskLabel', { task: taskLabel })}</p>}
<div className="mt-1 flex items-center gap-4 text-xs text-muted-foreground">
<span>{formatRelativeTime(item.createdAt)}</span>
<span>{t('achievements.feed.likesLabel', { count: formatNumber(item.likes) })}</span>
</div>
</div>
</div>
);
})}
</CardContent>
</Card>
);
}
type HighlightsProps = {
topPhoto: TopPhotoHighlight | null;
trendingEmotion: TrendingEmotionHighlight | null;
t: TranslateFn;
formatRelativeTime: (value: string) => string;
locale: LocaleCode;
formatNumber: (value: number) => string;
};
function Highlights({ topPhoto, trendingEmotion, t, formatRelativeTime, locale, formatNumber }: HighlightsProps) {
if (!topPhoto && !trendingEmotion) {
return null;
}
const renderTopPhoto = () => {
if (!topPhoto) return null;
const localizedTask = localizeTaskLabel(topPhoto.task ?? null, locale);
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>{t('achievements.highlights.topTitle')}</CardTitle>
<CardDescription>{t('achievements.highlights.topDescription')}</CardDescription>
</div>
<Trophy className="h-6 w-6 text-amber-400" aria-hidden />
</CardHeader>
<CardContent className="space-y-2 text-sm">
<div className="overflow-hidden rounded-xl border border-border/40">
{topPhoto.thumbnail ? (
<img src={topPhoto.thumbnail} alt={t('achievements.highlights.topTitle')} className="h-48 w-full object-cover" />
) : (
<div className="flex h-48 w-full items-center justify-center bg-muted text-muted-foreground">
{t('achievements.highlights.noPreview')}
</div>
)}
</div>
<p>
<span className="font-semibold text-foreground">{topPhoto.guest || t('achievements.leaderboard.guestFallback')}</span>
{` ${t('achievements.highlights.likesAmount', { count: formatNumber(topPhoto.likes) })}`}
</p>
{localizedTask && (
<p className="text-muted-foreground">
{t('achievements.highlights.taskLabel', { task: localizedTask })}
</p>
)}
<p className="text-xs text-muted-foreground">{formatRelativeTime(topPhoto.createdAt)}</p>
</CardContent>
</Card>
);
};
const renderTrendingEmotion = () => {
if (!trendingEmotion) return null;
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>{t('achievements.highlights.trendingTitle')}</CardTitle>
<CardDescription>{t('achievements.highlights.trendingDescription')}</CardDescription>
</div>
<Flame className="h-6 w-6 text-pink-500" aria-hidden />
</CardHeader>
<CardContent>
<p className="text-lg font-semibold text-foreground">{trendingEmotion.name}</p>
<p className="text-sm text-muted-foreground">
{t('achievements.highlights.trendingCount', { count: formatNumber(trendingEmotion.count) })}
</p>
</CardContent>
</Card>
);
};
return (
<div className="grid gap-4 md:grid-cols-2">
{renderTopPhoto()}
{renderTrendingEmotion()}
</div>
);
}
type PersonalActionsProps = {
token: string;
t: TranslateFn;
tasksEnabled: boolean;
};
function PersonalActions({ token, t, tasksEnabled }: PersonalActionsProps) {
return (
<div className="flex flex-wrap gap-3">
<Button asChild>
<Link to={`/e/${encodeURIComponent(token)}/upload`} className="flex items-center gap-2">
<Camera className="h-4 w-4" aria-hidden />
{t('achievements.personal.actions.upload')}
</Link>
</Button>
{tasksEnabled ? (
<Button variant="outline" asChild>
<Link to={`/e/${encodeURIComponent(token)}/tasks`} className="flex items-center gap-2">
<Sparkles className="h-4 w-4" aria-hidden />
{t('achievements.personal.actions.tasks')}
</Link>
</Button>
) : null}
</div>
);
}
export default function AchievementsPage() {
const { token } = useParams<{ token: string }>();
const navigationType = useNavigationType();
const identity = useGuestIdentity();
const { t, locale } = useTranslation();
const { event } = useEventData();
const tasksEnabled = isTaskModeEnabled(event);
const [data, setData] = useState<AchievementsPayload | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<'personal' | 'event' | 'feed'>('personal');
const numberFormatter = useMemo(() => new Intl.NumberFormat(locale), [locale]);
const formatNumber = (value: number) => numberFormatter.format(value);
const relativeFormatter = useMemo(() => new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }), [locale]);
const formatRelative = (value: string) => formatRelativeTimestamp(value, relativeFormatter);
const personalName = identity.hydrated && identity.name ? identity.name : undefined;
const loadAchievements = React.useCallback(async (signal?: AbortSignal) => {
if (!token) return;
setLoading(true);
setError(null);
try {
const payload = await fetchAchievements(token, {
guestName: personalName,
locale,
signal,
});
setData(payload);
if (!payload.personal) {
setActiveTab('event');
}
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return;
console.error('Failed to load achievements', err);
setError(err instanceof Error ? err.message : GENERIC_ERROR);
} finally {
if (!signal?.aborted) {
setLoading(false);
}
}
}, [locale, personalName, token]);
useEffect(() => {
const controller = new AbortController();
void loadAchievements(controller.signal);
return () => controller.abort();
}, [loadAchievements]);
const hasPersonal = Boolean(data?.personal);
const motionEnabled = !prefersReducedMotion();
const containerMotion = getMotionContainerPropsForNavigation(motionEnabled, STAGGER_FAST, navigationType);
const fadeUpMotion = getMotionItemProps(motionEnabled, FADE_UP);
const fadeScaleMotion = getMotionItemProps(motionEnabled, FADE_SCALE);
const tabMotion = motionEnabled
? { variants: FADE_UP, initial: 'hidden', animate: 'show', exit: 'hidden' as const }
: {};
const handleRefresh = React.useCallback(async () => {
await loadAchievements();
}, [loadAchievements]);
if (!token) {
return null;
}
const tabContent = (
<>
{activeTab === 'personal' && hasPersonal && data?.personal && (
<div className="space-y-5">
<Card>
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle className="text-lg font-semibold">
{t('achievements.personal.greeting', { name: data.personal.guestName || identity.name || t('achievements.leaderboard.guestFallback') })}
</CardTitle>
<CardDescription>
{t('achievements.personal.stats', {
photos: formatNumber(data.personal.photos),
tasks: formatNumber(data.personal.tasks),
likes: formatNumber(data.personal.likes),
})}
</CardDescription>
</div>
<PersonalActions token={token} t={t} tasksEnabled={tasksEnabled} />
</CardHeader>
</Card>
<BadgesGrid badges={data.personal.badges} t={t} />
</div>
)}
{activeTab === 'event' && data && (
<div className="space-y-5">
<Highlights
topPhoto={data.highlights.topPhoto}
trendingEmotion={data.highlights.trendingEmotion}
t={t}
formatRelativeTime={formatRelative}
locale={locale}
formatNumber={formatNumber}
/>
<Timeline points={data.highlights.timeline} t={t} formatNumber={formatNumber} />
<div className="grid gap-4 lg:grid-cols-2">
<Leaderboard
title={t('achievements.leaderboard.uploadsTitle')}
description={t('achievements.leaderboard.description')}
icon={Users}
entries={data.leaderboards.uploads}
emptyCopy={t('achievements.leaderboard.uploadsEmpty')}
t={t}
formatNumber={formatNumber}
/>
<Leaderboard
title={t('achievements.leaderboard.likesTitle')}
description={t('achievements.leaderboard.description')}
icon={Trophy}
entries={data.leaderboards.likes}
emptyCopy={t('achievements.leaderboard.likesEmpty')}
t={t}
formatNumber={formatNumber}
/>
</div>
</div>
)}
{activeTab === 'feed' && data && (
<Feed
feed={data.feed}
t={t}
formatRelativeTime={formatRelative}
locale={locale}
formatNumber={formatNumber}
/>
)}
</>
);
return (
<PullToRefresh
onRefresh={handleRefresh}
pullLabel={t('common.pullToRefresh')}
releaseLabel={t('common.releaseToRefresh')}
refreshingLabel={t('common.refreshing')}
>
<motion.div className="space-y-6 pb-24" {...containerMotion}>
<motion.div className="space-y-2" {...fadeUpMotion}>
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-pink-500/10 text-pink-500">
<Award className="h-5 w-5" aria-hidden />
</div>
<div>
<h1 className="text-2xl font-semibold text-foreground">{t('achievements.page.title')}</h1>
<p className="text-sm text-muted-foreground">{t('achievements.page.subtitle')}</p>
</div>
</div>
</motion.div>
{loading && (
<motion.div className="space-y-4" {...fadeUpMotion}>
<Skeleton className="h-24 w-full" />
<Skeleton className="h-32 w-full" />
<Skeleton className="h-48 w-full" />
</motion.div>
)}
{!loading && error && (
<motion.div {...fadeUpMotion}>
<Alert variant="destructive">
<AlertDescription className="flex items-center justify-between gap-3">
<span>{error === GENERIC_ERROR ? t('achievements.page.loadError') : error}</span>
<Button variant="outline" size="sm" onClick={() => setActiveTab(hasPersonal ? 'personal' : 'event')}>
{t('achievements.page.retry')}
</Button>
</AlertDescription>
</Alert>
</motion.div>
)}
{!loading && !error && data && (
<>
<motion.div className="flex flex-wrap items-center gap-2" {...fadeUpMotion}>
<Button
variant={activeTab === 'personal' ? 'default' : 'outline'}
onClick={() => setActiveTab('personal')}
disabled={!hasPersonal}
className="flex items-center gap-2"
>
<Sparkles className="h-4 w-4" aria-hidden />
{t('achievements.page.buttons.personal')}
</Button>
<Button
variant={activeTab === 'event' ? 'default' : 'outline'}
onClick={() => setActiveTab('event')}
className="flex items-center gap-2"
>
<Users className="h-4 w-4" aria-hidden />
{t('achievements.page.buttons.event')}
</Button>
<Button
variant={activeTab === 'feed' ? 'default' : 'outline'}
onClick={() => setActiveTab('feed')}
className="flex items-center gap-2"
>
<BarChart2 className="h-4 w-4" aria-hidden />
{t('achievements.page.buttons.feed')}
</Button>
</motion.div>
<motion.div {...fadeUpMotion}>
<Separator />
</motion.div>
{motionEnabled ? (
<AnimatePresence mode="wait" initial={false}>
<motion.div key={activeTab} {...tabMotion}>
{tabContent}
</motion.div>
</AnimatePresence>
) : (
<motion.div {...fadeScaleMotion}>{tabContent}</motion.div>
)}
</>
)}
</motion.div>
</PullToRefresh>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,349 +0,0 @@
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Sparkles, Camera, ShieldCheck, QrCode, PartyPopper, Smartphone } from 'lucide-react';
import { Html5Qrcode } from 'html5-qrcode';
import { readGuestName } from '../context/GuestIdentityContext';
import { useTranslation } from '../i18n/useTranslation';
type LandingErrorKey = 'eventClosed' | 'network' | 'camera';
export default function LandingPage() {
const nav = useNavigate();
const { t } = useTranslation();
const [eventCode, setEventCode] = useState('');
const [loading, setLoading] = useState(false);
const [errorKey, setErrorKey] = useState<LandingErrorKey | null>(null);
const [isScanning, setIsScanning] = useState(false);
const [scanner, setScanner] = useState<Html5Qrcode | null>(null);
const errorMessage = errorKey ? t(`landing.errors.${errorKey}`) : null;
function extractEventKey(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) {
return '';
}
try {
const url = new URL(trimmed);
const inviteParam = url.searchParams.get('invite') ?? url.searchParams.get('token');
if (inviteParam) {
return inviteParam;
}
const segments = url.pathname.split('/').filter(Boolean);
const eventIndex = segments.findIndex((segment) => segment === 'e');
if (eventIndex >= 0 && segments.length > eventIndex + 1) {
return decodeURIComponent(segments[eventIndex + 1]);
}
if (segments.length > 0) {
return decodeURIComponent(segments[segments.length - 1]);
}
} catch {
// Not a URL, treat as raw code
}
return trimmed;
}
async function join(input?: string) {
const provided = input ?? eventCode;
const normalized = extractEventKey(provided);
if (!normalized) return;
setLoading(true);
setErrorKey(null);
try {
const res = await fetch(`/api/v1/events/${encodeURIComponent(normalized)}`);
if (!res.ok) {
setErrorKey('eventClosed');
return;
}
const data = await res.json();
const targetKey = data.join_token ?? '';
if (!targetKey) {
setErrorKey('eventClosed');
return;
}
const storedName = readGuestName(targetKey);
if (!storedName) {
nav(`/setup/${encodeURIComponent(targetKey)}`);
} else {
nav(`/e/${encodeURIComponent(targetKey)}`);
}
} catch (e) {
console.error('Join request failed', e);
setErrorKey('network');
} finally {
setLoading(false);
}
}
const qrConfig = { fps: 10, qrbox: { width: 250, height: 250 } } as const;
async function startScanner() {
if (scanner) {
try {
await scanner.start({ facingMode: 'environment' }, qrConfig, onScanSuccess, () => undefined);
setIsScanning(true);
} catch (err) {
console.error('Scanner start failed', err);
setErrorKey('camera');
}
return;
}
try {
const newScanner = new Html5Qrcode('qr-reader');
setScanner(newScanner);
await newScanner.start({ facingMode: 'environment' }, qrConfig, onScanSuccess, () => undefined);
setIsScanning(true);
} catch (err) {
console.error('Scanner initialisation failed', err);
setErrorKey('camera');
}
}
function stopScanner() {
if (!scanner) {
setIsScanning(false);
return;
}
scanner
.stop()
.then(() => {
setIsScanning(false);
})
.catch((err) => console.error('Scanner stop failed', err));
}
async function onScanSuccess(decodedText: string) {
const value = decodedText.trim();
if (!value) return;
await join(value);
stopScanner();
}
useEffect(() => () => {
if (scanner) {
scanner.stop().catch(() => undefined);
}
}, [scanner]);
const heroFeatures = [
{
icon: Sparkles,
title: t('landing.features.momentsTitle', 'Momente mit Wow-Effekt'),
description: t('landing.features.momentsCopy', 'Moderierte Fotoaufgaben motivieren dein Team und halten die Stimmung hoch.'),
},
{
icon: Camera,
title: t('landing.features.uploadTitle', 'Uploads ohne App-Stress'),
description: t('landing.features.uploadCopy', 'Scan & Shoot: Gäste landen direkt im Event und teilen ihre Highlights live.'),
},
{
icon: ShieldCheck,
title: t('landing.features.trustTitle', 'Sicher & DSGVO-konform'),
description: t('landing.features.trustCopy', 'Nur eingeladene Gäste erhalten Zugriff mit Tokens, Rollenrechten und deutschem Hosting.'),
},
];
const highlightCards = [
{
icon: Sparkles,
title: t('landing.highlight.story', 'Storytelling statt Sammelalbum'),
description: t('landing.highlight.storyCopy', 'Fotospiel verbindet Aufgaben, Emotionen und Uploads zu einer spannenden Timeline.'),
},
{
icon: Camera,
title: t('landing.highlight.mobile', 'Optimiert für jedes Smartphone'),
description: t('landing.highlight.mobileCopy', 'Keine App-Installation nötig einfach Link öffnen oder QR-Code scannen.'),
},
{
icon: ShieldCheck,
title: t('landing.highlight.privacy', 'Transparente Freigaben'),
description: t('landing.highlight.privacyCopy', 'Admin- und Gästerollen sorgen dafür, dass nur autorisierte Personen Inhalte sehen.'),
},
{
icon: PartyPopper,
title: t('landing.highlight.live', 'Live auf Screens & Slideshows'),
description: t('landing.highlight.liveCopy', 'Uploads können sofort auf Displays, Projektoren oder dem großen Screen erscheinen.'),
},
];
const steps = [
{ icon: QrCode, label: t('landing.steps.scan', 'QR-Code vom Event scannen oder Link öffnen.') },
{ icon: Smartphone, label: t('landing.steps.profile', 'Kurz vorstellen: Name eintragen und loslegen.') },
{ icon: PartyPopper, label: t('landing.steps.upload', 'Fotos aufnehmen, Aufgaben lösen, Erinnerungen teilen.') },
];
return (
<div className="relative min-h-screen bg-slate-950 text-white">
<div
aria-hidden
className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_top,_rgba(255,174,204,0.55),transparent_55%),radial-gradient(circle_at_30%_30%,rgba(56,189,248,0.35),transparent_50%),radial-gradient(circle_at_bottom,_rgba(113,88,226,0.45),transparent_60%)] opacity-80"
/>
<div className="absolute inset-0 bg-gradient-to-b from-slate-950/80 via-slate-950/85 to-slate-950" aria-hidden />
<div className="relative z-10 px-4 py-10 sm:px-6 lg:px-10">
<div className="mx-auto flex w-full max-w-6xl flex-col gap-10">
{errorMessage && (
<Alert variant="destructive" className="border-rose-400/70 bg-rose-500/15 text-rose-50 backdrop-blur">
<AlertDescription>{errorMessage}</AlertDescription>
</Alert>
)}
<section className="grid gap-12 lg:grid-cols-[1.05fr_0.95fr] lg:items-center">
<div className="space-y-6">
<p className="text-sm uppercase tracking-[0.5em] text-rose-200">{t('landing.pageTitle')}</p>
<div className="space-y-4">
<h1 className="text-4xl font-semibold leading-tight text-white sm:text-5xl">
{t('landing.headline', 'Die Event-Landing, die zum Marketing passt.')}
</h1>
<p className="text-lg text-slate-200 sm:text-xl">
{t('landing.subheadline', 'Fotospiel begrüßt deine Gäste mit einem warmen Erlebnis, noch bevor die erste Aufnahme entsteht.')}
</p>
</div>
<ul className="space-y-3 text-base text-slate-200">
{heroFeatures.map((feature) => (
<li key={feature.title} className="flex items-start gap-3">
<span className="mt-1 flex h-9 w-9 items-center justify-center rounded-xl bg-white/10 text-rose-200">
<feature.icon className="h-4 w-4" />
</span>
<div>
<p className="text-base font-semibold text-white">{feature.title}</p>
<p className="text-sm text-slate-300">{feature.description}</p>
</div>
</li>
))}
</ul>
<div className="flex flex-wrap gap-3 text-sm text-slate-300">
<span className="rounded-full border border-white/20 px-4 py-1.5">
{t('landing.tags.private', 'Nur für eingeladene Gäste')}
</span>
<span className="rounded-full border border-white/20 px-4 py-1.5">
{t('landing.tags.instant', 'Live-Uploads & Aufgaben')}
</span>
<span className="rounded-full border border-white/20 px-4 py-1.5">
{t('landing.tags.deHosted', 'Gehostet in Deutschland')}
</span>
</div>
</div>
<Card className="border-white/15 bg-white/95 text-slate-900 shadow-[0_25px_60px_-30px_rgba(15,23,42,0.85)] backdrop-blur-xl">
<CardHeader className="space-y-2 text-center">
<CardTitle className="text-2xl font-semibold text-slate-900">
{t('landing.join.title')}
</CardTitle>
<CardDescription className="text-base text-slate-600">
{t('landing.join.description')}
</CardDescription>
<div className="text-xs uppercase tracking-[0.4em] text-rose-500">
{t('landing.join.subline', 'QR · Code · Link')}
</div>
</CardHeader>
<CardContent className="space-y-5">
<div className="rounded-2xl border border-slate-200 bg-slate-50/80 p-4 text-center">
<p className="text-sm font-semibold text-slate-700">{t('landing.scan.headline', 'QR-Code scannen')}</p>
<p className="text-xs text-slate-500">{t('landing.scan.subline', 'Nutze die Kamera deines Smartphones oder Tablets')}</p>
<div className="mt-4 flex flex-col items-center gap-3">
<div className="flex h-24 w-24 items-center justify-center rounded-2xl bg-white shadow-inner shadow-slate-200/80">
<QrCode className="h-10 w-10 text-slate-800" />
</div>
<div id="qr-reader" className="w-full" hidden={!isScanning} />
<Button
variant={isScanning ? 'secondary' : 'default'}
className="w-full justify-center rounded-xl text-base"
onClick={isScanning ? stopScanner : startScanner}
disabled={loading}
>
{isScanning ? t('landing.scan.stop') : t('landing.scan.start')}
</Button>
</div>
</div>
<div className="text-center text-xs uppercase tracking-[0.3em] text-slate-400">
{t('landing.scan.manualDivider')}
</div>
<div className="space-y-3">
<Input
value={eventCode}
onChange={(event) => setEventCode(event.target.value)}
placeholder={t('landing.input.placeholder')}
disabled={loading}
className="h-12 rounded-2xl border-slate-200 bg-white px-4 text-base"
/>
<Button
className="h-12 w-full rounded-2xl bg-gradient-to-r from-rose-500 via-fuchsia-500 to-indigo-500 text-base font-semibold text-white shadow-lg shadow-rose-500/30 transition hover:brightness-110"
disabled={loading || !eventCode.trim()}
onClick={() => join()}
>
{loading ? t('landing.join.buttonLoading') : t('landing.join.button')}
</Button>
</div>
<p className="text-center text-xs text-slate-500">
{t('landing.hint.support', 'Du hast einen Link erhalten? Füge ihn direkt oben ein wir erkennen den Event-Code automatisch.')}
</p>
</CardContent>
</Card>
</section>
<section className="rounded-3xl border border-white/10 bg-white/5 p-6 shadow-inner shadow-white/10 backdrop-blur">
<p className="text-sm font-semibold uppercase tracking-[0.45em] text-rose-200">
{t('landing.steps.title', 'So funktioniert Fotospiel')}
</p>
<div className="mt-4 grid gap-6 md:grid-cols-3">
{steps.map(({ icon: Icon, label }) => (
<div key={label} className="space-y-2 rounded-2xl border border-white/10 bg-white/5 p-5 text-sm text-slate-200">
<span className="flex h-10 w-10 items-center justify-center rounded-xl bg-white/10 text-rose-200">
<Icon className="h-5 w-5" />
</span>
<p className="text-base font-medium text-white">{label}</p>
</div>
))}
</div>
</section>
<section className="grid gap-6 lg:grid-cols-2">
{highlightCards.map((feature) => (
<div
key={feature.title}
className="rounded-3xl border border-white/10 bg-white/5 p-5 text-slate-200 shadow-inner shadow-white/5 backdrop-blur"
>
<div className="flex items-center gap-3 text-white">
<span className="flex h-10 w-10 items-center justify-center rounded-2xl bg-white/10 text-rose-200">
<feature.icon className="h-5 w-5" />
</span>
<h3 className="text-lg font-semibold">{feature.title}</h3>
</div>
<p className="mt-2 text-sm text-slate-200">{feature.description}</p>
</div>
))}
</section>
<section className="rounded-3xl border border-white/10 bg-white/10 p-6 text-sm text-white backdrop-blur">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<p className="text-base font-semibold uppercase tracking-[0.35em] text-rose-200">
{t('landing.support.title', 'Support & Fragen')}
</p>
<p className="mt-1 text-sm text-white/80">
{t('landing.support.copy', 'Frag dein Event-Team oder melde dich bei uns wir helfen sofort weiter.')}
</p>
</div>
<div className="flex flex-wrap gap-3 text-sm">
<span className="rounded-full border border-white/20 px-4 py-1.5">
{t('landing.support.email', 'support@fotospiel.de')}
</span>
<span className="rounded-full border border-white/20 px-4 py-1.5">
{t('landing.support.reply', 'Direkt auf die Einladung antworten')}
</span>
</div>
</div>
</section>
</div>
</div>
</div>
);
}

View File

@@ -1,59 +0,0 @@
import React from "react";
import { Page } from './_util';
import { useParams } from 'react-router-dom';
import { LegalMarkdown } from '../components/legal-markdown';
export default function LegalPage() {
const { page } = useParams();
const [loading, setLoading] = React.useState(true);
const [title, setTitle] = React.useState('');
const [body, setBody] = React.useState('');
const [html, setHtml] = React.useState('');
React.useEffect(() => {
if (!page) {
return;
}
const slug = page;
const controller = new AbortController();
async function loadLegal() {
try {
setLoading(true);
const res = await fetch(`/api/v1/legal/${encodeURIComponent(slug)}?lang=de`, {
headers: { 'Cache-Control': 'no-store' },
signal: controller.signal,
});
if (!res.ok) {
throw new Error('failed');
}
const data = await res.json();
setTitle(data.title || '');
setBody(data.body_markdown || '');
setHtml(data.body_html || '');
} catch (error) {
if (!controller.signal.aborted) {
console.error('Failed to load legal page', error);
setTitle('');
setBody('');
setHtml('');
}
} finally {
if (!controller.signal.aborted) {
setLoading(false);
}
}
}
loadLegal();
return () => controller.abort();
}, [page]);
const fallbackTitle = page ? `Rechtliches: ${page}` : 'Rechtliche Informationen';
return (
<Page title={title || fallbackTitle}>
{loading ? <p>Laedt...</p> : <LegalMarkdown markdown={body} html={html} />}
</Page>
);
}

View File

@@ -1,240 +0,0 @@
import React from 'react';
import { useParams } from 'react-router-dom';
import { Loader2, Maximize2, Minimize2, Pause, Play, WifiOff } from 'lucide-react';
import { AnimatePresence, motion } from 'framer-motion';
import { useLiveShowState } from '../hooks/useLiveShowState';
import { useLiveShowPlayback } from '../hooks/useLiveShowPlayback';
import LiveShowStage from '../components/LiveShowStage';
import LiveShowBackdrop from '../components/LiveShowBackdrop';
import { useTranslation } from '../i18n/useTranslation';
import { prefersReducedMotion } from '../lib/motion';
import { resolveLiveShowEffect } from '../lib/liveShowEffects';
export default function LiveShowPlayerPage() {
const { token } = useParams<{ token: string }>();
const { t } = useTranslation();
const { status, connection, error, event, photos, settings } = useLiveShowState(token ?? null);
const [paused, setPaused] = React.useState(false);
const { frame, layout, frameKey, nextFrame } = useLiveShowPlayback(photos, settings, { paused });
const hasPhoto = frame.length > 0;
const stageTitle = event?.name ?? t('liveShowPlayer.title', 'Live Show');
const reducedMotion = prefersReducedMotion();
const effect = resolveLiveShowEffect(settings.effect_preset, settings.effect_intensity, reducedMotion);
const showStage = status === 'ready' && hasPhoto;
const showEmpty = status === 'ready' && !hasPhoto;
const [controlsVisible, setControlsVisible] = React.useState(true);
const [isFullscreen, setIsFullscreen] = React.useState(false);
const [isOnline, setIsOnline] = React.useState(typeof navigator !== 'undefined' ? navigator.onLine : true);
const hideTimerRef = React.useRef<number | null>(null);
const preloadRef = React.useRef<Set<string>>(new Set());
const stageRef = React.useRef<HTMLDivElement | null>(null);
React.useEffect(() => {
document.body.classList.add('guest-immersive');
return () => {
document.body.classList.remove('guest-immersive');
};
}, []);
React.useEffect(() => {
const updateOnline = () => setIsOnline(navigator.onLine);
window.addEventListener('online', updateOnline);
window.addEventListener('offline', updateOnline);
return () => {
window.removeEventListener('online', updateOnline);
window.removeEventListener('offline', updateOnline);
};
}, []);
React.useEffect(() => {
const handleFullscreen = () => setIsFullscreen(Boolean(document.fullscreenElement));
document.addEventListener('fullscreenchange', handleFullscreen);
handleFullscreen();
return () => document.removeEventListener('fullscreenchange', handleFullscreen);
}, []);
const revealControls = React.useCallback(() => {
setControlsVisible(true);
if (hideTimerRef.current) {
window.clearTimeout(hideTimerRef.current);
}
hideTimerRef.current = window.setTimeout(() => {
setControlsVisible(false);
}, 3000);
}, []);
React.useEffect(() => {
if (!showStage) {
setControlsVisible(true);
return;
}
revealControls();
}, [revealControls, showStage, frameKey]);
const togglePause = React.useCallback(() => {
setPaused((prev) => !prev);
}, []);
const toggleFullscreen = React.useCallback(async () => {
const target = stageRef.current ?? document.documentElement;
try {
if (!document.fullscreenElement) {
await target.requestFullscreen?.();
} else {
await document.exitFullscreen?.();
}
} catch (err) {
console.warn('Fullscreen toggle failed', err);
}
}, []);
React.useEffect(() => {
const handleKey = (event: KeyboardEvent) => {
if (event.target && (event.target as HTMLElement).closest('input, textarea, select, button')) {
return;
}
if (event.code === 'Space') {
event.preventDefault();
togglePause();
revealControls();
}
if (event.key.toLowerCase() === 'f') {
event.preventDefault();
toggleFullscreen();
revealControls();
}
if (event.key === 'Escape' && document.fullscreenElement) {
event.preventDefault();
document.exitFullscreen?.();
}
};
window.addEventListener('keydown', handleKey);
return () => window.removeEventListener('keydown', handleKey);
}, [revealControls, toggleFullscreen, togglePause]);
React.useEffect(() => {
const candidates = [...frame, ...nextFrame].slice(0, 6);
candidates.forEach((photo) => {
const src = photo.full_url || photo.thumb_url;
if (!src || preloadRef.current.has(src)) {
return;
}
const img = new Image();
img.src = src;
preloadRef.current.add(src);
});
}, [frame, nextFrame]);
return (
<div
ref={stageRef}
className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden bg-black text-white"
aria-busy={status === 'loading'}
onMouseMove={revealControls}
onTouchStart={revealControls}
>
<LiveShowBackdrop mode={settings.background_mode} photo={frame[0]} intensity={settings.effect_intensity} />
<div className="pointer-events-none absolute inset-x-0 top-0 z-30 flex items-center justify-between px-6 py-4 text-sm">
<span className="font-semibold text-white" style={{ fontFamily: 'var(--guest-heading-font)' }}>
{stageTitle}
</span>
<span className="rounded-full bg-white/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.24em] text-white/70">
{connection === 'sse'
? t('liveShowPlayer.connection.live', 'Live')
: t('liveShowPlayer.connection.sync', 'Sync')}
</span>
</div>
{status === 'loading' && (
<div className="flex flex-col items-center gap-4 text-white/70">
<Loader2 className="h-10 w-10 animate-spin" aria-hidden />
<p className="text-sm">{t('liveShowPlayer.loading', 'Live Show wird geladen...')}</p>
</div>
)}
{status === 'error' && (
<div className="max-w-md space-y-2 px-6 text-center">
<p className="text-lg font-semibold text-white">
{t('liveShowPlayer.error.title', 'Live Show nicht erreichbar')}
</p>
<p className="text-sm text-white/70">
{error ?? t('liveShowPlayer.error.description', 'Bitte überprüfe den Live-Link.')}
</p>
</div>
)}
<AnimatePresence initial={false} mode="sync">
{showStage && (
<motion.div key={frameKey} className="relative z-10 flex min-h-0 w-full flex-1 items-stretch" {...effect.frame}>
<LiveShowStage layout={layout} photos={frame} title={stageTitle} />
</motion.div>
)}
</AnimatePresence>
{showStage && effect.flash && (
<motion.div
key={`flash-${frameKey}`}
className="pointer-events-none absolute inset-0 z-20 bg-white"
{...effect.flash}
/>
)}
<AnimatePresence initial={false}>
{controlsVisible && (
<motion.div
className="absolute bottom-6 left-1/2 z-30 flex -translate-x-1/2 items-center gap-3 rounded-full border border-white/10 bg-black/60 px-4 py-2 text-xs text-white/80 shadow-lg backdrop-blur"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 8 }}
transition={{ duration: 0.2 }}
>
<button
type="button"
className="flex items-center gap-2 rounded-full bg-white/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-white"
onClick={togglePause}
>
{paused ? <Play className="h-4 w-4" aria-hidden /> : <Pause className="h-4 w-4" aria-hidden />}
<span>{paused ? t('liveShowPlayer.controls.play', 'Play') : t('liveShowPlayer.controls.pause', 'Pause')}</span>
</button>
<button
type="button"
className="flex items-center gap-2 rounded-full bg-white/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-white"
onClick={toggleFullscreen}
>
{isFullscreen ? <Minimize2 className="h-4 w-4" aria-hidden /> : <Maximize2 className="h-4 w-4" aria-hidden />}
<span>
{isFullscreen
? t('liveShowPlayer.controls.exitFullscreen', 'Exit fullscreen')
: t('liveShowPlayer.controls.fullscreen', 'Fullscreen')}
</span>
</button>
{!isOnline && (
<span className="flex items-center gap-2 text-white/70">
<WifiOff className="h-4 w-4" aria-hidden />
{t('liveShowPlayer.controls.offline', 'Offline')}
</span>
)}
</motion.div>
)}
</AnimatePresence>
{paused && showStage && (
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center">
<div className="rounded-full border border-white/20 bg-black/50 px-6 py-3 text-sm font-semibold uppercase tracking-[0.3em] text-white/80">
{t('liveShowPlayer.controls.paused', 'Paused')}
</div>
</div>
)}
{showEmpty && (
<div className="max-w-md space-y-2 px-6 text-center text-white/70">
<p className="text-lg font-semibold text-white">
{t('liveShowPlayer.empty.title', 'Noch keine Live-Fotos')}
</p>
<p className="text-sm">{t('liveShowPlayer.empty.description', 'Warte auf die ersten Uploads...')}</p>
</div>
)}
</div>
);
}

View File

@@ -1,12 +0,0 @@
import React from 'react';
import { Page } from './_util';
import { useTranslation } from '../i18n/useTranslation';
export default function NotFoundPage() {
const { t } = useTranslation();
return (
<Page title={t('notFound.title')}>
<p>{t('notFound.description')}</p>
</Page>
);
}

View File

@@ -1,612 +0,0 @@
import React, { useState, useEffect } from 'react';
import { useParams, useLocation, useNavigate } from 'react-router-dom';
import { Dialog, DialogContent } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { Heart, ChevronLeft, ChevronRight, X, Share2, Download } from 'lucide-react';
import { likePhoto, createPhotoShareLink } from '../services/photosApi';
import { useTranslation } from '../i18n/useTranslation';
import { useToast } from '../components/ToastHost';
import ShareSheet from '../components/ShareSheet';
import { useEventBranding } from '../context/EventBrandingContext';
import { getDeviceId } from '../lib/device';
import { triggerHaptic } from '../lib/haptics';
import { useGesture } from '@use-gesture/react';
import { animated, to, useSpring } from '@react-spring/web';
type Photo = {
id: number;
file_path?: string;
thumbnail_path?: string;
likes_count?: number;
created_at?: string;
task_id?: number;
task_title?: string;
uploader_name?: string | null;
};
type Task = { id: number; title: string };
interface Props {
photos?: Photo[];
currentIndex?: number;
onClose?: () => void;
onIndexChange?: (index: number) => void;
token?: string;
eventName?: string | null;
}
export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexChange, token, eventName }: Props) {
const params = useParams<{ token?: string; photoId?: string }>();
const location = useLocation();
const navigate = useNavigate();
const photoId = params.photoId;
const eventToken = params.token || token;
const { t, locale } = useTranslation();
const toast = useToast();
const { branding } = useEventBranding();
const [standalonePhoto, setStandalonePhoto] = useState<Photo | null>(null);
const [task, setTask] = useState<Task | null>(null);
const [taskLoading, setTaskLoading] = useState(false);
const [likes, setLikes] = useState<number>(0);
const [liked, setLiked] = useState(false);
const [shareSheet, setShareSheet] = useState<{ url: string | null; loading: boolean }>({
url: null,
loading: false,
});
// Determine mode and photo
const isStandalone = !photos || photos.length === 0;
const currentPhotos = isStandalone ? (standalonePhoto ? [standalonePhoto] : []) : photos || [];
const currentIndexVal = isStandalone ? 0 : (currentIndex || 0);
const photo = currentPhotos[currentIndexVal];
// Fallback onClose for standalone
const handleClose = onClose || (() => navigate(-1));
// Fetch single photo for standalone mode
useEffect(() => {
if (isStandalone && photoId && !standalonePhoto && eventToken) {
const fetchPhoto = async () => {
try {
const res = await fetch(`/api/v1/photos/${photoId}?locale=${encodeURIComponent(locale)}`, {
headers: {
Accept: 'application/json',
'X-Locale': locale,
},
});
if (res.ok) {
const fetchedPhoto: Photo = await res.json();
setStandalonePhoto(fetchedPhoto);
// Check state for initial photo
if (location.state?.photo) {
setStandalonePhoto(location.state.photo);
}
} else {
toast.push({ text: t('lightbox.errors.notFound'), type: 'error' });
}
} catch (err) {
console.warn('Standalone photo load failed', err);
toast.push({ text: t('lightbox.errors.loadFailed'), type: 'error' });
}
};
fetchPhoto();
}
}, [isStandalone, photoId, eventToken, standalonePhoto, location.state, t, locale, toast]);
// Update likes when photo changes
React.useEffect(() => {
if (photo) {
setLikes(photo.likes_count ?? 0);
// Check if liked from localStorage
try {
const raw = localStorage.getItem('liked-photo-ids');
const likedIds = raw ? JSON.parse(raw) : [];
setLiked(likedIds.includes(photo.id));
} catch {
setLiked(false);
}
}
}, [photo]);
const radius = branding.buttons?.radius ?? 12;
const bodyFont = branding.typography?.body ?? branding.fontFamily ?? null;
const headingFont = branding.typography?.heading ?? branding.fontFamily ?? null;
const zoomContainerRef = React.useRef<HTMLDivElement | null>(null);
const zoomImageRef = React.useRef<HTMLImageElement | null>(null);
const baseSizeRef = React.useRef({ width: 0, height: 0 });
const scaleRef = React.useRef(1);
const lastTapRef = React.useRef(0);
const [isZoomed, setIsZoomed] = React.useState(false);
const [{ x, y, scale }, api] = useSpring(() => ({
x: 0,
y: 0,
scale: 1,
config: { tension: 260, friction: 28 },
}));
const updateBaseSize = React.useCallback(() => {
if (!zoomImageRef.current) {
return;
}
const rect = zoomImageRef.current.getBoundingClientRect();
baseSizeRef.current = { width: rect.width, height: rect.height };
}, []);
React.useEffect(() => {
updateBaseSize();
}, [photo?.id, updateBaseSize]);
React.useEffect(() => {
window.addEventListener('resize', updateBaseSize);
return () => window.removeEventListener('resize', updateBaseSize);
}, [updateBaseSize]);
const clamp = React.useCallback((value: number, min: number, max: number) => {
return Math.min(max, Math.max(min, value));
}, []);
const getBounds = React.useCallback(
(nextScale: number) => {
const container = zoomContainerRef.current?.getBoundingClientRect();
const { width, height } = baseSizeRef.current;
if (!container || !width || !height) {
return { maxX: 0, maxY: 0 };
}
const scaledWidth = width * nextScale;
const scaledHeight = height * nextScale;
const maxX = Math.max(0, (scaledWidth - container.width) / 2);
const maxY = Math.max(0, (scaledHeight - container.height) / 2);
return { maxX, maxY };
},
[]
);
const resetZoom = React.useCallback(() => {
scaleRef.current = 1;
setIsZoomed(false);
api.start({ x: 0, y: 0, scale: 1 });
}, [api]);
React.useEffect(() => {
resetZoom();
}, [photo?.id, resetZoom]);
const toggleZoom = React.useCallback(() => {
const nextScale = scaleRef.current > 1.01 ? 1 : 2;
scaleRef.current = nextScale;
setIsZoomed(nextScale > 1.01);
api.start({ x: 0, y: 0, scale: nextScale });
}, [api]);
const bind = useGesture(
{
onDrag: ({ down, movement: [mx, my], offset: [ox, oy], last, event }) => {
if (event.cancelable) {
event.preventDefault();
}
const zoomed = scaleRef.current > 1.01;
if (!zoomed) {
api.start({ x: down ? mx : 0, y: 0, immediate: down });
if (last) {
api.start({ x: 0, y: 0, immediate: false });
const threshold = 80;
if (Math.abs(mx) > threshold) {
if (mx > 0 && currentIndexVal > 0) {
onIndexChange?.(currentIndexVal - 1);
} else if (mx < 0 && currentIndexVal < currentPhotos.length - 1) {
onIndexChange?.(currentIndexVal + 1);
}
}
}
return;
}
const { maxX, maxY } = getBounds(scaleRef.current);
api.start({
x: clamp(ox, -maxX, maxX),
y: clamp(oy, -maxY, maxY),
immediate: down,
});
},
onPinch: ({ offset: [nextScale], last, event }) => {
if (event.cancelable) {
event.preventDefault();
}
const clampedScale = clamp(nextScale, 1, 3);
scaleRef.current = clampedScale;
setIsZoomed(clampedScale > 1.01);
const { maxX, maxY } = getBounds(clampedScale);
api.start({
scale: clampedScale,
x: clamp(x.get(), -maxX, maxX),
y: clamp(y.get(), -maxY, maxY),
immediate: true,
});
if (last && clampedScale <= 1.01) {
resetZoom();
}
},
},
{
drag: {
from: () => [x.get(), y.get()],
filterTaps: true,
threshold: 4,
},
pinch: {
scaleBounds: { min: 1, max: 3 },
rubberband: true,
},
eventOptions: { passive: false },
}
);
const handlePointerUp = (event: React.PointerEvent) => {
if (event.pointerType !== 'touch') {
return;
}
const now = Date.now();
if (now - lastTapRef.current < 280) {
lastTapRef.current = 0;
toggleZoom();
return;
}
lastTapRef.current = now;
};
// Load task info if photo has task_id and event key is available
React.useEffect(() => {
if (!photo?.task_id || !eventToken) {
setTask(null);
setTaskLoading(false);
return;
}
const taskId = photo.task_id;
(async () => {
setTaskLoading(true);
try {
const res = await fetch(
`/api/v1/events/${encodeURIComponent(eventToken)}/tasks?locale=${encodeURIComponent(locale)}`,
{
headers: {
Accept: 'application/json',
'X-Locale': locale,
'X-Device-Id': getDeviceId(),
},
}
);
if (res.ok) {
const payload = (await res.json()) as unknown;
const tasks = Array.isArray(payload)
? payload
: Array.isArray((payload as any)?.data)
? (payload as any).data
: Array.isArray((payload as any)?.tasks)
? (payload as any).tasks
: [];
const foundTask = (tasks as Task[]).find((t) => t.id === taskId);
if (foundTask) {
setTask({
id: foundTask.id,
title: foundTask.title || t('lightbox.fallbackTitle').replace('{id}', `${taskId}`)
});
} else {
setTask({
id: taskId,
title: t('lightbox.unknownTitle').replace('{id}', `${taskId}`)
});
}
} else {
setTask({
id: taskId,
title: t('lightbox.unknownTitle').replace('{id}', `${taskId}`)
});
}
} catch (error) {
console.error('Failed to load task:', error);
setTask({
id: taskId,
title: t('lightbox.unknownTitle').replace('{id}', `${taskId}`)
});
} finally {
setTaskLoading(false);
}
})();
}, [photo?.task_id, eventToken, t, locale]);
async function onLike() {
if (liked || !photo) return;
setLiked(true);
try {
const count = await likePhoto(photo.id);
setLikes(count);
triggerHaptic('selection');
// Update localStorage
try {
const raw = localStorage.getItem('liked-photo-ids');
const arr: number[] = raw ? JSON.parse(raw) : [];
if (!arr.includes(photo.id)) {
localStorage.setItem('liked-photo-ids', JSON.stringify([...arr, photo.id]));
}
} catch (storageError) {
console.warn('Failed to persist liked photo IDs', storageError);
}
} catch (error) {
console.error('Like failed:', error);
setLiked(false);
}
}
const shareTitle = photo?.task_title ?? task?.title ?? t('share.title', 'Geteiltes Foto');
const shareText = t('share.shareText', { event: eventName ?? shareTitle ?? 'Fotospiel' });
const createdLabel = React.useMemo(() => {
if (!photo?.created_at) return null;
try {
const date = new Date(photo.created_at);
return date.toLocaleString(locale, { dateStyle: 'medium', timeStyle: 'short' });
} catch {
return null;
}
}, [photo?.created_at, locale]);
const uploaderInitial = React.useMemo(() => {
const name = photo?.uploader_name;
if (!name) return 'G';
return (name.trim()[0] || 'G').toUpperCase();
}, [photo?.uploader_name]);
const primaryColor = branding.primaryColor || '#0ea5e9';
const secondaryColor = branding.secondaryColor || '#6366f1';
async function openShareSheet() {
if (!photo || !eventToken) return;
setShareSheet({ url: null, loading: true });
try {
const payload = await createPhotoShareLink(eventToken, photo.id);
setShareSheet({ url: payload.url, loading: false });
} catch (error) {
console.error('share failed', error);
toast.push({ text: t('share.error', 'Teilen fehlgeschlagen'), type: 'error' });
setShareSheet({ url: null, loading: false });
}
}
function shareWhatsApp(url?: string | null) {
if (!url) return;
const waUrl = `https://wa.me/?text=${encodeURIComponent(`${shareText} ${url}`)}`;
window.open(waUrl, '_blank', 'noopener');
setShareSheet({ url: null, loading: false });
}
function shareMessages(url?: string | null) {
if (!url) return;
const smsUrl = `sms:?&body=${encodeURIComponent(`${shareText} ${url}`)}`;
window.open(smsUrl, '_blank', 'noopener');
setShareSheet({ url: null, loading: false });
}
function shareNative(url?: string | null) {
if (!url) return;
const data: ShareData = {
title: shareTitle,
text: shareText,
url,
};
if (navigator.share && (!navigator.canShare || navigator.canShare(data))) {
navigator.share(data).catch(() => {});
setShareSheet({ url: null, loading: false });
return;
}
void copyLink(url);
}
async function copyLink(url?: string | null) {
if (!url) return;
try {
await navigator.clipboard?.writeText(url);
toast.push({ text: t('share.copySuccess', 'Link kopiert!') });
} catch {
toast.push({ text: t('share.copyError', 'Link konnte nicht kopiert werden.'), type: 'error' });
} finally {
setShareSheet({ url: null, loading: false });
}
}
function closeShareSheet() {
setShareSheet({ url: null, loading: false });
}
function onOpenChange(open: boolean) {
if (!open) handleClose();
}
return (
<Dialog open={true} onOpenChange={onOpenChange}>
<DialogContent
hideClose
className="max-w-6xl overflow-hidden rounded-3xl border border-white/10 bg-white/5 p-0 text-white shadow-2xl backdrop-blur-3xl"
>
<div className="relative">
<div className="absolute inset-0 opacity-50" style={{ background: 'radial-gradient(circle at 20% 20%, rgba(255,255,255,0.12), transparent 40%), radial-gradient(circle at 80% 10%, rgba(255,255,255,0.1), transparent 35%)' }} />
<div className="absolute top-4 left-0 right-0 z-30 flex items-center justify-between px-5">
<div className="flex items-center gap-2 rounded-full border border-white/20 bg-black/40 px-2 py-1 shadow-lg backdrop-blur">
<Button variant="ghost" size="icon" onClick={onClose} className="h-10 w-10 rounded-full text-white hover:bg-white/10">
<X className="h-5 w-5" />
</Button>
<div className="rounded-full bg-white/10 px-3 py-1 text-xs font-semibold text-white/90">
{currentIndexVal + 1} / {currentPhotos.length}
</div>
</div>
<div className="flex items-center gap-2 rounded-full border border-white/20 bg-black/40 px-2 py-1 shadow-lg backdrop-blur">
<Button variant="ghost" size="icon" onClick={onLike} disabled={liked} className="h-10 w-10 rounded-full text-white hover:bg-white/10">
<Heart className={`h-5 w-5 ${liked ? 'fill-red-500 text-red-500' : ''}`} />
</Button>
<Button
variant="ghost"
size="icon"
onClick={openShareSheet}
disabled={!eventToken || !photo}
className="h-10 w-10 rounded-full text-white hover:bg-white/10"
>
<Share2 className="h-5 w-5" />
</Button>
</div>
</div>
<div className="px-3 pb-5 pt-16">
<div
ref={zoomContainerRef}
className="relative flex min-h-[60vh] items-center justify-center overflow-hidden rounded-[30px] border border-white/15 bg-black/30 p-4 shadow-xl backdrop-blur"
data-zoomed={isZoomed ? 'true' : 'false'}
>
{currentIndexVal > 0 && (
<Button
variant="ghost"
size="icon"
onClick={() => onIndexChange?.(currentIndexVal - 1)}
className="absolute left-3 top-1/2 -translate-y-1/2 rounded-full bg-white/90 text-slate-800 shadow-lg hover:bg-white"
>
<ChevronLeft className="h-5 w-5" />
</Button>
)}
<animated.div
{...bind()}
onDoubleClick={toggleZoom}
onPointerUp={handlePointerUp}
data-testid="lightbox-zoom"
className="touch-none"
style={{
transform: to(
[x, y, scale],
(xValue, yValue, scaleValue) =>
`translate3d(${xValue}px, ${yValue}px, 0) scale(${scaleValue})`
),
}}
>
<img
ref={zoomImageRef}
src={photo?.file_path || photo?.thumbnail_path}
alt={t('lightbox.photoAlt')
.replace('{id}', `${photo?.id ?? ''}`)
.replace(
'{suffix}',
photo?.task_title
? t('lightbox.photoAltTaskSuffix').replace('{taskTitle}', photo.task_title)
: ''
)}
className="max-h-[70vh] max-w-full select-none object-contain"
onLoad={updateBaseSize}
onError={(e) => {
console.error('Image load error:', e);
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
</animated.div>
{currentIndexVal < currentPhotos.length - 1 && (
<Button
variant="ghost"
size="icon"
onClick={() => onIndexChange?.(currentIndexVal + 1)}
className="absolute right-3 top-1/2 -translate-y-1/2 rounded-full bg-white/90 text-slate-800 shadow-lg hover:bg-white"
>
<ChevronRight className="h-5 w-5" />
</Button>
)}
</div>
<div className="mt-5 grid gap-3 rounded-2xl border border-white/15 bg-black/35 px-4 py-3 text-sm text-white/90 shadow-md backdrop-blur sm:grid-cols-[1.3fr_1fr]">
<div className="flex items-center gap-3">
<Avatar className="h-11 w-11 border border-white/20 bg-white/10">
<AvatarFallback className="text-white">{uploaderInitial}</AvatarFallback>
</Avatar>
<div className="space-y-1">
{photo?.uploader_name ? (
<p className="font-semibold text-white">{photo.uploader_name}</p>
) : (
<p className="font-semibold text-white">{t('galleryPage.photo.anonymous', 'Gast')}</p>
)}
{createdLabel ? <p className="text-xs text-white/70">{createdLabel}</p> : null}
</div>
</div>
<div className="flex flex-wrap items-center justify-start gap-2 sm:justify-end">
{task ? (
<Badge variant="outline" className="border-white/30 bg-white/10 text-white">
{t('lightbox.taskLabel')}: {task.title}
</Badge>
) : null}
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
onClick={onLike}
disabled={liked}
className="h-9 w-9 rounded-full text-white hover:bg-white/10"
aria-label={t('lightbox.like', 'Like photo')}
>
<Heart className={`h-5 w-5 ${liked ? 'fill-red-500 text-red-500' : ''}`} />
</Button>
<Button
variant="ghost"
size="icon"
onClick={openShareSheet}
disabled={!eventToken || !photo}
className="h-9 w-9 rounded-full text-white hover:bg-white/10"
aria-label={t('share.button', 'Teilen')}
>
<Share2 className="h-5 w-5" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => {
if (!photo?.file_path) return;
window.open(photo.file_path, '_blank', 'noopener');
}}
className="h-9 w-9 rounded-full text-white hover:bg-white/10"
aria-label={t('lightbox.download', 'Download')}
disabled={!photo?.file_path}
>
<Download className="h-5 w-5" />
</Button>
</div>
</div>
</div>
{taskLoading && !task && (
<div className="mt-4 rounded-xl border border-white/20 bg-black/40 p-3 text-center text-xs text-white/80 shadow-sm backdrop-blur">
<div className="mx-auto mb-1 h-4 w-4 animate-spin rounded-full border-b-2 border-white/70" />
{t('lightbox.loadingTask')}
</div>
)}
</div>
</div>
<ShareSheet
open={shareSheet.loading || Boolean(shareSheet.url)}
photoId={photo?.id}
eventName={eventName ?? null}
url={shareSheet.url}
loading={shareSheet.loading}
onClose={closeShareSheet}
onShareNative={() => shareNative(shareSheet.url)}
onShareWhatsApp={() => shareWhatsApp(shareSheet.url)}
onShareMessages={() => shareMessages(shareSheet.url)}
onCopyLink={() => copyLink(shareSheet.url)}
radius={radius}
bodyFont={bodyFont}
headingFont={headingFont}
/>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,112 +0,0 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useEventData } from '../hooks/useEventData';
import { useGuestIdentity } from '../context/GuestIdentityContext';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { useTranslation } from '../i18n/useTranslation';
import { motion } from 'framer-motion';
import { FADE_SCALE, FADE_UP, STAGGER_FAST, getMotionContainerProps, getMotionItemProps, prefersReducedMotion } from '../lib/motion';
export default function ProfileSetupPage() {
const { token } = useParams<{ token: string }>();
const nav = useNavigate();
const { event, loading, error } = useEventData();
const { name: storedName, setName: persistName, hydrated } = useGuestIdentity();
const [name, setName] = useState(storedName);
const [submitting, setSubmitting] = useState(false);
const { t } = useTranslation();
const motionEnabled = !prefersReducedMotion();
const containerMotion = getMotionContainerProps(motionEnabled, STAGGER_FAST);
const fadeUpMotion = getMotionItemProps(motionEnabled, FADE_UP);
const fadeScaleMotion = getMotionItemProps(motionEnabled, FADE_SCALE);
useEffect(() => {
if (!token) {
nav('/');
return;
}
}, [token, nav]);
useEffect(() => {
if (hydrated) {
setName(storedName);
}
}, [hydrated, storedName]);
function handleChange(value: string) {
setName(value);
}
function submitName() {
if (!token) return;
const trimmedName = name.trim();
if (!trimmedName) return;
setSubmitting(true);
try {
persistName(trimmedName);
nav(`/e/${token}`);
} catch (e) {
console.error('Fehler beim Speichern des Namens:', e);
setSubmitting(false);
}
}
if (loading) {
return (
<div className="flex justify-center items-center h-32">
<div className="text-lg">{t('profileSetup.loading')}</div>
</div>
);
}
if (error || !event) {
return (
<div className="text-center p-4">
<p className="text-red-600 mb-4">{error || t('profileSetup.error.default')}</p>
<Button onClick={() => nav('/')}>{t('profileSetup.error.backToStart')}</Button>
</div>
);
}
return (
<motion.div className="min-h-screen bg-gradient-to-b from-pink-50 to-purple-50 flex flex-col" {...containerMotion}>
<motion.div className="flex-1 flex flex-col justify-center items-center px-4 py-8" {...fadeUpMotion}>
<motion.div {...fadeScaleMotion} className="w-full max-w-md">
<Card>
<CardHeader className="text-center space-y-2">
<CardTitle className="text-2xl font-bold text-gray-900">{event.name}</CardTitle>
<CardDescription className="text-lg text-gray-600">
{t('profileSetup.card.description')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4 p-6">
<div className="space-y-2">
<Label htmlFor="name" className="text-sm font-medium">{t('profileSetup.form.label')}</Label>
<Input
id="name"
value={name}
onChange={(e) => handleChange(e.target.value)}
placeholder={t('profileSetup.form.placeholder')}
className="text-lg"
disabled={submitting || !hydrated}
autoComplete="name"
/>
</div>
<Button
className="w-full bg-gradient-to-r from-pink-500 to-pink-600 hover:from-pink-600 hover:to-pink-700 text-white py-3 text-base font-semibold rounded-xl"
onClick={submitName}
disabled={submitting || !name.trim() || !hydrated}
>
{submitting ? t('profileSetup.form.submitting') : t('profileSetup.form.submit')}
</Button>
</CardContent>
</Card>
</motion.div>
</motion.div>
</motion.div>
);
}

View File

@@ -1,44 +0,0 @@
import React from 'react';
import { Page } from './_util';
import { useTranslation } from '../i18n/useTranslation';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Switch } from '@/components/ui/switch';
import { useHapticsPreference } from '../hooks/useHapticsPreference';
import { triggerHaptic } from '../lib/haptics';
export default function SettingsPage() {
const { t } = useTranslation();
const { enabled: hapticsEnabled, setEnabled: setHapticsEnabled, supported: hapticsSupported } = useHapticsPreference();
return (
<Page title={t('settings.title')}>
<p className="text-sm text-muted-foreground">{t('settings.subtitle')}</p>
<div className="mt-4 space-y-4">
<Card>
<CardHeader className="pb-3">
<CardTitle>{t('settings.haptics.title')}</CardTitle>
<CardDescription>{t('settings.haptics.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
<div className="flex items-center justify-between gap-4">
<span className="text-sm font-medium">{t('settings.haptics.label')}</span>
<Switch
checked={hapticsEnabled}
onCheckedChange={(checked) => {
setHapticsEnabled(checked);
if (checked) {
triggerHaptic('selection');
}
}}
disabled={!hapticsSupported}
aria-label={t('settings.haptics.label')}
/>
</div>
{!hapticsSupported && (
<div className="text-xs text-muted-foreground">{t('settings.haptics.unsupported')}</div>
)}
</CardContent>
</Card>
</div>
</Page>
);
}

View File

@@ -1,11 +0,0 @@
import React from 'react';
import { Page } from './_util';
export default function TaskDetailPage() {
return (
<Page title="Aufgaben-Detail">
<p>Aufgabenbeschreibung, Dauer, Gruppengröße.</p>
</Page>
);
}

View File

@@ -1,799 +0,0 @@
import React from 'react';
import { useNavigate, useNavigationType, useParams, useSearchParams } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Sparkles, RefreshCw, Smile, Camera, Timer as TimerIcon, Heart, ChevronRight, CheckCircle2 } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { useGuestTaskProgress } from '../hooks/useGuestTaskProgress';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import { cn } from '@/lib/utils';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { useEventBranding } from '../context/EventBrandingContext';
import { useTranslation, type TranslateFn } from '../i18n/useTranslation';
import { motion } from 'framer-motion';
import {
getEmotionIcon,
getEmotionTheme,
type EmotionIdentity,
type EmotionTheme,
} from '../lib/emotionTheme';
import { getDeviceId } from '../lib/device';
import { FADE_SCALE, FADE_UP, STAGGER_FAST, getMotionContainerPropsForNavigation, getMotionItemProps, prefersReducedMotion } from '../lib/motion';
import PullToRefresh from '../components/PullToRefresh';
import { triggerHaptic } from '../lib/haptics';
import { dedupeTasksById } from '../lib/taskUtils';
interface Task {
id: number;
title: string;
description: string;
instructions: string;
duration: number; // minutes
emotion?: {
slug: string;
name: string;
};
is_completed: boolean;
}
type EmotionOption = {
slug: string;
name: string;
};
type EventPhoto = {
id: number;
thumbnail_path?: string | null;
file_path?: string | null;
likes_count?: number | null;
task_id?: number | null;
};
const SWIPE_THRESHOLD_PX = 40;
const SIMILAR_PHOTO_LIMIT = 6;
export default function TaskPickerPage() {
const { token } = useParams<{ token: string }>();
const eventKey = token ?? '';
const navigate = useNavigate();
const navigationType = useNavigationType();
const [searchParams, setSearchParams] = useSearchParams();
const { branding } = useEventBranding();
const { t, locale } = useTranslation();
const radius = branding.buttons?.radius ?? 12;
const buttonStyle = branding.buttons?.style ?? 'filled';
const linkColor = branding.buttons?.linkColor ?? branding.secondaryColor;
const bodyFont = branding.typography?.body ?? branding.fontFamily ?? undefined;
const headingFont = branding.typography?.heading ?? branding.fontFamily ?? undefined;
const { isCompleted } = useGuestTaskProgress(eventKey);
const [tasks, setTasks] = React.useState<Task[]>([]);
const [currentTask, setCurrentTask] = React.useState<Task | null>(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [selectedEmotion, setSelectedEmotion] = React.useState<string>('all');
const [isFetching, setIsFetching] = React.useState(false);
const [photoPool, setPhotoPool] = React.useState<EventPhoto[]>([]);
const [photoPoolLoading, setPhotoPoolLoading] = React.useState(false);
const [photoPoolError, setPhotoPoolError] = React.useState<string | null>(null);
const [hasSwiped, setHasSwiped] = React.useState(false);
const [emotionPickerOpen, setEmotionPickerOpen] = React.useState(false);
const [recentEmotionSlug, setRecentEmotionSlug] = React.useState<string | null>(null);
const heroCardRef = React.useRef<HTMLDivElement | null>(null);
const cameraButtonStyle = React.useMemo(() => ({
background: `radial-gradient(circle at 20% 20%, ${branding.secondaryColor}, ${branding.primaryColor})`,
boxShadow: `0 18px 30px ${branding.primaryColor}44`,
color: '#ffffff',
}), [branding.primaryColor, branding.secondaryColor]);
const recentTaskIdsRef = React.useRef<number[]>([]);
const tasksCacheRef = React.useRef<Map<string, { data: Task[]; etag?: string | null }>>(new Map());
const initialEmotionRef = React.useRef(false);
const fetchTasks = React.useCallback(async () => {
if (!eventKey) return;
const cacheKey = `${eventKey}:${locale}`;
const cached = tasksCacheRef.current.get(cacheKey);
setIsFetching(true);
setLoading(!cached);
setError(null);
if (cached) {
setTasks(cached.data);
}
try {
const headers: HeadersInit = {
Accept: 'application/json',
'X-Locale': locale,
'X-Device-Id': getDeviceId(),
};
if (cached?.etag) {
headers['If-None-Match'] = cached.etag;
}
const response = await fetch(
`/api/v1/events/${encodeURIComponent(eventKey)}/tasks?locale=${encodeURIComponent(locale)}`,
{ headers }
);
if (response.status === 304 && cached) {
return;
}
if (!response.ok) throw new Error('Aufgaben konnten nicht geladen werden.');
const payload = await response.json();
const taskList: Task[] = Array.isArray(payload)
? payload
: Array.isArray(payload?.data)
? payload.data
: Array.isArray(payload?.tasks)
? payload.tasks
: [];
const uniqueTasks = dedupeTasksById(taskList);
const entry = { data: uniqueTasks, etag: response.headers.get('ETag') };
tasksCacheRef.current.set(cacheKey, entry);
setTasks(uniqueTasks);
} catch (err) {
console.error('Failed to load tasks', err);
setError(err instanceof Error ? err.message : 'Unbekannter Fehler');
if (!cached) {
setTasks([]);
}
} finally {
setIsFetching(false);
setLoading(false);
}
}, [eventKey, locale]);
React.useEffect(() => {
fetchTasks();
}, [fetchTasks]);
React.useEffect(() => {
if (initialEmotionRef.current) return;
const queryEmotion = searchParams.get('emotion');
if (queryEmotion) {
setSelectedEmotion(queryEmotion);
}
initialEmotionRef.current = true;
}, [searchParams]);
const emotionOptions = React.useMemo<EmotionOption[]>(() => {
const map = new Map<string, string>();
tasks.forEach((task) => {
if (task.emotion?.slug) {
map.set(task.emotion.slug, task.emotion.name);
}
});
return Array.from(map.entries()).map(([slugValue, name]) => ({ slug: slugValue, name }));
}, [tasks]);
const emotionCounts = React.useMemo(() => {
const map = new Map<string, number>();
tasks.forEach((task) => {
const slugValue = task.emotion?.slug;
if (!slugValue) return;
map.set(slugValue, (map.get(slugValue) ?? 0) + 1);
});
return map;
}, [tasks]);
const filteredTasks = React.useMemo(() => {
if (selectedEmotion === 'all') return tasks;
return tasks.filter((task) => task.emotion?.slug === selectedEmotion);
}, [tasks, selectedEmotion]);
const alternativeTasks = React.useMemo(() => {
return filteredTasks.filter((task) => task.id !== currentTask?.id).slice(0, 6);
}, [filteredTasks, currentTask]);
const selectRandomTask = React.useCallback(
(list: Task[]) => {
if (!list.length) {
setCurrentTask(null);
return;
}
const avoidIds = recentTaskIdsRef.current;
const available = list.filter((task) => !isCompleted(task.id));
const base = available.length ? available : list;
let candidates = base.filter((task) => !avoidIds.includes(task.id));
if (!candidates.length) {
candidates = base;
}
const chosen = candidates[Math.floor(Math.random() * candidates.length)];
setCurrentTask(chosen);
recentTaskIdsRef.current = [...avoidIds.filter((id) => id !== chosen.id), chosen.id].slice(-3);
},
[isCompleted]
);
const handleSelectEmotion = React.useCallback(
(slugValue: string) => {
setSelectedEmotion(slugValue);
const next = new URLSearchParams(searchParams.toString());
if (slugValue === 'all') {
next.delete('emotion');
} else {
next.set('emotion', slugValue);
setRecentEmotionSlug(slugValue);
}
setSearchParams(next, { replace: true });
},
[searchParams, setSearchParams]
);
const handleNewTask = React.useCallback(() => {
selectRandomTask(filteredTasks);
triggerHaptic('selection');
}, [filteredTasks, selectRandomTask]);
const handleStartUpload = () => {
if (!currentTask || !eventKey) return;
triggerHaptic('light');
navigate(`/e/${encodeURIComponent(eventKey)}/upload?task=${currentTask.id}&emotion=${currentTask.emotion?.slug || ''}`);
};
const handleViewSimilar = React.useCallback(() => {
if (!currentTask || !eventKey) return;
navigate(`/e/${encodeURIComponent(eventKey)}/gallery?task=${currentTask.id}`);
}, [currentTask, eventKey, navigate]);
const handleSelectTask = React.useCallback((task: Task) => {
setCurrentTask(task);
triggerHaptic('selection');
}, []);
const handleRetryFetch = () => {
fetchTasks();
};
const handleRefresh = React.useCallback(async () => {
tasksCacheRef.current.clear();
await fetchTasks();
setPhotoPool([]);
setPhotoPoolError(null);
}, [fetchTasks]);
const handlePhotoPreview = React.useCallback(
(photoId: number) => {
if (!eventKey) return;
navigate(`/e/${encodeURIComponent(eventKey)}/gallery?photoId=${photoId}&task=${currentTask?.id ?? ''}`);
},
[eventKey, navigate, currentTask?.id]
);
React.useEffect(() => {
if (!filteredTasks.length) {
setCurrentTask(null);
return;
}
if (!currentTask || !filteredTasks.some((task) => task.id === currentTask.id)) {
selectRandomTask(filteredTasks);
}
}, [filteredTasks, currentTask, selectRandomTask]);
React.useEffect(() => {
if (currentTask?.emotion?.slug) {
setRecentEmotionSlug(currentTask.emotion.slug);
}
}, [currentTask?.emotion?.slug]);
React.useEffect(() => {
if (!eventKey || photoPool.length) return;
const controller = new AbortController();
setPhotoPoolLoading(true);
setPhotoPoolError(null);
fetch(`/api/v1/events/${encodeURIComponent(eventKey)}/photos?locale=${encodeURIComponent(locale)}`, {
signal: controller.signal,
headers: {
Accept: 'application/json',
'X-Locale': locale,
},
})
.then((res) => {
if (!res.ok) {
throw new Error(t('tasks.page.inspirationError'));
}
return res.json();
})
.then((payload) => {
const data = Array.isArray(payload?.data) ? (payload.data as EventPhoto[]) : [];
setPhotoPool(data);
})
.catch((err) => {
if (controller.signal.aborted) return;
console.error('Failed to load photos', err);
setPhotoPoolError(t('tasks.page.inspirationError'));
})
.finally(() => {
if (!controller.signal.aborted) {
setPhotoPoolLoading(false);
}
});
return () => controller.abort();
}, [eventKey, photoPool.length, t, locale]);
const similarPhotos = React.useMemo(() => {
if (!currentTask) return [];
const matches = photoPool.filter((photo) => photo.task_id === currentTask.id);
return matches.slice(0, SIMILAR_PHOTO_LIMIT);
}, [photoPool, currentTask]);
React.useEffect(() => {
const card = heroCardRef.current;
if (!card) return;
let startX: number | null = null;
let startY: number | null = null;
const onTouchStart = (event: TouchEvent) => {
const touch = event.touches[0];
startX = touch.clientX;
startY = touch.clientY;
};
const onTouchEnd = (event: TouchEvent) => {
if (startX === null || startY === null) return;
const touch = event.changedTouches[0];
const deltaX = touch.clientX - startX;
const deltaY = touch.clientY - startY;
if (Math.abs(deltaX) > SWIPE_THRESHOLD_PX && Math.abs(deltaY) < 60) {
if (deltaX < 0) {
handleNewTask();
} else {
handleViewSimilar();
}
setHasSwiped(true);
}
startX = null;
startY = null;
};
card.addEventListener('touchstart', onTouchStart, { passive: true });
card.addEventListener('touchend', onTouchEnd);
return () => {
card.removeEventListener('touchstart', onTouchStart);
card.removeEventListener('touchend', onTouchEnd);
};
}, [handleNewTask, handleViewSimilar]);
const emptyState = !loading && (!filteredTasks.length || !currentTask);
const heroTheme = React.useMemo(() => getEmotionTheme(currentTask?.emotion ?? null), [currentTask?.emotion]);
const heroEmotionIcon = getEmotionIcon(currentTask?.emotion ?? null);
const recentEmotionOption = React.useMemo(
() => emotionOptions.find((option) => option.slug === recentEmotionSlug) ?? null,
[emotionOptions, recentEmotionSlug]
);
const toggleValue = selectedEmotion === 'all' ? 'none' : 'recent';
const motionEnabled = !prefersReducedMotion();
const containerMotion = getMotionContainerPropsForNavigation(motionEnabled, STAGGER_FAST, navigationType);
const fadeUpMotion = getMotionItemProps(motionEnabled, FADE_UP);
const fadeScaleMotion = getMotionItemProps(motionEnabled, FADE_SCALE);
const handleToggleChange = React.useCallback(
(value: string) => {
if (!value) return;
if (value === 'picker') {
setEmotionPickerOpen(true);
return;
}
if (value === 'none') {
handleSelectEmotion('all');
return;
}
if (value === 'recent') {
if (recentEmotionSlug) {
handleSelectEmotion(recentEmotionSlug);
} else {
setEmotionPickerOpen(true);
}
}
},
[handleSelectEmotion, recentEmotionSlug]
);
return (
<>
<PullToRefresh
onRefresh={handleRefresh}
pullLabel={t('common.pullToRefresh')}
releaseLabel={t('common.releaseToRefresh')}
refreshingLabel={t('common.refreshing')}
>
<motion.div className="space-y-6" {...containerMotion}>
<motion.header className="space-y-4" {...fadeUpMotion}>
<div className="space-y-1">
<p className="text-[11px] uppercase tracking-[0.4em] text-muted-foreground">{t('tasks.page.eyebrow')}</p>
<h1 className="text-2xl font-semibold text-foreground">{t('tasks.page.title')}</h1>
<p className="text-sm text-muted-foreground">{t('tasks.page.subtitle')}</p>
</div>
{emotionOptions.length > 0 && (
<motion.div className="overflow-x-auto pb-1 [-ms-overflow-style:none] [scrollbar-width:none]" {...fadeUpMotion}>
<ToggleGroup
type="single"
aria-label="Stimmung filtern"
value={toggleValue}
onValueChange={handleToggleChange}
className="inline-flex gap-1 rounded-full bg-muted/60 p-1"
variant="outline"
size="sm"
>
<ToggleGroupItem
value="none"
className="rounded-full !rounded-full px-4 py-2 text-sm font-medium first:!rounded-full last:!rounded-full"
>
<span className="mr-2">🎲</span>
{t('tasks.page.filters.none')}
</ToggleGroupItem>
<ToggleGroupItem
value="recent"
disabled={!recentEmotionOption}
className="rounded-full !rounded-full px-4 py-2 text-sm font-medium first:!rounded-full last:!rounded-full"
>
<span className="mr-2">{getEmotionIcon(recentEmotionOption)}</span>
{recentEmotionOption?.name ?? t('tasks.page.filters.recentFallback')}
</ToggleGroupItem>
<ToggleGroupItem
value="picker"
className="rounded-full !rounded-full px-4 py-2 text-sm font-medium first:!rounded-full last:!rounded-full"
>
<span className="mr-2">🗂</span>
{t('tasks.page.filters.showAll')}
</ToggleGroupItem>
</ToggleGroup>
</motion.div>
)}
</motion.header>
{loading && (
<motion.div className="space-y-4" {...fadeUpMotion}>
<SkeletonBlock />
<SkeletonBlock />
</motion.div>
)}
{error && !loading && (
<motion.div {...fadeUpMotion}>
<Alert variant="destructive">
<AlertDescription className="flex items-center justify-between gap-3">
<span>{error}</span>
<Button variant="outline" size="sm" onClick={handleRetryFetch} disabled={isFetching}>
Erneut versuchen
</Button>
</AlertDescription>
</Alert>
</motion.div>
)}
{emptyState && (
<motion.div {...fadeUpMotion}>
<EmptyState
hasTasks={Boolean(tasks.length)}
onRetry={handleRetryFetch}
emotionOptions={emotionOptions}
onEmotionSelect={handleSelectEmotion}
t={t}
/>
</motion.div>
)}
{!emptyState && currentTask && (
<motion.div className="space-y-8" {...fadeUpMotion}>
<motion.section
ref={heroCardRef}
className={cn(
'relative overflow-hidden rounded-3xl p-5 text-white shadow-lg transition-[background] duration-700',
'bg-gradient-to-br',
heroTheme.gradientClass
)}
style={{ background: heroTheme.gradientBackground }}
{...fadeScaleMotion}
>
<div className="relative space-y-5">
<div className="flex flex-wrap items-center justify-between gap-3 text-xs font-semibold uppercase tracking-[0.25em] text-white/70">
<span className="flex items-center gap-2 tracking-[0.3em]">
<Sparkles className="h-4 w-4" />
{heroEmotionIcon} {currentTask.emotion?.name ?? 'Neue Mission'}
</span>
<span className="flex items-center gap-1 text-white tracking-normal">
<TimerIcon className="h-4 w-4" />
{currentTask.duration} Min
</span>
</div>
<div className="space-y-3">
<h2 className="text-3xl font-semibold leading-tight drop-shadow-sm">{currentTask.title}</h2>
<p className="text-sm leading-relaxed text-white/80">{currentTask.description}</p>
</div>
{!hasSwiped && (
<p className="text-[11px] uppercase tracking-[0.4em] text-white/70">
{t('tasks.page.swipeHint')}
</p>
)}
{currentTask.instructions && (
<div className="rounded-2xl bg-white/15 p-3 text-sm font-medium text-white/90">
{currentTask.instructions}
</div>
)}
<div className="flex flex-wrap gap-2 text-sm text-white/80">
{isCompleted(currentTask.id) && (
<span className="rounded-full border border-white/30 px-3 py-1">
<CheckCircle2 className="mr-1 inline h-4 w-4" />
{t('tasks.page.completedLabel')}
</span>
)}
</div>
<div className="grid grid-cols-3 gap-3">
<Button
onClick={handleStartUpload}
className="col-span-2 flex h-14 items-center justify-center gap-2 rounded-2xl text-base font-semibold text-white shadow-lg shadow-black/10 transition hover:scale-[1.01]"
style={cameraButtonStyle}
>
<Camera className="h-6 w-6" />
{t('tasks.page.ctaStart')}
</Button>
<HeroActionButton
icon={RefreshCw}
label={t('tasks.page.shuffleCta')}
onClick={handleNewTask}
className="h-12 justify-center px-3 py-2"
/>
</div>
{(photoPoolLoading || photoPoolError || similarPhotos.length > 0) && (
<div className="space-y-2 rounded-2xl border border-white/25 bg-white/10 p-3">
<div className="flex items-center justify-between text-xs font-semibold uppercase tracking-[0.3em] text-white/70">
<span>{t('tasks.page.inspirationTitle')}</span>
{photoPoolLoading && <span className="text-[10px] text-white/70">{t('tasks.page.inspirationLoading')}</span>}
</div>
{photoPoolError && similarPhotos.length === 0 ? (
<p className="text-xs text-white/80">{photoPoolError}</p>
) : similarPhotos.length > 0 ? (
<div className="flex gap-3 overflow-x-auto pb-2 [-ms-overflow-style:none] [scrollbar-width:none]">
{similarPhotos.map((photo) => (
<SimilarPhotoChip key={photo.id} photo={photo} onOpen={handlePhotoPreview} />
))}
<button
type="button"
onClick={handleViewSimilar}
className="flex h-16 min-w-[64px] flex-col items-center justify-center rounded-2xl border border-dashed border-white/40 px-3 text-center text-[11px] font-semibold uppercase tracking-[0.3em] text-white/80"
>
{t('tasks.page.inspirationMore')}
</button>
</div>
) : (
<button
type="button"
onClick={handleStartUpload}
className="flex items-center justify-between rounded-2xl border border-white/30 bg-white/10 px-4 py-3 text-sm text-white/80 transition hover:bg-white/20"
>
<div className="text-left">
<p className="font-semibold">{t('tasks.page.inspirationEmptyTitle')}</p>
<p className="text-xs text-white/70">{t('tasks.page.inspirationEmptyDescription')}</p>
</div>
<Camera className="h-4 w-4" />
</button>
)}
</div>
)}
</div>
</motion.section>
{alternativeTasks.length > 0 && (
<motion.section className="space-y-3" {...fadeUpMotion}>
<div className="flex items-center justify-between">
<div>
<p className="text-sm uppercase tracking-[0.2em] text-muted-foreground">{t('tasks.page.suggestionsEyebrow')}</p>
<h2 className="text-lg font-semibold text-foreground">{t('tasks.page.suggestionsTitle')}</h2>
</div>
<Button variant="outline" size="sm" onClick={handleNewTask} className="shrink-0">
{t('tasks.page.shuffleButton')}
</Button>
</div>
<div className="flex gap-3 overflow-x-auto pb-2 [-ms-overflow-style:none] [scrollbar-width:none]">
{alternativeTasks.map((task) => (
<TaskSuggestionCard key={task.id} task={task} onSelect={handleSelectTask} />
))}
</div>
</motion.section>
)}
</motion.div>
)}
{!loading && !tasks.length && !error && (
<motion.div {...fadeUpMotion}>
<Alert>
<AlertDescription>{t('tasks.page.noTasksAlert')}</AlertDescription>
</Alert>
</motion.div>
)}
</motion.div>
</PullToRefresh>
<Dialog open={emotionPickerOpen} onOpenChange={setEmotionPickerOpen}>
<DialogContent className="max-h-[90vh] overflow-y-auto" hideClose>
<DialogHeader>
<DialogTitle>{t('tasks.page.filters.dialogTitle')}</DialogTitle>
</DialogHeader>
{emotionOptions.length ? (
<div className="grid gap-3 sm:grid-cols-2">
{emotionOptions.map((emotion) => {
const count = emotionCounts.get(emotion.slug) ?? 0;
return (
<button
key={emotion.slug}
type="button"
onClick={() => {
handleSelectEmotion(emotion.slug);
setEmotionPickerOpen(false);
}}
className="flex items-center gap-3 rounded-2xl border border-muted/50 px-4 py-3 text-left transition hover:border-pink-300"
>
<span className="text-2xl" aria-hidden>
{getEmotionIcon(emotion)}
</span>
<div>
<p className="font-semibold text-foreground">{emotion.name}</p>
<p className="text-xs text-muted-foreground">
{count === 1
? t('tasks.page.filters.countOne', { count })
: t('tasks.page.filters.countMany', { count })}
</p>
</div>
</button>
);
})}
</div>
) : (
<p className="text-sm text-muted-foreground">{t('tasks.page.filters.empty')}</p>
)}
</DialogContent>
</Dialog>
</>
);
}
function SkeletonBlock() {
return <div className="h-24 animate-pulse rounded-xl bg-muted/60" />;
}
function EmptyState({
hasTasks,
onRetry,
emotionOptions,
onEmotionSelect,
t,
}: {
hasTasks: boolean;
onRetry: () => void;
emotionOptions: EmotionOption[];
onEmotionSelect: (slug: string) => void;
t: TranslateFn;
}) {
return (
<div className="flex flex-col items-center justify-center gap-4 rounded-2xl border border-dashed border-muted-foreground/30 bg-muted/20 p-8 text-center">
<Smile className="h-12 w-12 text-pink-500" aria-hidden />
<div className="space-y-2">
<h2 className="text-xl font-semibold">{t('tasks.page.emptyTitle')}</h2>
<p className="text-sm text-muted-foreground">
{hasTasks ? t('tasks.page.emptyDescriptionWithTasks') : t('tasks.page.emptyDescriptionNoTasks')}
</p>
</div>
{hasTasks && emotionOptions.length > 0 && (
<div className="grid w-full max-w-md grid-cols-2 gap-2 sm:grid-cols-3">
{emotionOptions.map((emotion) => (
<button
key={emotion.slug}
type="button"
onClick={() => onEmotionSelect(emotion.slug)}
className="rounded-full border border-border px-4 py-1 text-sm text-muted-foreground transition hover:border-pink-400 hover:text-foreground"
>
{emotion.name}
</button>
))}
</div>
)}
<Button onClick={onRetry} variant="outline" className="mt-2">
{t('tasks.page.reloadButton')}
</Button>
</div>
);
}
function HeroActionButton({
icon: Icon,
label,
detail,
onClick,
className,
}: {
icon: LucideIcon;
label: string;
detail?: string;
onClick: () => void;
className?: string;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn('flex flex-col rounded-2xl border border-white/30 bg-white/10 px-4 py-3 text-left text-sm font-medium text-white transition hover:bg-white/20', className)}
>
<span className="flex items-center gap-2 text-base font-semibold">
<Icon className="h-4 w-4" />
{label}
</span>
{detail && <span className="mt-1 text-xs text-white/80">{detail}</span>}
</button>
);
}
function SimilarPhotoChip({ photo, onOpen }: { photo: EventPhoto; onOpen: (photoId: number) => void }) {
const cover = photo.thumbnail_path || photo.file_path || '';
return (
<button
type="button"
onClick={() => onOpen(photo.id)}
className="relative h-16 w-16 overflow-hidden rounded-2xl border border-white/30 bg-white/10"
>
{cover ? (
<img
src={cover}
alt="Eventfoto"
className="h-full w-full object-cover"
loading="lazy"
decoding="async"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-[10px] font-semibold uppercase tracking-[0.3em] text-white/70">
Foto
</div>
)}
<div className="absolute bottom-1 left-1 flex items-center gap-1 rounded-full bg-black/60 px-1.5 py-0.5 text-[10px] font-semibold text-white">
<Heart className="h-3 w-3" />
<span>{photo.likes_count ?? 0}</span>
</div>
</button>
);
}
function TaskSuggestionCard({ task, onSelect }: { task: Task; onSelect: (task: Task) => void }) {
const theme = getEmotionTheme(task.emotion ?? null);
const emotionIcon = getEmotionIcon(task.emotion ?? null);
return (
<button
type="button"
onClick={() => onSelect(task)}
className={cn(
'group flex min-w-[220px] flex-col justify-between rounded-2xl border p-4 text-left shadow-sm transition hover:-translate-y-0.5 hover:shadow-md',
'bg-gradient-to-br text-gray-900 dark:text-white',
theme.suggestionGradient,
theme.suggestionBorder
)}
>
<div className="space-y-2">
<p className="text-xs uppercase tracking-[0.3em] text-gray-500 group-hover:text-gray-700 dark:text-gray-300">
{emotionIcon} {task.emotion?.name ?? 'Aufgabe'}
</p>
<h3 className="text-base font-semibold leading-tight line-clamp-2">{task.title}</h3>
<p className="text-xs text-gray-600 line-clamp-2 dark:text-gray-200">{task.description}</p>
</div>
<div className="mt-3 flex items-center justify-between text-xs font-semibold">
<span>{task.duration} Min</span>
<span className="flex items-center gap-1 text-pink-600 dark:text-pink-200">
Starten
<ChevronRight className="h-3.5 w-3.5" />
</span>
</div>
</button>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,153 +0,0 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { Page } from './_util';
import { useTranslation } from '../i18n/useTranslation';
import { fetchPendingUploadsSummary, type PendingUpload } from '../services/pendingUploadsApi';
import { Button } from '@/components/ui/button';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Image as ImageIcon, Loader2, RefreshCcw } from 'lucide-react';
import { useEventBranding } from '../context/EventBrandingContext';
export default function UploadQueuePage() {
const { t, locale } = useTranslation();
const { token } = useParams<{ token?: string }>();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const { branding } = useEventBranding();
const bodyFont = branding.typography?.body ?? branding.fontFamily ?? undefined;
const [pending, setPending] = useState<PendingUpload[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const showSuccess = searchParams.get('uploaded') === 'true';
const buttonStyle = branding.buttons?.style ?? 'filled';
const linkColor = branding.buttons?.linkColor ?? branding.secondaryColor;
const radius = branding.buttons?.radius ?? 12;
const formatter = useMemo(
() => new Intl.DateTimeFormat(locale, { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' }),
[locale],
);
const formatTimestamp = useCallback((value?: string | null) => {
if (!value) {
return t('pendingUploads.card.justNow');
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return t('pendingUploads.card.justNow');
}
return formatter.format(date);
}, [formatter, t]);
const loadPendingUploads = useCallback(async () => {
if (!token) return;
try {
setLoading(true);
setError(null);
const result = await fetchPendingUploadsSummary(token, 12);
setPending(result.items);
} catch (err) {
console.error('Pending uploads load failed', err);
setError(t('pendingUploads.error'));
} finally {
setLoading(false);
}
}, [t, token]);
useEffect(() => {
if (!token) return;
loadPendingUploads();
}, [loadPendingUploads, token]);
const emptyState = !loading && pending.length === 0;
return (
<Page title={t('pendingUploads.title')}>
<div className="space-y-4" style={bodyFont ? { fontFamily: bodyFont } : undefined}>
<p className="text-sm text-muted-foreground">{t('pendingUploads.subtitle')}</p>
{showSuccess && (
<Alert className="border-amber-300/70 bg-amber-50/80 text-amber-900 dark:border-amber-400/40 dark:bg-amber-500/10 dark:text-amber-50">
<AlertDescription>
<p className="text-sm font-semibold">{t('pendingUploads.successTitle')}</p>
<p className="text-xs">{t('pendingUploads.successBody')}</p>
</AlertDescription>
</Alert>
)}
{error && (
<Alert variant="destructive">
<AlertDescription className="text-sm">{error}</AlertDescription>
</Alert>
)}
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
onClick={() => {
if (token) {
navigate(`/e/${encodeURIComponent(token)}/upload`);
}
}}
style={buttonStyle === 'outline'
? { borderRadius: radius, background: 'transparent', color: linkColor, border: `1px solid ${linkColor}` }
: { borderRadius: radius }}
>
{t('pendingUploads.cta')}
</Button>
<Button
size="sm"
variant="secondary"
onClick={loadPendingUploads}
disabled={loading}
style={buttonStyle === 'outline'
? { borderRadius: radius, background: 'transparent', color: linkColor, border: `1px solid ${linkColor}` }
: { borderRadius: radius }}
>
<RefreshCcw className="mr-2 h-4 w-4" />
{t('pendingUploads.refresh')}
</Button>
</div>
{loading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{t('pendingUploads.loading', 'Lade Uploads...')}
</div>
) : (
<div className="grid gap-3">
{pending.map((photo) => (
<div
key={photo.id}
className="flex items-center gap-3 rounded-xl border border-white/10 bg-white/90 p-3 shadow-sm dark:border-white/10 dark:bg-white/5"
>
<div className="h-16 w-16 overflow-hidden rounded-lg bg-slate-200/70 dark:bg-white/10">
{photo.thumbnail_url ? (
<img src={photo.thumbnail_url} alt="" className="h-full w-full object-cover" />
) : (
<div className="flex h-full w-full items-center justify-center text-slate-500 dark:text-white/50">
<ImageIcon className="h-6 w-6" />
</div>
)}
</div>
<div className="flex-1">
<p className="text-sm font-semibold">{t('pendingUploads.card.pending')}</p>
<p className="text-xs text-muted-foreground">
{t('pendingUploads.card.uploadedAt').replace('{time}', formatTimestamp(photo.created_at))}
</p>
</div>
</div>
))}
{emptyState && (
<div className="rounded-2xl border border-dashed border-white/20 bg-white/80 p-6 text-center text-sm text-muted-foreground dark:border-white/10 dark:bg-white/5">
<p className="font-semibold text-foreground">{t('pendingUploads.emptyTitle')}</p>
<p className="mt-2 text-xs text-muted-foreground">{t('pendingUploads.emptyBody')}</p>
</div>
)}
</div>
)}
</div>
</Page>
);
}

View File

@@ -1,42 +0,0 @@
import React from 'react';
import { describe, expect, it } from 'vitest';
import { render, screen } from '@testing-library/react';
import { BadgesGrid } from '../AchievementsPage';
const t = (key: string) => key;
describe('BadgesGrid', () => {
it('adds dark mode classes for earned and pending badges', () => {
render(
<BadgesGrid
badges={[
{
id: '1',
title: 'First Upload',
description: 'Uploaded your first photo',
earned: true,
progress: 1,
target: 1,
},
{
id: '2',
title: 'Social Star',
description: 'Received 10 likes',
earned: false,
progress: 3,
target: 10,
},
]}
t={t}
/>,
);
const earnedCard = screen.getByTestId('badge-card-1');
expect(earnedCard.className).toContain('dark:from-emerald-400/20');
expect(earnedCard.className).toContain('dark:text-emerald-50');
const pendingCard = screen.getByTestId('badge-card-2');
expect(pendingCard.className).toContain('bg-card/90');
expect(pendingCard.className).toContain('border-border/60');
});
});

View File

@@ -1,55 +0,0 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import LiveShowPlayerPage from '../LiveShowPlayerPage';
vi.mock('../../hooks/useLiveShowState', () => ({
useLiveShowState: () => ({
status: 'ready',
connection: 'polling',
error: null,
event: { id: 1, name: 'Showcase' },
photos: [],
settings: {
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: 'gradient',
},
}),
}));
vi.mock('../../hooks/useLiveShowPlayback', () => ({
useLiveShowPlayback: () => ({
frame: [],
nextFrame: [],
layout: 'single',
frameKey: 'empty',
}),
}));
vi.mock('../../i18n/useTranslation', () => ({
useTranslation: () => ({
t: (_key: string, fallback: string) => fallback,
}),
}));
describe('LiveShowPlayerPage', () => {
it('renders empty state when no photos', () => {
render(
<MemoryRouter initialEntries={['/show/demo']}>
<Routes>
<Route path="/show/:token" element={<LiveShowPlayerPage />} />
</Routes>
</MemoryRouter>
);
expect(screen.getByText('Noch keine Live-Fotos')).toBeInTheDocument();
});
});

View File

@@ -1,62 +0,0 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { MissionActionCard } from '../HomePage';
vi.mock('../../context/EventBrandingContext', () => ({
useEventBranding: () => ({
branding: {
primaryColor: '#FF5A5F',
secondaryColor: '#FFF8F5',
buttons: { radius: 12 },
typography: {},
fontFamily: 'Montserrat',
},
}),
}));
vi.mock('../../lib/emotionTheme', () => ({
getEmotionTheme: () => ({
gradientBackground: 'linear-gradient(135deg, #FF5A5F, #FFF8F5)',
}),
getEmotionIcon: () => '🙂',
}));
vi.mock('swiper/react', () => ({
Swiper: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SwiperSlide: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('swiper/modules', () => ({
EffectCards: {},
}));
describe('MissionActionCard layout spacing', () => {
it('uses a tighter min height for the stack container', () => {
render(
<MemoryRouter>
<MissionActionCard
token="demo"
mission={{
id: 1,
title: 'Demo Mission',
description: 'Do a demo task.',
duration: 3,
emotion: null,
}}
loading={false}
onAdvance={() => {}}
stack={[]}
initialIndex={0}
onIndexChange={() => {}}
swiperRef={{ current: null }}
/>
</MemoryRouter>,
);
const stack = screen.getByTestId('mission-card-stack');
expect(stack.className).toContain('min-h-[240px]');
expect(stack.className).toContain('sm:min-h-[260px]');
});
});

View File

@@ -1,56 +0,0 @@
import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { beforeAll, vi } from 'vitest';
import { MemoryRouter } from 'react-router-dom';
import PhotoLightbox from '../PhotoLightbox';
import { EventBrandingProvider } from '../../context/EventBrandingContext';
import { LocaleProvider } from '../../i18n/LocaleContext';
import { ToastProvider } from '../../components/ToastHost';
const photo = {
id: 1,
file_path: '/test.jpg',
likes_count: 0,
};
describe('PhotoLightbox zoom gestures', () => {
beforeAll(() => {
if (!window.matchMedia) {
Object.defineProperty(window, 'matchMedia', {
configurable: true,
value: vi.fn().mockReturnValue({
matches: false,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}),
});
}
});
it('toggles zoom state on double click', () => {
render(
<MemoryRouter>
<LocaleProvider>
<EventBrandingProvider>
<ToastProvider>
<PhotoLightbox photos={[photo]} currentIndex={0} token="event-token" />
</ToastProvider>
</EventBrandingProvider>
</LocaleProvider>
</MemoryRouter>
);
const zoomSurface = screen.getByTestId('lightbox-zoom');
const container = zoomSurface.closest('[data-zoomed]');
expect(container).toHaveAttribute('data-zoomed', 'false');
fireEvent.doubleClick(zoomSurface);
expect(container).toHaveAttribute('data-zoomed', 'true');
fireEvent.doubleClick(zoomSurface);
expect(container).toHaveAttribute('data-zoomed', 'false');
});
});

View File

@@ -1,44 +0,0 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { UploadActionCard } from '../HomePage';
vi.mock('../../hooks/useDirectUpload', () => ({
useDirectUpload: () => ({
upload: vi.fn(),
uploading: false,
error: null,
warning: null,
progress: 0,
reset: vi.fn(),
}),
}));
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom');
return {
...actual,
useNavigate: () => vi.fn(),
};
});
describe('UploadActionCard', () => {
it('renders with dark mode surface classes', () => {
render(
<MemoryRouter>
<UploadActionCard
token="demo"
accentColor="#FF5A5F"
secondaryAccent="#FFF8F5"
radius={12}
requiresApproval={false}
/>
</MemoryRouter>,
);
const card = screen.getByTestId('upload-action-card');
expect(card.className).toContain('bg-[var(--guest-surface)]');
expect(card.className).toContain('dark:bg-slate-950/70');
});
});

View File

@@ -1,83 +0,0 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import UploadPage from '../UploadPage';
vi.mock('react-router-dom', () => ({
useNavigate: () => vi.fn(),
useParams: () => ({ token: 'demo' }),
useSearchParams: () => [new URLSearchParams(), vi.fn()],
}));
vi.mock('../../demo/demoMode', () => ({
isGuestDemoModeEnabled: () => true,
}));
vi.mock('../../hooks/useGuestTaskProgress', () => ({
useGuestTaskProgress: () => ({
markCompleted: vi.fn(),
}),
}));
vi.mock('../../context/GuestIdentityContext', () => ({
useGuestIdentity: () => ({
name: 'Guest',
}),
}));
vi.mock('../../hooks/useEventData', () => ({
useEventData: () => ({
event: {
guest_upload_visibility: 'immediate',
demo_read_only: false,
engagement_mode: 'photo_only',
},
}),
}));
vi.mock('../../context/EventStatsContext', () => ({
useEventStats: () => ({
latestPhotoAt: null,
onlineGuests: 2,
tasksSolved: 0,
guestCount: 2,
likesCount: 0,
}),
}));
vi.mock('../../context/EventBrandingContext', () => ({
useEventBranding: () => ({
branding: {
primaryColor: '#FF5A5F',
secondaryColor: '#FFF8F5',
buttons: { radius: 12 },
typography: {},
fontFamily: 'Montserrat',
},
}),
}));
vi.mock('../../i18n/useTranslation', () => ({
useTranslation: () => ({
t: (key: string, fallback?: string) => fallback ?? key,
locale: 'de',
}),
}));
vi.mock('../../services/eventApi', () => ({
getEventPackage: vi.fn().mockResolvedValue(null),
}));
vi.mock('../../services/photosApi', () => ({
uploadPhoto: vi.fn(),
}));
describe('UploadPage demo mode', () => {
it('keeps the UI visible and shows the demo notice', async () => {
render(<UploadPage />);
await waitFor(() => {
expect(screen.getByText('Demo-Modus aktiv')).toBeInTheDocument();
});
});
});

View File

@@ -1,90 +0,0 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import UploadPage from '../UploadPage';
vi.mock('react-router-dom', () => ({
useNavigate: () => vi.fn(),
useParams: () => ({ token: 'demo' }),
useSearchParams: () => [new URLSearchParams(), vi.fn()],
}));
vi.mock('../../hooks/useGuestTaskProgress', () => ({
useGuestTaskProgress: () => ({
markCompleted: vi.fn(),
}),
}));
vi.mock('../../context/GuestIdentityContext', () => ({
useGuestIdentity: () => ({
name: 'Guest',
}),
}));
vi.mock('../../hooks/useEventData', () => ({
useEventData: () => ({
event: {
guest_upload_visibility: 'immediate',
demo_read_only: false,
engagement_mode: 'photo_only',
},
}),
}));
vi.mock('../../context/EventStatsContext', () => ({
useEventStats: () => ({
latestPhotoAt: null,
onlineGuests: 0,
tasksSolved: 0,
guestCount: 0,
likesCount: 0,
}),
}));
vi.mock('../../context/EventBrandingContext', () => ({
useEventBranding: () => ({
branding: {
primaryColor: '#FF5A5F',
secondaryColor: '#FFF8F5',
buttons: { radius: 12 },
typography: {},
fontFamily: 'Montserrat',
},
}),
}));
vi.mock('../../i18n/useTranslation', () => ({
useTranslation: () => ({
t: (key: string, fallback?: string) => fallback ?? key,
locale: 'de',
}),
}));
vi.mock('../../services/eventApi', () => ({
getEventPackage: vi.fn().mockResolvedValue(null),
}));
vi.mock('../../services/photosApi', () => ({
uploadPhoto: vi.fn(),
}));
describe('UploadPage immersive mode', () => {
it('adds the guest-immersive class on mount', async () => {
render(<UploadPage />);
await waitFor(() => {
expect(document.body.classList.contains('guest-immersive')).toBe(true);
});
});
it('centers the capture button within the countdown ring', () => {
render(<UploadPage />);
const captureButton = screen.getByRole('button', { name: 'upload.buttons.startCamera' });
const wrapper = captureButton.parentElement;
expect(wrapper).not.toBeNull();
expect(wrapper?.className).toContain('items-center');
expect(wrapper?.className).toContain('justify-center');
});
});

View File

@@ -1,100 +0,0 @@
import React from 'react';
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { render, waitFor } from '@testing-library/react';
import UploadPage from '../UploadPage';
vi.mock('react-router-dom', () => ({
useNavigate: () => vi.fn(),
useParams: () => ({ token: 'demo' }),
useSearchParams: () => [new URLSearchParams(), vi.fn()],
}));
vi.mock('../../hooks/useGuestTaskProgress', () => ({
useGuestTaskProgress: () => ({
markCompleted: vi.fn(),
}),
}));
vi.mock('../../context/GuestIdentityContext', () => ({
useGuestIdentity: () => ({
name: 'Guest',
}),
}));
vi.mock('../../hooks/useEventData', () => ({
useEventData: () => ({
event: {
guest_upload_visibility: 'immediate',
demo_read_only: false,
engagement_mode: 'photo_only',
},
}),
}));
vi.mock('../../context/EventStatsContext', () => ({
useEventStats: () => ({
latestPhotoAt: null,
onlineGuests: 2,
tasksSolved: 0,
guestCount: 2,
likesCount: 0,
}),
}));
vi.mock('../../context/EventBrandingContext', () => ({
useEventBranding: () => ({
branding: {
primaryColor: '#FF5A5F',
secondaryColor: '#FFF8F5',
buttons: { radius: 12 },
typography: {},
fontFamily: 'Montserrat',
},
}),
}));
vi.mock('../../i18n/useTranslation', () => ({
useTranslation: () => ({
t: (key: string, fallback?: string) => fallback ?? key,
locale: 'de',
}),
}));
vi.mock('../../services/eventApi', () => ({
getEventPackage: vi.fn().mockResolvedValue(null),
}));
vi.mock('../../services/photosApi', () => ({
uploadPhoto: vi.fn(),
}));
describe('UploadPage bottom nav visibility', () => {
beforeEach(() => {
document.body.classList.remove('guest-nav-visible');
document.body.classList.remove('guest-immersive');
Object.defineProperty(window, 'scrollY', { value: 0, writable: true, configurable: true });
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
cb(0);
return 0;
});
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {});
});
it('toggles the nav visibility based on scroll position', async () => {
render(<UploadPage />);
expect(document.body.classList.contains('guest-nav-visible')).toBe(false);
window.scrollY = 120;
window.dispatchEvent(new Event('scroll'));
await waitFor(() => {
expect(document.body.classList.contains('guest-nav-visible')).toBe(true);
});
window.scrollY = 0;
window.dispatchEvent(new Event('scroll'));
await waitFor(() => {
expect(document.body.classList.contains('guest-nav-visible')).toBe(false);
});
});
});

View File

@@ -1,18 +0,0 @@
import React from 'react';
import { motion } from 'framer-motion';
import { FADE_UP, STAGGER_FAST, getMotionContainerProps, getMotionItemProps, prefersReducedMotion } from '../lib/motion';
export function Page({ title, children }: { title: string; children?: React.ReactNode }) {
const motionEnabled = !prefersReducedMotion();
const containerProps = getMotionContainerProps(motionEnabled, STAGGER_FAST);
const itemProps = getMotionItemProps(motionEnabled, FADE_UP);
return (
<motion.div {...containerProps} style={{ maxWidth: 720, margin: '0 auto', padding: 16 }}>
<motion.h1 {...itemProps} style={{ fontSize: 20, fontWeight: 600, marginBottom: 12 }}>
{title}
</motion.h1>
<motion.div {...itemProps}>{children}</motion.div>
</motion.div>
);
}

Some files were not shown because too many files have changed in this diff Show More