Files
fotospiel-app/resources/js/guest/components/settings-sheet.tsx
Codex Agent bdb1789a10
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
Add guest analytics consent nudge
2026-01-23 16:20:14 +01:00

562 lines
19 KiB
TypeScript

import React from "react";
import { Link, useLocation, useParams } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
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-9 w-9 rounded-md">
<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>
<CardDescription>{t('settings.language.description')}</CardDescription>
</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>
{isActive && (
<Badge
variant="secondary"
className="border border-white/40 bg-white/10 text-[10px] uppercase tracking-wide text-white"
>
{t('settings.language.activeBadge')}
</Badge>
)}
</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>
);
}