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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user