402 lines
13 KiB
TypeScript
402 lines
13 KiB
TypeScript
import React from "react";
|
|
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 { Settings, ArrowLeft, FileText, RefreshCcw, ChevronRight, UserCircle } from 'lucide-react';
|
|
import { useOptionalGuestIdentity } from '../context/GuestIdentityContext';
|
|
import { LegalMarkdown } from './legal-markdown';
|
|
|
|
const legalPages = [
|
|
{ slug: 'impressum', label: 'Impressum' },
|
|
{ slug: 'datenschutz', label: 'Datenschutz' },
|
|
{ slug: 'agb', label: 'AGB' },
|
|
] as const;
|
|
|
|
type ViewState =
|
|
| { mode: 'home' }
|
|
| { mode: 'legal'; slug: (typeof legalPages)[number]['slug']; label: string };
|
|
|
|
type LegalDocumentState =
|
|
| { phase: 'idle'; title: string; body: string }
|
|
| { phase: 'loading'; title: string; body: string }
|
|
| { phase: 'ready'; title: string; body: string }
|
|
| { phase: 'error'; title: string; body: 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 [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);
|
|
|
|
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'], label: string) => {
|
|
setView({ mode: 'legal', slug, label });
|
|
},
|
|
[]
|
|
);
|
|
|
|
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">Einstellungen oeffnen</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">Zurück</span>
|
|
</Button>
|
|
<div className="min-w-0">
|
|
<SheetTitle className="truncate">
|
|
{legalDocument.phase === 'ready' && legalDocument.title
|
|
? legalDocument.title
|
|
: view.label}
|
|
</SheetTitle>
|
|
<SheetDescription>
|
|
{legalDocument.phase === 'loading' ? 'Laedt...' : 'Rechtlicher Hinweis'}
|
|
</SheetDescription>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div>
|
|
<SheetTitle>Einstellungen</SheetTitle>
|
|
<SheetDescription>
|
|
Verwalte deinen Gastzugang, rechtliche Dokumente und lokale Daten.
|
|
</SheetDescription>
|
|
</div>
|
|
)}
|
|
</header>
|
|
|
|
<main className="flex-1 overflow-y-auto px-6 py-4">
|
|
{isLegal ? (
|
|
<LegalView document={legalDocument} onClose={() => handleOpenChange(false)} />
|
|
) : (
|
|
<HomeView
|
|
identity={identity}
|
|
nameDraft={nameDraft}
|
|
onNameChange={setNameDraft}
|
|
onSaveName={handleSaveName}
|
|
onResetName={handleResetName}
|
|
canSaveName={canSaveName}
|
|
savingName={savingName}
|
|
nameStatus={nameStatus}
|
|
onOpenLegal={handleOpenLegal}
|
|
/>
|
|
)}
|
|
</main>
|
|
|
|
<SheetFooter className="border-t bg-muted/40 px-6 py-3 text-xs text-muted-foreground">
|
|
<div>Gastbereich - Daten werden lokal im Browser gespeichert.</div>
|
|
</SheetFooter>
|
|
</div>
|
|
</SheetContent>
|
|
</Sheet>
|
|
);
|
|
}
|
|
|
|
function LegalView({ document, onClose }: { document: LegalDocumentState; onClose: () => void }) {
|
|
if (document.phase === 'error') {
|
|
return (
|
|
<div className="space-y-4">
|
|
<Alert variant="destructive">
|
|
<AlertDescription>
|
|
Das Dokument konnte nicht geladen werden. Bitte versuche es spaeter erneut.
|
|
</AlertDescription>
|
|
</Alert>
|
|
<Button variant="secondary" onClick={onClose}>
|
|
Schliessen
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (document.phase === 'loading' || document.phase === 'idle') {
|
|
return <div className="text-sm text-muted-foreground">Dokument wird geladen...</div>;
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>{document.title || 'Rechtlicher Hinweis'}</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="prose prose-sm max-w-none dark:prose-invert">
|
|
<LegalMarkdown markdown={document.body} />
|
|
</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;
|
|
onOpenLegal: (slug: (typeof legalPages)[number]['slug'], label: string) => void;
|
|
}
|
|
|
|
function HomeView({
|
|
identity,
|
|
nameDraft,
|
|
onNameChange,
|
|
onSaveName,
|
|
onResetName,
|
|
canSaveName,
|
|
savingName,
|
|
nameStatus,
|
|
onOpenLegal,
|
|
}: HomeViewProps) {
|
|
return (
|
|
<div className="space-y-6">
|
|
{identity && (
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<CardTitle>Dein Name</CardTitle>
|
|
<CardDescription>
|
|
Passe an, wie wir dich im Event begruessen. Der Name wird nur lokal gespeichert.
|
|
</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">
|
|
Anzeigename
|
|
</Label>
|
|
<Input
|
|
id="guest-name"
|
|
value={nameDraft}
|
|
placeholder="z.B. Anna"
|
|
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 ? 'Speichere...' : 'Name speichern'}
|
|
</Button>
|
|
<Button type="button" variant="ghost" onClick={onResetName} disabled={savingName}>
|
|
zurücksetzen
|
|
</Button>
|
|
{nameStatus === 'saved' && (
|
|
<span className="text-xs text-muted-foreground">Gespeichert (ok)</span>
|
|
)}
|
|
{!identity.hydrated && (
|
|
<span className="text-xs text-muted-foreground">Lade gespeicherten Namen...</span>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<CardTitle>
|
|
<div className="flex items-center gap-2">
|
|
<FileText className="h-4 w-4 text-pink-500" />
|
|
Rechtliches
|
|
</div>
|
|
</CardTitle>
|
|
<CardDescription>
|
|
Die rechtlich verbindlichen Texte sind jederzeit hier abrufbar.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-2">
|
|
{legalPages.map((page) => (
|
|
<Button
|
|
key={page.slug}
|
|
variant="ghost"
|
|
className="w-full justify-between px-3"
|
|
onClick={() => onOpenLegal(page.slug, page.label)}
|
|
>
|
|
<span className="text-left text-sm">{page.label}</span>
|
|
<ChevronRight className="h-4 w-4" />
|
|
</Button>
|
|
))}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Offline Cache</CardTitle>
|
|
<CardDescription>
|
|
Loesche lokale Daten, falls Inhalte veraltet erscheinen oder Uploads haengen bleiben.
|
|
</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>Dies betrifft nur diesen Browser und muss pro Geraet erneut ausgefuehrt werden.</span>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function useLegalDocument(slug: string | null): LegalDocumentState {
|
|
const [state, setState] = React.useState<LegalDocumentState>({
|
|
phase: 'idle',
|
|
title: '',
|
|
body: '',
|
|
});
|
|
|
|
React.useEffect(() => {
|
|
if (!slug) {
|
|
setState({ phase: 'idle', title: '', body: '' });
|
|
return;
|
|
}
|
|
|
|
const controller = new AbortController();
|
|
setState({ phase: 'loading', title: '', body: '' });
|
|
|
|
fetch(`/api/v1/legal/${encodeURIComponent(slug)}?lang=de`, {
|
|
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 ?? '',
|
|
body: payload.body_markdown ?? '',
|
|
});
|
|
})
|
|
.catch((error) => {
|
|
if (controller.signal.aborted) {
|
|
return;
|
|
}
|
|
console.error('Failed to load legal page', error);
|
|
setState({ phase: 'error', title: '', body: '' });
|
|
});
|
|
|
|
return () => controller.abort();
|
|
}, [slug]);
|
|
|
|
return state;
|
|
}
|
|
|
|
function ClearCacheButton() {
|
|
const [busy, setBusy] = React.useState(false);
|
|
const [done, setDone] = React.useState(false);
|
|
|
|
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 ? 'Leere Cache...' : 'Cache leeren'}
|
|
</Button>
|
|
{done && <div className="text-xs text-muted-foreground">Cache geloescht.</div>}
|
|
</div>
|
|
);
|
|
}
|