refactor(guest): retire legacy guest app and move shared modules
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
|
||||
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 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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' },
|
||||
},
|
||||
};
|
||||
@@ -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' }));
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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]
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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]);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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({});
|
||||
});
|
||||
});
|
||||
@@ -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' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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.');
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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] ?? '✨';
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import type { EventData } from '../services/eventApi';
|
||||
|
||||
export function shouldShowPhotoboothFilter(event?: EventData | null): boolean {
|
||||
return Boolean(event?.photobooth_enabled);
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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." />);
|
||||
});
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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]');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user