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'; 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({ 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('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 (
{isLegal ? (
{legalDocument.phase === 'ready' && legalDocument.title ? legalDocument.title : t(view.translationKey)} {legalDocument.phase === 'loading' ? t('common.actions.loading') : t('settings.sheet.legalDescription')}
) : (
{t('settings.title')} {t('settings.subtitle')}
)}
{isLegal ? ( handleOpenChange(false)} translationKey={view.mode === 'legal' ? view.translationKey : null} /> ) : ( )}
{t('settings.footer.notice')}
); } function LegalView({ document, onClose, translationKey, }: { document: LegalDocumentState; onClose: () => void; translationKey: string | null; }) { const { t } = useTranslation(); if (document.phase === 'error') { return (
{t('settings.legal.error')}
); } if (document.phase === 'loading' || document.phase === 'idle') { return
{t('settings.legal.loading')}
; } return (
{document.title || t(translationKey ?? 'settings.legal.fallbackTitle')}
); } interface HomeViewProps { identity: ReturnType; 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 legalLinks = React.useMemo( () => legalPages.map((page) => ({ slug: page.slug, translationKey: page.translationKey, label: t(page.translationKey), })), [t], ); return (
{t('settings.language.title')} {t('settings.language.description')}
{localeContext.availableLocales.map((option) => { const isActive = localeContext.locale === option.code; return ( ); })}
{identity && ( {t('settings.name.title')} {t('settings.name.description')}
onNameChange(event.target.value)} autoComplete="name" disabled={!identity.hydrated || savingName} />
{nameStatus === 'saved' && ( {t('settings.name.saved')} )} {!identity.hydrated && ( {t('settings.name.loading')} )}
)} {t('settings.haptics.title')} {t('settings.haptics.description')}
{t('settings.haptics.label')} { setHapticsEnabled(checked); if (checked) { triggerHaptic('selection'); } }} disabled={!hapticsSupported} aria-label={t('settings.haptics.label')} />
{!hapticsSupported && (
{t('settings.haptics.unsupported')}
)}
{t('settings.legal.title')}
{t('settings.legal.description')}
{legalLinks.map((page) => ( ))}
{t('settings.help.title')}
{t('settings.help.description')}
{t('settings.cache.title')} {t('settings.cache.description')}
{t('settings.cache.note')}
); } function useLegalDocument(slug: string | null, locale: LocaleCode): LegalDocumentState { const [state, setState] = React.useState({ 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 (
{done &&
{t('settings.cache.cleared')}
}
); }