Guest PWA vollständig lokalisiert
This commit is contained in:
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { NavLink, useParams, useLocation } from 'react-router-dom';
|
||||
import { CheckSquare, GalleryHorizontal, Home, Trophy } from 'lucide-react';
|
||||
import { useEventData } from '../hooks/useEventData';
|
||||
import { useTranslation } from '../i18n/useTranslation';
|
||||
|
||||
function TabLink({
|
||||
to,
|
||||
@@ -31,32 +32,21 @@ export default function BottomNav() {
|
||||
const { token } = useParams();
|
||||
const location = useLocation();
|
||||
const { event, status } = useEventData();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const isReady = status === 'ready' && !!event;
|
||||
|
||||
if (!token || !isReady) return null; // Only show bottom nav within event context
|
||||
const base = `/e/${encodeURIComponent(token)}`;
|
||||
const currentPath = location.pathname;
|
||||
const locale = event?.default_locale || 'de';
|
||||
|
||||
// Translations
|
||||
const translations = {
|
||||
de: {
|
||||
home: 'Start',
|
||||
tasks: 'Aufgaben',
|
||||
achievements: 'Erfolge',
|
||||
gallery: 'Galerie'
|
||||
},
|
||||
en: {
|
||||
home: 'Home',
|
||||
tasks: 'Tasks',
|
||||
achievements: 'Achievements',
|
||||
gallery: 'Gallery'
|
||||
}
|
||||
const labels = {
|
||||
home: t('navigation.home'),
|
||||
tasks: t('navigation.tasks'),
|
||||
achievements: t('navigation.achievements'),
|
||||
gallery: t('navigation.gallery'),
|
||||
};
|
||||
|
||||
const t = translations[locale as keyof typeof translations] || translations.de;
|
||||
|
||||
// Improved active state logic
|
||||
const isHomeActive = currentPath === base || currentPath === `/${token}`;
|
||||
const isTasksActive = currentPath.startsWith(`${base}/tasks`) || currentPath === `${base}/upload`;
|
||||
@@ -68,22 +58,22 @@ export default function BottomNav() {
|
||||
<div className="mx-auto flex max-w-sm items-center justify-around">
|
||||
<TabLink to={`${base}`} isActive={isHomeActive}>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<Home className="h-5 w-5" /> <span className="text-xs">{t.home}</span>
|
||||
<Home className="h-5 w-5" /> <span className="text-xs">{labels.home}</span>
|
||||
</div>
|
||||
</TabLink>
|
||||
<TabLink to={`${base}/tasks`} isActive={isTasksActive}>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<CheckSquare className="h-5 w-5" /> <span className="text-xs">{t.tasks}</span>
|
||||
<CheckSquare className="h-5 w-5" /> <span className="text-xs">{labels.tasks}</span>
|
||||
</div>
|
||||
</TabLink>
|
||||
<TabLink to={`${base}/achievements`} isActive={isAchievementsActive}>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<Trophy className="h-5 w-5" /> <span className="text-xs">{t.achievements}</span>
|
||||
<Trophy className="h-5 w-5" /> <span className="text-xs">{labels.achievements}</span>
|
||||
</div>
|
||||
</TabLink>
|
||||
<TabLink to={`${base}/gallery`} isActive={isGalleryActive}>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<GalleryHorizontal className="h-5 w-5" /> <span className="text-xs">{t.gallery}</span>
|
||||
<GalleryHorizontal className="h-5 w-5" /> <span className="text-xs">{labels.gallery}</span>
|
||||
</div>
|
||||
</TabLink>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,77 @@
|
||||
import React from 'react';
|
||||
import AppearanceToggleDropdown from '@/components/appearance-dropdown';
|
||||
import { User } from 'lucide-react';
|
||||
import { User, Heart, Users, PartyPopper, Camera } from 'lucide-react';
|
||||
import { useEventData } from '../hooks/useEventData';
|
||||
import { useOptionalEventStats } from '../context/EventStatsContext';
|
||||
import { useOptionalGuestIdentity } from '../context/GuestIdentityContext';
|
||||
import { SettingsSheet } from './settings-sheet';
|
||||
import { useTranslation } from '../i18n/useTranslation';
|
||||
|
||||
const EVENT_ICON_COMPONENTS: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
heart: Heart,
|
||||
guests: Users,
|
||||
party: PartyPopper,
|
||||
camera: 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 renderEventAvatar(name: string, icon: unknown) {
|
||||
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 h-10 w-10 items-center justify-center rounded-full bg-pink-100 text-pink-600">
|
||||
<IconComponent className="h-5 w-5" aria-hidden />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLikelyEmoji(trimmed)) {
|
||||
return (
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-pink-100 text-pink-600 text-xl">
|
||||
<span aria-hidden>{trimmed}</span>
|
||||
<span className="sr-only">{name}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-pink-100 text-pink-600 font-semibold text-sm">
|
||||
{getInitials(name)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Header({ slug, title = '' }: { slug?: string; title?: string }) {
|
||||
const statsContext = useOptionalEventStats();
|
||||
const identity = useOptionalGuestIdentity();
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!slug) {
|
||||
const guestName = identity?.name && identity?.hydrated ? identity.name : null;
|
||||
@@ -17,7 +80,9 @@ export default function Header({ slug, title = '' }: { slug?: string; title?: st
|
||||
<div className="flex flex-col">
|
||||
<div className="font-semibold">{title}</div>
|
||||
{guestName && (
|
||||
<span className="text-xs text-muted-foreground">Hi {guestName}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{`${t('common.hi')} ${guestName}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -35,7 +100,7 @@ export default function Header({ slug, title = '' }: { slug?: string; title?: st
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<div className="sticky top-0 z-20 flex items-center justify-between border-b bg-white/70 px-4 py-2 backdrop-blur dark:bg-black/40">
|
||||
<div className="font-semibold">Lade Event...</div>
|
||||
<div className="font-semibold">{t('header.loading')}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<AppearanceToggleDropdown />
|
||||
<SettingsSheet />
|
||||
@@ -51,49 +116,28 @@ export default function Header({ slug, title = '' }: { slug?: string; title?: st
|
||||
const stats =
|
||||
statsContext && statsContext.eventKey === slug ? statsContext : undefined;
|
||||
|
||||
const getEventAvatar = (event: any) => {
|
||||
if (event.type?.icon) {
|
||||
return (
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-pink-100 text-pink-600 text-xl">
|
||||
{event.type.icon}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getInitials = (name: string) => {
|
||||
const words = name.split(' ');
|
||||
if (words.length >= 2) {
|
||||
return `${words[0][0]}${words[1][0]}`.toUpperCase();
|
||||
}
|
||||
return name.substring(0, 2).toUpperCase();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-pink-100 text-pink-600 font-semibold text-sm">
|
||||
{getInitials(event.name)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="sticky top-0 z-20 flex items-center justify-between border-b bg-white/70 px-4 py-2 backdrop-blur dark:bg-black/40">
|
||||
<div className="flex items-center gap-3">
|
||||
{getEventAvatar(event)}
|
||||
{renderEventAvatar(event.name, event.type?.icon)}
|
||||
<div className="flex flex-col">
|
||||
<div className="font-semibold text-base">{event.name}</div>
|
||||
{guestName && (
|
||||
<span className="text-xs text-muted-foreground">Hi {guestName}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{`${t('common.hi')} ${guestName}`}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{stats && (
|
||||
<>
|
||||
<span className="flex items-center gap-1">
|
||||
<User className="h-3 w-3" />
|
||||
<span>{stats.onlineGuests} online</span>
|
||||
<span>{`${stats.onlineGuests} ${t('header.stats.online')}`}</span>
|
||||
</span>
|
||||
<span className="text-muted-foreground">|</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="font-medium">{stats.tasksSolved}</span> Aufgaben geloest
|
||||
<span className="font-medium">{stats.tasksSolved}</span>{' '}
|
||||
{t('header.stats.tasksSolved')}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from "react";
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
@@ -15,16 +16,23 @@ 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';
|
||||
import { useLocale, type LocaleContextValue } from '../i18n/LocaleContext';
|
||||
import { useTranslation } from '../i18n/useTranslation';
|
||||
import type { LocaleCode } from '../i18n/messages';
|
||||
|
||||
const legalPages = [
|
||||
{ slug: 'impressum', label: 'Impressum' },
|
||||
{ slug: 'datenschutz', label: 'Datenschutz' },
|
||||
{ slug: 'agb', label: 'AGB' },
|
||||
{ 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']; label: string };
|
||||
| {
|
||||
mode: 'legal';
|
||||
slug: (typeof legalPages)[number]['slug'];
|
||||
translationKey: (typeof legalPages)[number]['translationKey'];
|
||||
};
|
||||
|
||||
type LegalDocumentState =
|
||||
| { phase: 'idle'; title: string; body: string }
|
||||
@@ -38,11 +46,13 @@ 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 [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);
|
||||
const legalDocument = useLegalDocument(isLegal ? view.slug : null, localeContext.locale);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open && identity?.hydrated) {
|
||||
@@ -56,10 +66,13 @@ export function SettingsSheet() {
|
||||
}, []);
|
||||
|
||||
const handleOpenLegal = React.useCallback(
|
||||
(slug: (typeof legalPages)[number]['slug'], label: string) => {
|
||||
setView({ mode: 'legal', slug, label });
|
||||
(
|
||||
slug: (typeof legalPages)[number]['slug'],
|
||||
translationKey: (typeof legalPages)[number]['translationKey'],
|
||||
) => {
|
||||
setView({ mode: 'legal', slug, translationKey });
|
||||
},
|
||||
[]
|
||||
[],
|
||||
);
|
||||
|
||||
const handleOpenChange = React.useCallback((next: boolean) => {
|
||||
@@ -100,7 +113,7 @@ export function SettingsSheet() {
|
||||
<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>
|
||||
<span className="sr-only">{t('settings.sheet.openLabel')}</span>
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="right" className="sm:max-w-md">
|
||||
@@ -115,32 +128,36 @@ export function SettingsSheet() {
|
||||
onClick={handleBack}
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
<span className="sr-only">Zurück</span>
|
||||
<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
|
||||
: view.label}
|
||||
: t(view.translationKey)}
|
||||
</SheetTitle>
|
||||
<SheetDescription>
|
||||
{legalDocument.phase === 'loading' ? 'Laedt...' : 'Rechtlicher Hinweis'}
|
||||
{legalDocument.phase === 'loading'
|
||||
? t('common.actions.loading')
|
||||
: t('settings.sheet.legalDescription')}
|
||||
</SheetDescription>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<SheetTitle>Einstellungen</SheetTitle>
|
||||
<SheetDescription>
|
||||
Verwalte deinen Gastzugang, rechtliche Dokumente und lokale Daten.
|
||||
</SheetDescription>
|
||||
<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)} />
|
||||
<LegalView
|
||||
document={legalDocument}
|
||||
onClose={() => handleOpenChange(false)}
|
||||
translationKey={view.mode === 'legal' ? view.translationKey : null}
|
||||
/>
|
||||
) : (
|
||||
<HomeView
|
||||
identity={identity}
|
||||
@@ -151,13 +168,14 @@ export function SettingsSheet() {
|
||||
canSaveName={canSaveName}
|
||||
savingName={savingName}
|
||||
nameStatus={nameStatus}
|
||||
localeContext={localeContext}
|
||||
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>
|
||||
<div>{t('settings.footer.notice')}</div>
|
||||
</SheetFooter>
|
||||
</div>
|
||||
</SheetContent>
|
||||
@@ -165,31 +183,41 @@ export function SettingsSheet() {
|
||||
);
|
||||
}
|
||||
|
||||
function LegalView({ document, onClose }: { document: LegalDocumentState; onClose: () => void }) {
|
||||
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>
|
||||
Das Dokument konnte nicht geladen werden. Bitte versuche es spaeter erneut.
|
||||
{t('settings.legal.error')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Schliessen
|
||||
{t('common.actions.close')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (document.phase === 'loading' || document.phase === 'idle') {
|
||||
return <div className="text-sm text-muted-foreground">Dokument wird geladen...</div>;
|
||||
return <div className="text-sm text-muted-foreground">{t('settings.legal.loading')}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{document.title || 'Rechtlicher Hinweis'}</CardTitle>
|
||||
<CardTitle>{document.title || t(translationKey ?? 'settings.legal.fallbackTitle')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="prose prose-sm max-w-none dark:prose-invert">
|
||||
<LegalMarkdown markdown={document.body} />
|
||||
@@ -208,7 +236,11 @@ interface HomeViewProps {
|
||||
canSaveName: boolean;
|
||||
savingName: boolean;
|
||||
nameStatus: NameStatus;
|
||||
onOpenLegal: (slug: (typeof legalPages)[number]['slug'], label: string) => void;
|
||||
localeContext: LocaleContextValue;
|
||||
onOpenLegal: (
|
||||
slug: (typeof legalPages)[number]['slug'],
|
||||
translationKey: (typeof legalPages)[number]['translationKey'],
|
||||
) => void;
|
||||
}
|
||||
|
||||
function HomeView({
|
||||
@@ -220,17 +252,65 @@ function HomeView({
|
||||
canSaveName,
|
||||
savingName,
|
||||
nameStatus,
|
||||
localeContext,
|
||||
onOpenLegal,
|
||||
}: HomeViewProps) {
|
||||
const { t } = useTranslation();
|
||||
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>Dein Name</CardTitle>
|
||||
<CardDescription>
|
||||
Passe an, wie wir dich im Event begruessen. Der Name wird nur lokal gespeichert.
|
||||
</CardDescription>
|
||||
<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">
|
||||
@@ -239,12 +319,12 @@ function HomeView({
|
||||
</div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<Label htmlFor="guest-name" className="text-sm font-medium">
|
||||
Anzeigename
|
||||
{t('settings.name.label')}
|
||||
</Label>
|
||||
<Input
|
||||
id="guest-name"
|
||||
value={nameDraft}
|
||||
placeholder="z.B. Anna"
|
||||
placeholder={t('settings.name.placeholder')}
|
||||
onChange={(event) => onNameChange(event.target.value)}
|
||||
autoComplete="name"
|
||||
disabled={!identity.hydrated || savingName}
|
||||
@@ -253,16 +333,16 @@ function HomeView({
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button onClick={onSaveName} disabled={!canSaveName || savingName}>
|
||||
{savingName ? 'Speichere...' : 'Name speichern'}
|
||||
{savingName ? t('settings.name.saving') : t('settings.name.save')}
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" onClick={onResetName} disabled={savingName}>
|
||||
zurücksetzen
|
||||
{t('settings.name.reset')}
|
||||
</Button>
|
||||
{nameStatus === 'saved' && (
|
||||
<span className="text-xs text-muted-foreground">Gespeichert (ok)</span>
|
||||
<span className="text-xs text-muted-foreground">{t('settings.name.saved')}</span>
|
||||
)}
|
||||
{!identity.hydrated && (
|
||||
<span className="text-xs text-muted-foreground">Lade gespeicherten Namen...</span>
|
||||
<span className="text-xs text-muted-foreground">{t('settings.name.loading')}</span>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -274,20 +354,18 @@ function HomeView({
|
||||
<CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-4 w-4 text-pink-500" />
|
||||
Rechtliches
|
||||
{t('settings.legal.title')}
|
||||
</div>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Die rechtlich verbindlichen Texte sind jederzeit hier abrufbar.
|
||||
</CardDescription>
|
||||
<CardDescription>{t('settings.legal.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{legalPages.map((page) => (
|
||||
{legalLinks.map((page) => (
|
||||
<Button
|
||||
key={page.slug}
|
||||
variant="ghost"
|
||||
className="w-full justify-between px-3"
|
||||
onClick={() => onOpenLegal(page.slug, page.label)}
|
||||
onClick={() => onOpenLegal(page.slug, page.translationKey)}
|
||||
>
|
||||
<span className="text-left text-sm">{page.label}</span>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
@@ -298,16 +376,14 @@ function HomeView({
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Offline Cache</CardTitle>
|
||||
<CardDescription>
|
||||
Loesche lokale Daten, falls Inhalte veraltet erscheinen oder Uploads haengen bleiben.
|
||||
</CardDescription>
|
||||
<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>Dies betrifft nur diesen Browser und muss pro Geraet erneut ausgefuehrt werden.</span>
|
||||
<span>{t('settings.cache.note')}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -315,7 +391,7 @@ function HomeView({
|
||||
);
|
||||
}
|
||||
|
||||
function useLegalDocument(slug: string | null): LegalDocumentState {
|
||||
function useLegalDocument(slug: string | null, locale: LocaleCode): LegalDocumentState {
|
||||
const [state, setState] = React.useState<LegalDocumentState>({
|
||||
phase: 'idle',
|
||||
title: '',
|
||||
@@ -331,7 +407,8 @@ function useLegalDocument(slug: string | null): LegalDocumentState {
|
||||
const controller = new AbortController();
|
||||
setState({ phase: 'loading', title: '', body: '' });
|
||||
|
||||
fetch(`/api/v1/legal/${encodeURIComponent(slug)}?lang=de`, {
|
||||
const langParam = encodeURIComponent(locale);
|
||||
fetch(`/api/v1/legal/${encodeURIComponent(slug)}?lang=${langParam}`, {
|
||||
headers: { 'Cache-Control': 'no-store' },
|
||||
signal: controller.signal,
|
||||
})
|
||||
@@ -355,7 +432,7 @@ function useLegalDocument(slug: string | null): LegalDocumentState {
|
||||
});
|
||||
|
||||
return () => controller.abort();
|
||||
}, [slug]);
|
||||
}, [slug, locale]);
|
||||
|
||||
return state;
|
||||
}
|
||||
@@ -363,6 +440,7 @@ function useLegalDocument(slug: string | null): LegalDocumentState {
|
||||
function ClearCacheButton() {
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const [done, setDone] = React.useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
async function clearAll() {
|
||||
setBusy(true);
|
||||
@@ -393,9 +471,9 @@ function ClearCacheButton() {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Button variant="secondary" onClick={clearAll} disabled={busy} className="w-full">
|
||||
{busy ? 'Leere Cache...' : 'Cache leeren'}
|
||||
{busy ? t('settings.cache.clearing') : t('settings.cache.clear')}
|
||||
</Button>
|
||||
{done && <div className="text-xs text-muted-foreground">Cache geloescht.</div>}
|
||||
{done && <div className="text-xs text-muted-foreground">{t('settings.cache.cleared')}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
124
resources/js/guest/i18n/LocaleContext.tsx
Normal file
124
resources/js/guest/i18n/LocaleContext.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import React from 'react';
|
||||
import { DEFAULT_LOCALE, SUPPORTED_LOCALES, type LocaleCode, isLocaleCode } from './messages';
|
||||
|
||||
export interface LocaleContextValue {
|
||||
locale: LocaleCode;
|
||||
setLocale: (next: LocaleCode) => void;
|
||||
resetLocale: () => void;
|
||||
hydrated: boolean;
|
||||
defaultLocale: LocaleCode;
|
||||
storageKey: string;
|
||||
availableLocales: typeof SUPPORTED_LOCALES;
|
||||
}
|
||||
|
||||
const LocaleContext = React.createContext<LocaleContextValue | undefined>(undefined);
|
||||
|
||||
function sanitizeLocale(value: string | null | undefined, fallback: LocaleCode = DEFAULT_LOCALE): LocaleCode {
|
||||
if (value && isLocaleCode(value)) {
|
||||
return value;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export interface LocaleProviderProps {
|
||||
children: React.ReactNode;
|
||||
defaultLocale?: LocaleCode;
|
||||
storageKey?: string;
|
||||
}
|
||||
|
||||
export function LocaleProvider({
|
||||
children,
|
||||
defaultLocale = DEFAULT_LOCALE,
|
||||
storageKey = 'guestLocale_global',
|
||||
}: LocaleProviderProps) {
|
||||
const resolvedDefault = sanitizeLocale(defaultLocale, DEFAULT_LOCALE);
|
||||
const [locale, setLocaleState] = React.useState<LocaleCode>(resolvedDefault);
|
||||
const [userLocale, setUserLocale] = React.useState<LocaleCode | null>(null);
|
||||
const [hydrated, setHydrated] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setHydrated(false);
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
setLocaleState(resolvedDefault);
|
||||
setUserLocale(null);
|
||||
setHydrated(true);
|
||||
return;
|
||||
}
|
||||
|
||||
let stored: string | null = null;
|
||||
try {
|
||||
stored = window.localStorage.getItem(storageKey);
|
||||
} catch (error) {
|
||||
console.warn('Failed to read stored locale', error);
|
||||
}
|
||||
|
||||
const nextLocale = sanitizeLocale(stored, resolvedDefault);
|
||||
setLocaleState(nextLocale);
|
||||
setUserLocale(isLocaleCode(stored) ? stored : null);
|
||||
setHydrated(true);
|
||||
}, [storageKey, resolvedDefault]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!hydrated || userLocale !== null) {
|
||||
return;
|
||||
}
|
||||
setLocaleState(resolvedDefault);
|
||||
}, [hydrated, userLocale, resolvedDefault]);
|
||||
|
||||
const setLocale = React.useCallback(
|
||||
(next: LocaleCode) => {
|
||||
const safeLocale = sanitizeLocale(next, resolvedDefault);
|
||||
setLocaleState(safeLocale);
|
||||
setUserLocale(safeLocale);
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
window.localStorage.setItem(storageKey, safeLocale);
|
||||
} catch (error) {
|
||||
console.warn('Failed to persist locale', error);
|
||||
}
|
||||
}
|
||||
},
|
||||
[storageKey, resolvedDefault],
|
||||
);
|
||||
|
||||
const resetLocale = React.useCallback(() => {
|
||||
setUserLocale(null);
|
||||
setLocaleState(resolvedDefault);
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
window.localStorage.removeItem(storageKey);
|
||||
} catch (error) {
|
||||
console.warn('Failed to clear stored locale', error);
|
||||
}
|
||||
}
|
||||
}, [resolvedDefault, storageKey]);
|
||||
|
||||
const value = React.useMemo<LocaleContextValue>(
|
||||
() => ({
|
||||
locale,
|
||||
setLocale,
|
||||
resetLocale,
|
||||
hydrated,
|
||||
defaultLocale: resolvedDefault,
|
||||
storageKey,
|
||||
availableLocales: SUPPORTED_LOCALES,
|
||||
}),
|
||||
[locale, setLocale, resetLocale, hydrated, resolvedDefault, storageKey],
|
||||
);
|
||||
|
||||
return <LocaleContext.Provider value={value}>{children}</LocaleContext.Provider>;
|
||||
}
|
||||
|
||||
export function useLocale(): LocaleContextValue {
|
||||
const ctx = React.useContext(LocaleContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useLocale must be used within a LocaleProvider');
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function useOptionalLocale(): LocaleContextValue | undefined {
|
||||
return React.useContext(LocaleContext);
|
||||
}
|
||||
699
resources/js/guest/i18n/messages.ts
Normal file
699
resources/js/guest/i18n/messages.ts
Normal file
@@ -0,0 +1,699 @@
|
||||
export const SUPPORTED_LOCALES = [
|
||||
{ code: 'de', label: 'Deutsch', flag: '🇩🇪' },
|
||||
{ code: 'en', label: 'English', flag: '🇬🇧' },
|
||||
] as const;
|
||||
|
||||
export type LocaleCode = typeof SUPPORTED_LOCALES[number]['code'];
|
||||
|
||||
type NestedMessages = {
|
||||
[key: string]: string | NestedMessages;
|
||||
};
|
||||
|
||||
export const DEFAULT_LOCALE: LocaleCode = 'de';
|
||||
|
||||
export const messages: Record<LocaleCode, NestedMessages> = {
|
||||
de: {
|
||||
common: {
|
||||
hi: 'Hi',
|
||||
actions: {
|
||||
close: 'Schliessen',
|
||||
loading: 'Laedt...',
|
||||
},
|
||||
},
|
||||
navigation: {
|
||||
home: 'Start',
|
||||
tasks: 'Aufgaben',
|
||||
achievements: 'Erfolge',
|
||||
gallery: 'Galerie',
|
||||
},
|
||||
header: {
|
||||
loading: 'Lade Event...',
|
||||
stats: {
|
||||
online: 'online',
|
||||
tasksSolved: 'Aufgaben geloest',
|
||||
},
|
||||
},
|
||||
eventAccess: {
|
||||
loading: {
|
||||
title: 'Wir pruefen deinen Zugang...',
|
||||
subtitle: 'Einen Moment bitte.',
|
||||
},
|
||||
error: {
|
||||
invalid_token: {
|
||||
title: 'Zugriffscode ungueltig',
|
||||
description: 'Der eingegebene Code konnte nicht verifiziert werden.',
|
||||
ctaLabel: 'Neuen Code anfordern',
|
||||
},
|
||||
token_revoked: {
|
||||
title: 'Zugriffscode deaktiviert',
|
||||
description: 'Dieser Code wurde zurueckgezogen. Bitte fordere einen neuen Code an.',
|
||||
ctaLabel: 'Neuen Code anfordern',
|
||||
},
|
||||
token_expired: {
|
||||
title: 'Zugriffscode abgelaufen',
|
||||
description: 'Der Code ist nicht mehr gueltig. Aktualisiere deinen Code, um fortzufahren.',
|
||||
ctaLabel: 'Code aktualisieren',
|
||||
},
|
||||
token_rate_limited: {
|
||||
title: 'Zu viele Versuche',
|
||||
description: 'Es gab zu viele Eingaben in kurzer Zeit. Warte kurz und versuche es erneut.',
|
||||
hint: 'Tipp: Eine erneute Eingabe ist in wenigen Minuten moeglich.',
|
||||
},
|
||||
event_not_public: {
|
||||
title: 'Event nicht oeffentlich',
|
||||
description: 'Dieses Event ist aktuell nicht oeffentlich zugaenglich.',
|
||||
hint: 'Nimm Kontakt mit den Veranstalter:innen auf, um Zugang zu erhalten.',
|
||||
},
|
||||
network_error: {
|
||||
title: 'Verbindungsproblem',
|
||||
description: 'Wir konnten keine Verbindung zum Server herstellen. Pruefe deine Internetverbindung und versuche es erneut.',
|
||||
},
|
||||
server_error: {
|
||||
title: 'Server nicht erreichbar',
|
||||
description: 'Der Server reagiert derzeit nicht. Versuche es spaeter erneut.',
|
||||
},
|
||||
default: {
|
||||
title: 'Event nicht erreichbar',
|
||||
description: 'Wir konnten dein Event nicht laden. Bitte versuche es erneut.',
|
||||
ctaLabel: 'Zur Code-Eingabe',
|
||||
},
|
||||
},
|
||||
},
|
||||
profileSetup: {
|
||||
loading: 'Lade Event...',
|
||||
error: {
|
||||
default: 'Event nicht gefunden.',
|
||||
backToStart: 'Zurueck zur Startseite',
|
||||
},
|
||||
card: {
|
||||
description: 'Fange den schoensten Moment ein!',
|
||||
},
|
||||
form: {
|
||||
label: 'Dein Name (z.B. Anna)',
|
||||
placeholder: 'Dein Name',
|
||||
submit: 'Los gehts!',
|
||||
submitting: 'Speichere...',
|
||||
},
|
||||
},
|
||||
landing: {
|
||||
pageTitle: 'Willkommen bei der Fotobox!',
|
||||
headline: 'Willkommen bei der Fotobox!',
|
||||
subheadline: 'Dein Schluessel zu unvergesslichen Momenten.',
|
||||
join: {
|
||||
title: 'Event beitreten',
|
||||
description: 'Scanne den QR-Code oder gib den Code manuell ein.',
|
||||
button: 'Event beitreten',
|
||||
buttonLoading: 'Pruefe...',
|
||||
},
|
||||
scan: {
|
||||
start: 'QR-Code scannen',
|
||||
stop: 'Scanner stoppen',
|
||||
manualDivider: 'Oder manuell eingeben',
|
||||
},
|
||||
input: {
|
||||
placeholder: 'Event-Code eingeben',
|
||||
},
|
||||
errors: {
|
||||
eventClosed: 'Event nicht gefunden oder geschlossen.',
|
||||
network: 'Netzwerkfehler. Bitte spaeter erneut versuchen.',
|
||||
camera: 'Kamera-Zugriff fehlgeschlagen. Bitte erlaube den Zugriff und versuche es erneut.',
|
||||
},
|
||||
},
|
||||
home: {
|
||||
fallbackGuestName: 'Gast',
|
||||
hero: {
|
||||
subtitle: 'Willkommen zur Party',
|
||||
title: 'Hey {name}!',
|
||||
description: 'Du bist bereit fuer "{eventName}". Fang die Highlights des Events ein und teile sie mit allen Gaesten.',
|
||||
progress: {
|
||||
some: 'Schon {count} Aufgaben erledigt - weiter so!',
|
||||
none: 'Starte mit deiner ersten Aufgabe - wir zaehlen auf dich!',
|
||||
},
|
||||
defaultEventName: 'Dein Event',
|
||||
},
|
||||
stats: {
|
||||
online: 'Gleichzeitig online',
|
||||
tasksSolved: 'Aufgaben geloest',
|
||||
lastUpload: 'Letzter Upload',
|
||||
completedTasks: 'Deine erledigten Aufgaben',
|
||||
},
|
||||
actions: {
|
||||
title: 'Deine Aktionen',
|
||||
subtitle: 'Waehle aus, womit du starten willst',
|
||||
queueButton: 'Uploads in Warteschlange ansehen',
|
||||
items: {
|
||||
tasks: {
|
||||
label: 'Aufgabe ziehen',
|
||||
description: 'Hol dir deine naechste Challenge',
|
||||
},
|
||||
upload: {
|
||||
label: 'Direkt hochladen',
|
||||
description: 'Teile deine neuesten Fotos',
|
||||
},
|
||||
gallery: {
|
||||
label: 'Galerie ansehen',
|
||||
description: 'Lass dich von anderen inspirieren',
|
||||
},
|
||||
},
|
||||
},
|
||||
checklist: {
|
||||
title: 'Dein Fortschritt',
|
||||
description: 'Halte dich an diese drei kurzen Schritte fuer die besten Ergebnisse.',
|
||||
steps: {
|
||||
first: 'Aufgabe auswaehlen oder starten',
|
||||
second: 'Emotion festhalten und Foto schiessen',
|
||||
third: 'Bild hochladen und Credits sammeln',
|
||||
},
|
||||
},
|
||||
latestUpload: {
|
||||
none: 'Noch kein Upload',
|
||||
invalid: 'Noch kein Upload',
|
||||
justNow: 'Gerade eben',
|
||||
minutes: 'vor {count} Min',
|
||||
hours: 'vor {count} Std',
|
||||
days: 'vor {count} Tagen',
|
||||
},
|
||||
},
|
||||
notFound: {
|
||||
title: 'Nicht gefunden',
|
||||
description: 'Die Seite konnte nicht gefunden werden.',
|
||||
},
|
||||
uploadQueue: {
|
||||
title: 'Uploads',
|
||||
description: 'Warteschlange mit Fortschritt und erneuten Versuchen; Hintergrund-Sync umschalten.',
|
||||
},
|
||||
lightbox: {
|
||||
taskLabel: 'Aufgabe',
|
||||
loadingTask: 'Lade Aufgabe...',
|
||||
photoAlt: 'Foto {id}{suffix}',
|
||||
photoAltTaskSuffix: ' - {taskTitle}',
|
||||
fallbackTitle: 'Aufgabe {id}',
|
||||
unknownTitle: 'Unbekannte Aufgabe {id}',
|
||||
errors: {
|
||||
notFound: 'Foto nicht gefunden',
|
||||
loadFailed: 'Fehler beim Laden des Fotos',
|
||||
},
|
||||
},
|
||||
upload: {
|
||||
cameraTitle: 'Kamera',
|
||||
preparing: 'Aufgabe und Kamera werden vorbereitet ...',
|
||||
loadError: {
|
||||
title: 'Aufgabe konnte nicht geladen werden. Du kannst trotzdem ein Foto machen.',
|
||||
retry: 'Nochmal versuchen',
|
||||
},
|
||||
primer: {
|
||||
title: 'Bereit fuer dein Shooting?',
|
||||
body: {
|
||||
part1: 'Lass uns sicherstellen, dass alles sitzt: pruefe das Licht, wisch die Kamera sauber und richte alle Personen im Bild aus.',
|
||||
part2: 'Du kannst zwischen Front- und Rueckkamera wechseln und bei Bedarf ein Raster aktivieren.',
|
||||
},
|
||||
dismiss: 'Verstanden',
|
||||
},
|
||||
cameraUnsupported: {
|
||||
title: 'Kamera nicht verfuegbar',
|
||||
message: 'Dein Geraet unterstuetzt keine Kameravorschau im Browser. Du kannst stattdessen Fotos aus deiner Galerie hochladen.',
|
||||
openGallery: 'Foto aus Galerie waehlen',
|
||||
},
|
||||
cameraDenied: {
|
||||
title: 'Kamera-Zugriff verweigert',
|
||||
explanation: 'Du musst den Zugriff auf die Kamera erlauben, um Fotos aufnehmen zu koennen.',
|
||||
reopenPrompt: 'Systemdialog erneut oeffnen',
|
||||
chooseFile: 'Foto aus Galerie waehlen',
|
||||
prompt: 'Wir brauchen Zugriff auf deine Kamera. Erlaube die Anfrage oder waehle alternativ ein Foto aus deiner Galerie.',
|
||||
},
|
||||
cameraError: {
|
||||
title: 'Kamera konnte nicht gestartet werden',
|
||||
explanation: 'Wir konnten keine Verbindung zur Kamera herstellen. Pruefe die Berechtigungen oder starte dein Geraet neu.',
|
||||
tryAgain: 'Nochmals versuchen',
|
||||
},
|
||||
readyOverlay: {
|
||||
title: 'Kamera bereit',
|
||||
message: 'Wenn alle im Bild sind, kannst du den Countdown starten oder ein vorhandenes Foto waehlen.',
|
||||
start: 'Countdown starten',
|
||||
chooseFile: 'Foto auswaehlen',
|
||||
},
|
||||
taskInfo: {
|
||||
countdown: 'Countdown',
|
||||
emotion: 'Stimmung: {value}',
|
||||
instructionsPrefix: 'Hinweis',
|
||||
difficulty: {
|
||||
easy: 'Leicht',
|
||||
medium: 'Medium',
|
||||
hard: 'Herausfordernd',
|
||||
},
|
||||
timeEstimate: '{count} Min',
|
||||
fallbackTitle: 'Aufgabe {id}',
|
||||
fallbackDescription: 'Halte den Moment fest und teile ihn mit allen Gaesten.',
|
||||
fallbackInstructions: 'Positioniere alle im Bild, starte den Countdown und lass die Emotion wirken.',
|
||||
badge: 'Aufgabe #{id}',
|
||||
},
|
||||
countdown: {
|
||||
ready: 'Bereit machen ...',
|
||||
},
|
||||
review: {
|
||||
retake: 'Nochmal aufnehmen',
|
||||
keep: 'Foto verwenden',
|
||||
readyAnnouncement: 'Foto aufgenommen. Bitte Vorschau pruefen.',
|
||||
},
|
||||
status: {
|
||||
saving: 'Speichere Foto...',
|
||||
processing: 'Verarbeite Foto...',
|
||||
uploading: 'Foto wird hochgeladen...',
|
||||
preparing: 'Foto wird vorbereitet...',
|
||||
completed: 'Upload abgeschlossen.',
|
||||
failed: 'Upload fehlgeschlagen. Bitte versuche es erneut.',
|
||||
},
|
||||
controls: {
|
||||
toggleGrid: 'Raster umschalten',
|
||||
toggleCountdown: 'Countdown umschalten',
|
||||
toggleMirror: 'Spiegelung fuer Frontkamera umschalten',
|
||||
toggleFlash: 'Blitzpraeferenz umschalten',
|
||||
capture: 'Foto aufnehmen',
|
||||
switchCamera: 'Kamera wechseln',
|
||||
chooseFile: 'Foto auswaehlen',
|
||||
},
|
||||
limitReached: 'Upload-Limit erreicht ({used} / {max} Fotos). Bitte kontaktiere die Veranstalter fuer ein Upgrade.',
|
||||
limitUnlimited: 'unbegrenzt',
|
||||
cameraInactive: 'Kamera ist nicht aktiv. {hint}',
|
||||
cameraInactiveHint: 'Tippe auf "{label}", um loszulegen.',
|
||||
captureError: 'Foto konnte nicht erstellt werden.',
|
||||
feedError: 'Kamera liefert kein Bild. Bitte starte die Kamera neu.',
|
||||
canvasError: 'Canvas konnte nicht initialisiert werden.',
|
||||
limitCheckError: 'Fehler beim Pruefen des Upload-Limits. Upload deaktiviert.',
|
||||
galleryPickError: 'Auswahl fehlgeschlagen. Bitte versuche es erneut.',
|
||||
captureButton: 'Foto aufnehmen',
|
||||
galleryButton: 'Foto aus Galerie waehlen',
|
||||
switchCamera: 'Kamera wechseln',
|
||||
countdownLabel: 'Countdown: {seconds}s',
|
||||
countdownReady: 'Bereit machen ...',
|
||||
buttons: {
|
||||
startCamera: 'Kamera starten',
|
||||
tryAgain: 'Erneut versuchen',
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
title: 'Einstellungen',
|
||||
subtitle: 'Verwalte deinen Gastzugang, rechtliche Dokumente und lokale Daten.',
|
||||
language: {
|
||||
title: 'Sprache',
|
||||
description: 'Waehle deine bevorzugte Sprache fuer diese Veranstaltung.',
|
||||
activeBadge: 'aktiv',
|
||||
option: {
|
||||
de: 'Deutsch',
|
||||
en: 'English',
|
||||
},
|
||||
},
|
||||
name: {
|
||||
title: 'Dein Name',
|
||||
description: 'Passe an, wie wir dich im Event begruessen. Der Name wird nur lokal gespeichert.',
|
||||
label: 'Anzeigename',
|
||||
placeholder: 'z.B. Anna',
|
||||
save: 'Name speichern',
|
||||
saving: 'Speichere...',
|
||||
reset: 'Zuruecksetzen',
|
||||
saved: 'Gespeichert (ok)',
|
||||
loading: 'Lade gespeicherten Namen...',
|
||||
},
|
||||
legal: {
|
||||
title: 'Rechtliches',
|
||||
description: 'Die rechtlich verbindlichen Texte sind jederzeit hier abrufbar.',
|
||||
loading: 'Dokument wird geladen...',
|
||||
error: 'Das Dokument konnte nicht geladen werden. Bitte versuche es spaeter erneut.',
|
||||
fallbackTitle: 'Rechtlicher Hinweis',
|
||||
section: {
|
||||
impressum: 'Impressum',
|
||||
privacy: 'Datenschutz',
|
||||
terms: 'AGB',
|
||||
},
|
||||
},
|
||||
cache: {
|
||||
title: 'Offline Cache',
|
||||
description: 'Loesche lokale Daten, falls Inhalte veraltet erscheinen oder Uploads haengen bleiben.',
|
||||
clear: 'Cache leeren',
|
||||
clearing: 'Leere Cache...',
|
||||
cleared: 'Cache geloescht.',
|
||||
note: 'Dies betrifft nur diesen Browser und muss pro Geraet erneut ausgefuehrt werden.',
|
||||
},
|
||||
footer: {
|
||||
notice: 'Gastbereich - Daten werden lokal im Browser gespeichert.',
|
||||
},
|
||||
sheet: {
|
||||
openLabel: 'Einstellungen oeffnen',
|
||||
backLabel: 'Zurueck',
|
||||
legalDescription: 'Rechtlicher Hinweis',
|
||||
},
|
||||
},
|
||||
},
|
||||
en: {
|
||||
common: {
|
||||
hi: 'Hi',
|
||||
actions: {
|
||||
close: 'Close',
|
||||
loading: 'Loading...',
|
||||
},
|
||||
},
|
||||
navigation: {
|
||||
home: 'Home',
|
||||
tasks: 'Tasks',
|
||||
achievements: 'Achievements',
|
||||
gallery: 'Gallery',
|
||||
},
|
||||
header: {
|
||||
loading: 'Loading event...',
|
||||
stats: {
|
||||
online: 'online',
|
||||
tasksSolved: 'tasks solved',
|
||||
},
|
||||
},
|
||||
eventAccess: {
|
||||
loading: {
|
||||
title: 'Checking your access...',
|
||||
subtitle: 'Hang tight for a moment.',
|
||||
},
|
||||
error: {
|
||||
invalid_token: {
|
||||
title: 'Access code invalid',
|
||||
description: 'We could not verify the code you entered.',
|
||||
ctaLabel: 'Request new code',
|
||||
},
|
||||
token_revoked: {
|
||||
title: 'Access code revoked',
|
||||
description: 'This code was revoked. Please request a new one.',
|
||||
ctaLabel: 'Request new code',
|
||||
},
|
||||
token_expired: {
|
||||
title: 'Access code expired',
|
||||
description: 'The code is no longer valid. Refresh your code to continue.',
|
||||
ctaLabel: 'Refresh code',
|
||||
},
|
||||
token_rate_limited: {
|
||||
title: 'Too many attempts',
|
||||
description: 'There were too many attempts in a short time. Wait a bit and try again.',
|
||||
hint: 'Tip: You can retry in a few minutes.',
|
||||
},
|
||||
event_not_public: {
|
||||
title: 'Event not public',
|
||||
description: 'This event is not publicly accessible right now.',
|
||||
hint: 'Contact the organizers to get access.',
|
||||
},
|
||||
network_error: {
|
||||
title: 'Connection issue',
|
||||
description: 'We could not reach the server. Check your connection and try again.',
|
||||
},
|
||||
server_error: {
|
||||
title: 'Server unavailable',
|
||||
description: 'The server is currently unavailable. Please try again later.',
|
||||
},
|
||||
default: {
|
||||
title: 'Event unavailable',
|
||||
description: 'We could not load your event. Please try again.',
|
||||
ctaLabel: 'Back to code entry',
|
||||
},
|
||||
},
|
||||
},
|
||||
profileSetup: {
|
||||
loading: 'Loading event...',
|
||||
error: {
|
||||
default: 'Event not found.',
|
||||
backToStart: 'Back to start',
|
||||
},
|
||||
card: {
|
||||
description: 'Capture the best moment!',
|
||||
},
|
||||
form: {
|
||||
label: 'Your name (e.g. Anna)',
|
||||
placeholder: 'Your name',
|
||||
submit: "Let's go!",
|
||||
submitting: 'Saving...',
|
||||
},
|
||||
},
|
||||
landing: {
|
||||
pageTitle: 'Welcome to the photo booth!',
|
||||
headline: 'Welcome to the photo booth!',
|
||||
subheadline: 'Your key to unforgettable moments.',
|
||||
join: {
|
||||
title: 'Join the event',
|
||||
description: 'Scan the QR code or enter the code manually.',
|
||||
button: 'Join event',
|
||||
buttonLoading: 'Checking...',
|
||||
},
|
||||
scan: {
|
||||
start: 'Scan QR code',
|
||||
stop: 'Stop scanner',
|
||||
manualDivider: 'Or enter it manually',
|
||||
},
|
||||
input: {
|
||||
placeholder: 'Enter event code',
|
||||
},
|
||||
errors: {
|
||||
eventClosed: 'Event not found or closed.',
|
||||
network: 'Network error. Please try again later.',
|
||||
camera: 'Camera access failed. Allow access and try again.',
|
||||
},
|
||||
},
|
||||
home: {
|
||||
fallbackGuestName: 'Guest',
|
||||
hero: {
|
||||
subtitle: 'Welcome to the party',
|
||||
title: 'Hey {name}!',
|
||||
description: 'You are ready for "{eventName}". Capture the highlights and share them with everyone.',
|
||||
progress: {
|
||||
some: 'Already {count} tasks done - keep going!',
|
||||
none: 'Start with your first task - we are counting on you!',
|
||||
},
|
||||
defaultEventName: 'Your event',
|
||||
},
|
||||
stats: {
|
||||
online: 'Guests online',
|
||||
tasksSolved: 'Tasks completed',
|
||||
lastUpload: 'Latest upload',
|
||||
completedTasks: 'Your completed tasks',
|
||||
},
|
||||
actions: {
|
||||
title: 'Your actions',
|
||||
subtitle: 'Choose how you want to start',
|
||||
queueButton: 'View uploads in queue',
|
||||
items: {
|
||||
tasks: {
|
||||
label: 'Draw a task',
|
||||
description: 'Grab your next challenge',
|
||||
},
|
||||
upload: {
|
||||
label: 'Upload directly',
|
||||
description: 'Share your latest photos',
|
||||
},
|
||||
gallery: {
|
||||
label: 'Browse gallery',
|
||||
description: 'Get inspired by others',
|
||||
},
|
||||
},
|
||||
},
|
||||
checklist: {
|
||||
title: 'Your progress',
|
||||
description: 'Follow these three quick steps for the best results.',
|
||||
steps: {
|
||||
first: 'Pick or start a task',
|
||||
second: 'Capture the emotion and take the photo',
|
||||
third: 'Upload the picture and earn credits',
|
||||
},
|
||||
},
|
||||
latestUpload: {
|
||||
none: 'No upload yet',
|
||||
invalid: 'No upload yet',
|
||||
justNow: 'Just now',
|
||||
minutes: '{count} min ago',
|
||||
hours: '{count} h ago',
|
||||
days: '{count} days ago',
|
||||
},
|
||||
},
|
||||
notFound: {
|
||||
title: 'Not found',
|
||||
description: 'We could not find the page you requested.',
|
||||
},
|
||||
uploadQueue: {
|
||||
title: 'Uploads',
|
||||
description: 'Queue with progress/retry and background sync toggle.',
|
||||
},
|
||||
lightbox: {
|
||||
taskLabel: 'Task',
|
||||
loadingTask: 'Loading task...',
|
||||
photoAlt: 'Photo {id}{suffix}',
|
||||
photoAltTaskSuffix: ' - {taskTitle}',
|
||||
fallbackTitle: 'Task {id}',
|
||||
unknownTitle: 'Unknown task {id}',
|
||||
errors: {
|
||||
notFound: 'Photo not found',
|
||||
loadFailed: 'Failed to load photo',
|
||||
},
|
||||
},
|
||||
upload: {
|
||||
cameraTitle: 'Camera',
|
||||
preparing: 'Preparing task and camera ...',
|
||||
loadError: {
|
||||
title: 'Task could not be loaded. You can still take a photo.',
|
||||
retry: 'Try again',
|
||||
},
|
||||
primer: {
|
||||
title: 'Ready for your shoot?',
|
||||
body: {
|
||||
part1: 'Make sure everything is set: check the lighting, clean the lens, and line everyone up in the frame.',
|
||||
part2: 'You can switch between front and back camera and enable the grid if needed.',
|
||||
},
|
||||
dismiss: 'Got it',
|
||||
},
|
||||
cameraUnsupported: {
|
||||
title: 'Camera not available',
|
||||
message: 'Your device does not support live camera preview in this browser. You can upload photos from your gallery instead.',
|
||||
openGallery: 'Choose photo from gallery',
|
||||
},
|
||||
cameraDenied: {
|
||||
title: 'Camera access denied',
|
||||
explanation: 'Allow camera access to capture photos.',
|
||||
reopenPrompt: 'Open system dialog again',
|
||||
chooseFile: 'Choose photo from gallery',
|
||||
prompt: 'We need access to your camera. Allow the request or pick a photo from your gallery.',
|
||||
},
|
||||
cameraError: {
|
||||
title: 'Camera could not be started',
|
||||
explanation: 'We could not connect to the camera. Check permissions or restart your device.',
|
||||
tryAgain: 'Try again',
|
||||
},
|
||||
readyOverlay: {
|
||||
title: 'Camera ready',
|
||||
message: 'Once everyone is in frame, start the countdown or pick an existing photo.',
|
||||
start: 'Start countdown',
|
||||
chooseFile: 'Choose photo',
|
||||
},
|
||||
taskInfo: {
|
||||
countdown: 'Countdown',
|
||||
emotion: 'Emotion: {value}',
|
||||
instructionsPrefix: 'Hint',
|
||||
difficulty: {
|
||||
easy: 'Easy',
|
||||
medium: 'Medium',
|
||||
hard: 'Challenging',
|
||||
},
|
||||
timeEstimate: '{count} min',
|
||||
fallbackTitle: 'Task {id}',
|
||||
fallbackDescription: 'Capture the moment and share it with everyone.',
|
||||
fallbackInstructions: 'Line everyone up, start the countdown, and let the emotion shine.',
|
||||
badge: 'Task #{id}',
|
||||
},
|
||||
countdown: {
|
||||
ready: 'Get ready ...',
|
||||
},
|
||||
review: {
|
||||
retake: 'Retake photo',
|
||||
keep: 'Use this photo',
|
||||
readyAnnouncement: 'Photo captured. Please review the preview.',
|
||||
},
|
||||
status: {
|
||||
saving: 'Saving photo...',
|
||||
processing: 'Processing photo...',
|
||||
uploading: 'Uploading photo...',
|
||||
preparing: 'Preparing photo...',
|
||||
completed: 'Upload complete.',
|
||||
failed: 'Upload failed. Please try again.',
|
||||
},
|
||||
controls: {
|
||||
toggleGrid: 'Toggle grid',
|
||||
toggleCountdown: 'Toggle countdown',
|
||||
toggleMirror: 'Toggle mirroring for front camera',
|
||||
toggleFlash: 'Toggle flash',
|
||||
capture: 'Capture photo',
|
||||
switchCamera: 'Switch camera',
|
||||
chooseFile: 'Choose photo',
|
||||
},
|
||||
limitReached: 'Upload limit reached ({used} / {max} photos). Contact the organizers for an upgrade.',
|
||||
limitUnlimited: 'unlimited',
|
||||
cameraInactive: 'Camera is not active. {hint}',
|
||||
cameraInactiveHint: 'Tap "{label}" to get started.',
|
||||
captureError: 'Photo could not be created.',
|
||||
feedError: 'Camera feed not available. Please restart the camera.',
|
||||
canvasError: 'Canvas could not be initialised.',
|
||||
limitCheckError: 'Failed to check upload limits. Upload disabled.',
|
||||
galleryPickError: 'Selection failed. Please try again.',
|
||||
captureButton: 'Capture photo',
|
||||
galleryButton: 'Choose from gallery',
|
||||
switchCamera: 'Switch camera',
|
||||
countdownLabel: 'Countdown: {seconds}s',
|
||||
countdownReady: 'Get ready ...',
|
||||
buttons: {
|
||||
startCamera: 'Start camera',
|
||||
tryAgain: 'Try again',
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
title: 'Settings',
|
||||
subtitle: 'Manage your guest access, legal documents, and local data.',
|
||||
language: {
|
||||
title: 'Language',
|
||||
description: 'Choose your preferred language for this event.',
|
||||
activeBadge: 'active',
|
||||
option: {
|
||||
de: 'German',
|
||||
en: 'English',
|
||||
},
|
||||
},
|
||||
name: {
|
||||
title: 'Your name',
|
||||
description: 'Update how we greet you inside the event. The name is stored locally only.',
|
||||
label: 'Display name',
|
||||
placeholder: 'e.g. Anna',
|
||||
save: 'Save name',
|
||||
saving: 'Saving...',
|
||||
reset: 'Reset',
|
||||
saved: 'Saved',
|
||||
loading: 'Loading saved name...',
|
||||
},
|
||||
legal: {
|
||||
title: 'Legal',
|
||||
description: 'The legally binding documents are always available here.',
|
||||
loading: 'Loading document...',
|
||||
error: 'The document could not be loaded. Please try again later.',
|
||||
fallbackTitle: 'Legal notice',
|
||||
section: {
|
||||
impressum: 'Imprint',
|
||||
privacy: 'Privacy',
|
||||
terms: 'Terms',
|
||||
},
|
||||
},
|
||||
cache: {
|
||||
title: 'Offline cache',
|
||||
description: 'Clear local data if content looks outdated or uploads get stuck.',
|
||||
clear: 'Clear cache',
|
||||
clearing: 'Clearing cache...',
|
||||
cleared: 'Cache cleared.',
|
||||
note: 'This only affects this browser and must be repeated per device.',
|
||||
},
|
||||
footer: {
|
||||
notice: 'Guest area - data is stored locally in the browser.',
|
||||
},
|
||||
sheet: {
|
||||
openLabel: 'Open settings',
|
||||
backLabel: 'Back',
|
||||
legalDescription: 'Legal notice',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export function isLocaleCode(value: string | null | undefined): value is LocaleCode {
|
||||
return SUPPORTED_LOCALES.some((item) => item.code === value);
|
||||
}
|
||||
|
||||
export function translate(locale: LocaleCode, key: string): string | undefined {
|
||||
const segments = key.split('.');
|
||||
const tryLocale = (loc: LocaleCode): string | undefined => {
|
||||
let current: unknown = messages[loc];
|
||||
for (const segment of segments) {
|
||||
if (!current || typeof current !== 'object' || !(segment in current)) {
|
||||
return undefined;
|
||||
}
|
||||
current = (current as NestedMessages)[segment];
|
||||
}
|
||||
return typeof current === 'string' ? current : undefined;
|
||||
};
|
||||
|
||||
return tryLocale(locale) ?? tryLocale(DEFAULT_LOCALE);
|
||||
}
|
||||
21
resources/js/guest/i18n/useTranslation.ts
Normal file
21
resources/js/guest/i18n/useTranslation.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
import { translate, DEFAULT_LOCALE, type LocaleCode } from './messages';
|
||||
import { useLocale } from './LocaleContext';
|
||||
|
||||
export type TranslateFn = (key: string, fallback?: string) => string;
|
||||
|
||||
function resolveTranslation(locale: LocaleCode, key: string, fallback?: string): string {
|
||||
return translate(locale, key) ?? translate(DEFAULT_LOCALE, key) ?? fallback ?? key;
|
||||
}
|
||||
|
||||
export function useTranslation() {
|
||||
const { locale } = useLocale();
|
||||
|
||||
const t = React.useCallback<TranslateFn>(
|
||||
(key, fallback) => resolveTranslation(locale, key, fallback),
|
||||
[locale],
|
||||
);
|
||||
|
||||
return React.useMemo(() => ({ t, locale }), [t, locale]);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { router } from './router';
|
||||
import '../../css/app.css';
|
||||
import { initializeTheme } from '@/hooks/use-appearance';
|
||||
import { ToastProvider } from './components/ToastHost';
|
||||
import { LocaleProvider } from './i18n/LocaleContext';
|
||||
|
||||
initializeTheme();
|
||||
const rootEl = document.getElementById('root')!;
|
||||
@@ -25,9 +26,10 @@ if ('serviceWorker' in navigator) {
|
||||
}
|
||||
createRoot(rootEl).render(
|
||||
<React.StrictMode>
|
||||
<ToastProvider>
|
||||
<RouterProvider router={router} />
|
||||
</ToastProvider>
|
||||
<LocaleProvider>
|
||||
<ToastProvider>
|
||||
<RouterProvider router={router} />
|
||||
</ToastProvider>
|
||||
</LocaleProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useEventStats } from '../context/EventStatsContext';
|
||||
import { useEventData } from '../hooks/useEventData';
|
||||
import { useGuestTaskProgress } from '../hooks/useGuestTaskProgress';
|
||||
import { Sparkles, UploadCloud, Images, CheckCircle2, Users, TimerReset } from 'lucide-react';
|
||||
import { useTranslation, type TranslateFn } from '../i18n/useTranslation';
|
||||
|
||||
export default function HomePage() {
|
||||
const { token } = useParams<{ token: string }>();
|
||||
@@ -17,63 +18,76 @@ export default function HomePage() {
|
||||
const stats = useEventStats();
|
||||
const { event } = useEventData();
|
||||
const { completedCount } = useGuestTaskProgress(token);
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!token) return null;
|
||||
|
||||
const displayName = hydrated && name ? name : 'Gast';
|
||||
const latestUploadText = formatLatestUpload(stats.latestPhotoAt);
|
||||
const displayName = hydrated && name ? name : t('home.fallbackGuestName');
|
||||
const eventNameDisplay = event?.name ?? t('home.hero.defaultEventName');
|
||||
const latestUploadText = formatLatestUpload(stats.latestPhotoAt, t);
|
||||
|
||||
const primaryActions: Array<{ to: string; label: string; description: string; icon: React.ReactNode }> = [
|
||||
{
|
||||
to: 'tasks',
|
||||
label: 'Aufgabe ziehen',
|
||||
description: 'Hol dir deine naechste Challenge',
|
||||
icon: <Sparkles className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
to: 'upload',
|
||||
label: 'Direkt hochladen',
|
||||
description: 'Teile deine neuesten Fotos',
|
||||
icon: <UploadCloud className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
to: 'gallery',
|
||||
label: 'Galerie ansehen',
|
||||
description: 'Lass dich von anderen inspirieren',
|
||||
icon: <Images className="h-5 w-5" />,
|
||||
},
|
||||
];
|
||||
const primaryActions = React.useMemo(
|
||||
() => [
|
||||
{
|
||||
to: 'tasks',
|
||||
label: t('home.actions.items.tasks.label'),
|
||||
description: t('home.actions.items.tasks.description'),
|
||||
icon: <Sparkles className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
to: 'upload',
|
||||
label: t('home.actions.items.upload.label'),
|
||||
description: t('home.actions.items.upload.description'),
|
||||
icon: <UploadCloud className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
to: 'gallery',
|
||||
label: t('home.actions.items.gallery.label'),
|
||||
description: t('home.actions.items.gallery.description'),
|
||||
icon: <Images className="h-5 w-5" />,
|
||||
},
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
const checklistItems = [
|
||||
'Aufgabe auswaehlen oder starten',
|
||||
'Emotion festhalten und Foto schiessen',
|
||||
'Bild hochladen und Credits sammeln',
|
||||
];
|
||||
const checklistItems = React.useMemo(
|
||||
() => [
|
||||
t('home.checklist.steps.first'),
|
||||
t('home.checklist.steps.second'),
|
||||
t('home.checklist.steps.third'),
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-24">
|
||||
<HeroCard name={displayName} eventName={event?.name ?? 'Dein Event'} tasksCompleted={completedCount} />
|
||||
<HeroCard
|
||||
name={displayName}
|
||||
eventName={eventNameDisplay}
|
||||
tasksCompleted={completedCount}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardContent className="grid grid-cols-1 gap-4 py-4 sm:grid-cols-4">
|
||||
<StatTile
|
||||
icon={<Users className="h-4 w-4" />}
|
||||
label="Gleichzeitig online"
|
||||
label={t('home.stats.online')}
|
||||
value={`${stats.onlineGuests}`}
|
||||
/>
|
||||
<StatTile
|
||||
icon={<Sparkles className="h-4 w-4" />}
|
||||
label="Aufgaben gelöst"
|
||||
label={t('home.stats.tasksSolved')}
|
||||
value={`${stats.tasksSolved}`}
|
||||
/>
|
||||
<StatTile
|
||||
icon={<TimerReset className="h-4 w-4" />}
|
||||
label="Letzter Upload"
|
||||
label={t('home.stats.lastUpload')}
|
||||
value={latestUploadText}
|
||||
/>
|
||||
<StatTile
|
||||
icon={<CheckCircle2 className="h-4 w-4" />}
|
||||
label="Deine erledigten Aufgaben"
|
||||
label={t('home.stats.completedTasks')}
|
||||
value={`${completedCount}`}
|
||||
/>
|
||||
</CardContent>
|
||||
@@ -81,8 +95,10 @@ export default function HomePage() {
|
||||
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">Deine Aktionen</h2>
|
||||
<span className="text-xs text-muted-foreground">Waehle aus, womit du starten willst</span>
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{t('home.actions.title')}
|
||||
</h2>
|
||||
<span className="text-xs text-muted-foreground">{t('home.actions.subtitle')}</span>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{primaryActions.map((action) => (
|
||||
@@ -102,14 +118,14 @@ export default function HomePage() {
|
||||
))}
|
||||
</div>
|
||||
<Button variant="outline" asChild className="w-full">
|
||||
<Link to="queue">Uploads in Warteschlange ansehen</Link>
|
||||
<Link to="queue">{t('home.actions.queueButton')}</Link>
|
||||
</Button>
|
||||
</section>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Dein Fortschritt</CardTitle>
|
||||
<CardDescription>Halte dich an diese drei kurzen Schritte fuer die besten Ergebnisse.</CardDescription>
|
||||
<CardTitle>{t('home.checklist.title')}</CardTitle>
|
||||
<CardDescription>{t('home.checklist.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{checklistItems.map((item) => (
|
||||
@@ -130,17 +146,29 @@ export default function HomePage() {
|
||||
);
|
||||
}
|
||||
|
||||
function HeroCard({ name, eventName, tasksCompleted }: { name: string; eventName: string; tasksCompleted: number }) {
|
||||
function HeroCard({
|
||||
name,
|
||||
eventName,
|
||||
tasksCompleted,
|
||||
t,
|
||||
}: {
|
||||
name: string;
|
||||
eventName: string;
|
||||
tasksCompleted: number;
|
||||
t: TranslateFn;
|
||||
}) {
|
||||
const heroTitle = t('home.hero.title').replace('{name}', name);
|
||||
const heroDescription = t('home.hero.description').replace('{eventName}', eventName);
|
||||
const progressMessage = tasksCompleted > 0
|
||||
? `Schon ${tasksCompleted} Aufgaben erledigt - weiter so!`
|
||||
: 'Starte mit deiner ersten Aufgabe - wir zählen auf dich!';
|
||||
? t('home.hero.progress.some').replace('{count}', `${tasksCompleted}`)
|
||||
: t('home.hero.progress.none');
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden border-0 bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 text-white shadow-md">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardDescription className="text-sm text-white/80">Willkommen zur Party</CardDescription>
|
||||
<CardTitle className="text-2xl font-bold">Hey {name}!</CardTitle>
|
||||
<p className="text-sm text-white/80">Du bist bereit für "{eventName}". Fang die Highlights des Events ein und teile sie mit allen Gästen.</p>
|
||||
<CardDescription className="text-sm text-white/80">{t('home.hero.subtitle')}</CardDescription>
|
||||
<CardTitle className="text-2xl font-bold">{heroTitle}</CardTitle>
|
||||
<p className="text-sm text-white/80">{heroDescription}</p>
|
||||
<p className="text-sm font-medium text-white/90">{progressMessage}</p>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
@@ -161,26 +189,26 @@ function StatTile({ icon, label, value }: { icon: React.ReactNode; label: string
|
||||
);
|
||||
}
|
||||
|
||||
function formatLatestUpload(isoDate: string | null) {
|
||||
function formatLatestUpload(isoDate: string | null, t: TranslateFn) {
|
||||
if (!isoDate) {
|
||||
return 'Noch kein Upload';
|
||||
return t('home.latestUpload.none');
|
||||
}
|
||||
const date = new Date(isoDate);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return 'Noch kein Upload';
|
||||
return t('home.latestUpload.invalid');
|
||||
}
|
||||
const diffMs = Date.now() - date.getTime();
|
||||
const diffMinutes = Math.round(diffMs / 60000);
|
||||
if (diffMinutes < 1) {
|
||||
return 'Gerade eben';
|
||||
return t('home.latestUpload.justNow');
|
||||
}
|
||||
if (diffMinutes < 60) {
|
||||
return `vor ${diffMinutes} Min`;
|
||||
return t('home.latestUpload.minutes').replace('{count}', `${diffMinutes}`);
|
||||
}
|
||||
const diffHours = Math.round(diffMinutes / 60);
|
||||
if (diffHours < 24) {
|
||||
return `vor ${diffHours} Std`;
|
||||
return t('home.latestUpload.hours').replace('{count}', `${diffHours}`);
|
||||
}
|
||||
const diffDays = Math.round(diffHours / 24);
|
||||
return `vor ${diffDays} Tagen`;
|
||||
return t('home.latestUpload.days').replace('{count}', `${diffDays}`);
|
||||
}
|
||||
|
||||
@@ -7,14 +7,19 @@ import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Html5Qrcode } from 'html5-qrcode';
|
||||
import { readGuestName } from '../context/GuestIdentityContext';
|
||||
import { useTranslation } from '../i18n/useTranslation';
|
||||
|
||||
type LandingErrorKey = 'eventClosed' | 'network' | 'camera';
|
||||
|
||||
export default function LandingPage() {
|
||||
const nav = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const [eventCode, setEventCode] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [errorKey, setErrorKey] = useState<LandingErrorKey | null>(null);
|
||||
const [isScanning, setIsScanning] = useState(false);
|
||||
const [scanner, setScanner] = useState<Html5Qrcode | null>(null);
|
||||
const errorMessage = errorKey ? t(`landing.errors.${errorKey}`) : null;
|
||||
|
||||
function extractEventKey(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
@@ -48,11 +53,11 @@ export default function LandingPage() {
|
||||
const normalized = extractEventKey(provided);
|
||||
if (!normalized) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setErrorKey(null);
|
||||
try {
|
||||
const res = await fetch(`/api/v1/events/${encodeURIComponent(normalized)}`);
|
||||
if (!res.ok) {
|
||||
setError('Event nicht gefunden oder geschlossen.');
|
||||
setErrorKey('eventClosed');
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
@@ -65,7 +70,7 @@ export default function LandingPage() {
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Join request failed', e);
|
||||
setError('Netzwerkfehler. Bitte spaeter erneut versuchen.');
|
||||
setErrorKey('network');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -80,7 +85,7 @@ export default function LandingPage() {
|
||||
setIsScanning(true);
|
||||
} catch (err) {
|
||||
console.error('Scanner start failed', err);
|
||||
setError('Kamera-Zugriff fehlgeschlagen. Bitte erlaube den Zugriff und versuche es erneut.');
|
||||
setErrorKey('camera');
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -92,7 +97,7 @@ export default function LandingPage() {
|
||||
setIsScanning(true);
|
||||
} catch (err) {
|
||||
console.error('Scanner initialisation failed', err);
|
||||
setError('Kamera-Zugriff fehlgeschlagen. Bitte erlaube den Zugriff und versuche es erneut.');
|
||||
setErrorKey('camera');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,22 +128,22 @@ export default function LandingPage() {
|
||||
}, [scanner]);
|
||||
|
||||
return (
|
||||
<Page title="Willkommen bei der Fotobox!">
|
||||
{error && (
|
||||
<Page title={t('landing.pageTitle')}>
|
||||
{errorMessage && (
|
||||
<Alert className="mb-3" variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
<AlertDescription>{errorMessage}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<div className="space-y-6 pb-20">
|
||||
<div className="text-center space-y-2">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Willkommen bei der Fotobox!</h1>
|
||||
<p className="text-lg text-gray-600">Dein Schluessel zu unvergesslichen Momenten.</p>
|
||||
<h1 className="text-2xl font-bold text-gray-900">{t('landing.headline')}</h1>
|
||||
<p className="text-lg text-gray-600">{t('landing.subheadline')}</p>
|
||||
</div>
|
||||
|
||||
<Card className="mx-auto w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-xl font-semibold">Event beitreten</CardTitle>
|
||||
<CardDescription>Scanne den QR-Code oder gib den Code manuell ein.</CardDescription>
|
||||
<CardTitle className="text-xl font-semibold">{t('landing.join.title')}</CardTitle>
|
||||
<CardDescription>{t('landing.join.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 p-6">
|
||||
<div className="flex flex-col items-center space-y-3">
|
||||
@@ -155,20 +160,20 @@ export default function LandingPage() {
|
||||
onClick={isScanning ? stopScanner : startScanner}
|
||||
disabled={loading}
|
||||
>
|
||||
{isScanning ? 'Scanner stoppen' : 'QR-Code scannen'}
|
||||
{isScanning ? t('landing.scan.stop') : t('landing.scan.start')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 py-2 text-center text-sm text-gray-500">
|
||||
Oder manuell eingeben
|
||||
{t('landing.scan.manualDivider')}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
value={eventCode}
|
||||
onChange={(event) => setEventCode(event.target.value)}
|
||||
placeholder="Event-Code eingeben"
|
||||
placeholder={t('landing.input.placeholder')}
|
||||
disabled={loading}
|
||||
/>
|
||||
<Button
|
||||
@@ -176,7 +181,7 @@ export default function LandingPage() {
|
||||
disabled={loading || !eventCode.trim()}
|
||||
onClick={() => join()}
|
||||
>
|
||||
{loading ? 'Pruefe...' : 'Event beitreten'}
|
||||
{loading ? t('landing.join.buttonLoading') : t('landing.join.button')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import React from 'react';
|
||||
import { Page } from './_util';
|
||||
import { useTranslation } from '../i18n/useTranslation';
|
||||
|
||||
export default function NotFoundPage() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Page title="Nicht gefunden">
|
||||
<p>Die Seite konnte nicht gefunden werden.</p>
|
||||
<Page title={t('notFound.title')}>
|
||||
<p>{t('notFound.description')}</p>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Dialog, DialogContent } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Heart, ChevronLeft, ChevronRight, X } from 'lucide-react';
|
||||
import { likePhoto } from '../services/photosApi';
|
||||
import { useTranslation } from '../i18n/useTranslation';
|
||||
|
||||
type Photo = {
|
||||
id: number;
|
||||
@@ -31,6 +32,7 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh
|
||||
const navigate = useNavigate();
|
||||
const photoId = params.photoId;
|
||||
const eventSlug = params.token || slug;
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [standalonePhoto, setStandalonePhoto] = useState<Photo | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -65,10 +67,10 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh
|
||||
setStandalonePhoto(location.state.photo);
|
||||
}
|
||||
} else {
|
||||
setError('Foto nicht gefunden');
|
||||
setError(t('lightbox.errors.notFound'));
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Fehler beim Laden des Fotos');
|
||||
setError(t('lightbox.errors.loadFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -78,7 +80,7 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh
|
||||
} else if (!isStandalone) {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [isStandalone, photoId, eventSlug, standalonePhoto, location.state]);
|
||||
}, [isStandalone, photoId, eventSlug, standalonePhoto, location.state, t]);
|
||||
|
||||
// Update likes when photo changes
|
||||
React.useEffect(() => {
|
||||
@@ -149,31 +151,31 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh
|
||||
if (foundTask) {
|
||||
setTask({
|
||||
id: foundTask.id,
|
||||
title: foundTask.title || `Aufgabe ${taskId}`
|
||||
title: foundTask.title || t('lightbox.fallbackTitle').replace('{id}', `${taskId}`)
|
||||
});
|
||||
} else {
|
||||
setTask({
|
||||
id: taskId,
|
||||
title: `Unbekannte Aufgabe ${taskId}`
|
||||
title: t('lightbox.unknownTitle').replace('{id}', `${taskId}`)
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setTask({
|
||||
id: taskId,
|
||||
title: `Unbekannte Aufgabe ${taskId}`
|
||||
title: t('lightbox.unknownTitle').replace('{id}', `${taskId}`)
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load task:', error);
|
||||
setTask({
|
||||
id: taskId,
|
||||
title: `Unbekannte Aufgabe ${taskId}`
|
||||
title: t('lightbox.unknownTitle').replace('{id}', `${taskId}`)
|
||||
});
|
||||
} finally {
|
||||
setTaskLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [photo?.task_id, eventSlug]);
|
||||
}, [photo?.task_id, eventSlug, t]);
|
||||
|
||||
async function onLike() {
|
||||
if (liked || !photo) return;
|
||||
@@ -251,9 +253,9 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh
|
||||
{task && (
|
||||
<div className="absolute bottom-4 left-4 right-4 z-20 bg-black/60 backdrop-blur-sm rounded-xl p-3 border border-white/20 max-w-md">
|
||||
<div className="text-sm">
|
||||
<div className="font-semibold mb-1 text-white">Task: {task.title}</div>
|
||||
<div className="font-semibold mb-1 text-white">{t('lightbox.taskLabel')}: {task.title}</div>
|
||||
{taskLoading && (
|
||||
<div className="text-xs opacity-70 text-gray-300">Lade Aufgabe...</div>
|
||||
<div className="text-xs opacity-70 text-gray-300">{t('lightbox.loadingTask')}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -269,7 +271,14 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh
|
||||
>
|
||||
<img
|
||||
src={photo?.file_path || photo?.thumbnail_path}
|
||||
alt={`Foto ${photo?.id || ''}${photo?.task_title ? ` - ${photo.task_title}` : ''}`}
|
||||
alt={t('lightbox.photoAlt')
|
||||
.replace('{id}', `${photo?.id ?? ''}`)
|
||||
.replace(
|
||||
'{suffix}',
|
||||
photo?.task_title
|
||||
? t('lightbox.photoAltTaskSuffix').replace('{taskTitle}', photo.task_title)
|
||||
: ''
|
||||
)}
|
||||
className="max-h-[80vh] max-w-full object-contain transition-transform duration-200"
|
||||
onError={(e) => {
|
||||
console.error('Image load error:', e);
|
||||
@@ -283,7 +292,7 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh
|
||||
<div className="absolute top-4 left-4 right-4 z-20 bg-black/60 backdrop-blur-sm rounded-xl p-3 border border-white/20 max-w-md">
|
||||
<div className="text-sm text-center">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mx-auto mb-1"></div>
|
||||
<div className="text-xs opacity-70">Lade Aufgabe...</div>
|
||||
<div className="text-xs opacity-70">{t('lightbox.loadingTask')}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import Header from '../components/Header';
|
||||
import { useTranslation } from '../i18n/useTranslation';
|
||||
|
||||
export default function ProfileSetupPage() {
|
||||
const { token } = useParams<{ token: string }>();
|
||||
@@ -15,6 +15,7 @@ export default function ProfileSetupPage() {
|
||||
const { name: storedName, setName: persistName, hydrated } = useGuestIdentity();
|
||||
const [name, setName] = useState(storedName);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
@@ -51,7 +52,7 @@ export default function ProfileSetupPage() {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-32">
|
||||
<div className="text-lg">Lade Event...</div>
|
||||
<div className="text-lg">{t('profileSetup.loading')}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -59,31 +60,30 @@ export default function ProfileSetupPage() {
|
||||
if (error || !event) {
|
||||
return (
|
||||
<div className="text-center p-4">
|
||||
<p className="text-red-600 mb-4">{error || 'Event nicht gefunden.'}</p>
|
||||
<Button onClick={() => nav('/')}>Zurück zur Startseite</Button>
|
||||
<p className="text-red-600 mb-4">{error || t('profileSetup.error.default')}</p>
|
||||
<Button onClick={() => nav('/')}>{t('profileSetup.error.backToStart')}</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-pink-50 to-purple-50 flex flex-col">
|
||||
<Header slug={token!} />
|
||||
<div className="flex-1 flex flex-col justify-center items-center px-4 py-8">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center space-y-2">
|
||||
<CardTitle className="text-2xl font-bold text-gray-900">{event.name}</CardTitle>
|
||||
<CardDescription className="text-lg text-gray-600">
|
||||
Fange den schoensten Moment ein!
|
||||
{t('profileSetup.card.description')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 p-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name" className="text-sm font-medium">Dein Name (z.B. Anna)</Label>
|
||||
<Label htmlFor="name" className="text-sm font-medium">{t('profileSetup.form.label')}</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
placeholder="Dein Name"
|
||||
placeholder={t('profileSetup.form.placeholder')}
|
||||
className="text-lg"
|
||||
disabled={submitting || !hydrated}
|
||||
autoComplete="name"
|
||||
@@ -94,7 +94,7 @@ export default function ProfileSetupPage() {
|
||||
onClick={submitName}
|
||||
disabled={submitting || !name.trim() || !hydrated}
|
||||
>
|
||||
{submitting ? 'Speichere...' : "Let's go!"}
|
||||
{submitting ? t('profileSetup.form.submitting') : t('profileSetup.form.submit')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
import React from 'react';
|
||||
import { Page } from './_util';
|
||||
import { useTranslation } from '../i18n/useTranslation';
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Page title="Einstellungen">
|
||||
<ul>
|
||||
<li>Sprache</li>
|
||||
<li>Theme</li>
|
||||
<li>Cache leeren</li>
|
||||
<li>Rechtliches</li>
|
||||
</ul>
|
||||
<Page title={t('settings.title')}>
|
||||
<p style={{ fontSize: 14 }}>{t('settings.subtitle')}</p>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
ZapOff,
|
||||
} from 'lucide-react';
|
||||
import { getEventPackage, type EventPackage } from '../services/eventApi';
|
||||
import { useTranslation } from '../i18n/useTranslation';
|
||||
|
||||
interface Task {
|
||||
id: number;
|
||||
@@ -62,6 +63,7 @@ export default function UploadPage() {
|
||||
const { appearance } = useAppearance();
|
||||
const isDarkMode = appearance === 'dark';
|
||||
const { markCompleted } = useGuestTaskProgress(slug);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const taskIdParam = searchParams.get('task');
|
||||
const emotionSlug = searchParams.get('emotion') || '';
|
||||
@@ -137,7 +139,7 @@ export default function UploadPage() {
|
||||
// Load task metadata
|
||||
useEffect(() => {
|
||||
if (!slug || !taskId) {
|
||||
setTaskError('Keine Aufgabeninformationen gefunden.');
|
||||
setTaskError(t('upload.loadError.title'));
|
||||
setLoadingTask(false);
|
||||
return;
|
||||
}
|
||||
@@ -145,6 +147,10 @@ export default function UploadPage() {
|
||||
let active = true;
|
||||
|
||||
async function loadTask() {
|
||||
const fallbackTitle = t('upload.taskInfo.fallbackTitle').replace('{id}', `${taskId!}`);
|
||||
const fallbackDescription = t('upload.taskInfo.fallbackDescription');
|
||||
const fallbackInstructions = t('upload.taskInfo.fallbackInstructions');
|
||||
|
||||
try {
|
||||
setLoadingTask(true);
|
||||
setTaskError(null);
|
||||
@@ -159,9 +165,9 @@ export default function UploadPage() {
|
||||
if (found) {
|
||||
setTask({
|
||||
id: found.id,
|
||||
title: found.title || `Aufgabe ${taskId!}`,
|
||||
description: found.description || 'Halte den Moment fest und teile ihn mit allen Gästen.',
|
||||
instructions: found.instructions,
|
||||
title: found.title || fallbackTitle,
|
||||
description: found.description || fallbackDescription,
|
||||
instructions: found.instructions ?? fallbackInstructions,
|
||||
duration: found.duration || 2,
|
||||
emotion: found.emotion,
|
||||
difficulty: found.difficulty ?? 'medium',
|
||||
@@ -169,9 +175,9 @@ export default function UploadPage() {
|
||||
} else {
|
||||
setTask({
|
||||
id: taskId!,
|
||||
title: `Aufgabe ${taskId!}`,
|
||||
description: 'Halte den Moment fest und teile ihn mit allen Gästen.',
|
||||
instructions: 'Positioniere alle im Bild, starte den Countdown und lass die Emotion wirken.',
|
||||
title: fallbackTitle,
|
||||
description: fallbackDescription,
|
||||
instructions: fallbackInstructions,
|
||||
duration: 2,
|
||||
emotion: emotionSlug
|
||||
? { slug: emotionSlug, name: emotionSlug.replace('-', ' ').replace(/\b\w/g, (l) => l.toUpperCase()) }
|
||||
@@ -182,12 +188,12 @@ export default function UploadPage() {
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch task', error);
|
||||
if (active) {
|
||||
setTaskError('Aufgabe konnte nicht geladen werden. Du kannst trotzdem ein Foto machen.');
|
||||
setTaskError(t('upload.loadError.title'));
|
||||
setTask({
|
||||
id: taskId!,
|
||||
title: `Aufgabe ${taskId!}`,
|
||||
description: 'Halte den Moment fest und teile ihn mit allen Gästen.',
|
||||
instructions: 'Positioniere alle im Bild, starte den Countdown und lass die Emotion wirken.',
|
||||
title: fallbackTitle,
|
||||
description: fallbackDescription,
|
||||
instructions: fallbackInstructions,
|
||||
duration: 2,
|
||||
emotion: emotionSlug
|
||||
? { slug: emotionSlug, name: emotionSlug.replace('-', ' ').replace(/\b\w/g, (l) => l.toUpperCase()) }
|
||||
@@ -204,7 +210,7 @@ export default function UploadPage() {
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [eventKey, taskId, emotionSlug]);
|
||||
}, [eventKey, taskId, emotionSlug, t]);
|
||||
|
||||
// Check upload limits
|
||||
useEffect(() => {
|
||||
@@ -216,19 +222,27 @@ export default function UploadPage() {
|
||||
setEventPackage(pkg);
|
||||
if (pkg && pkg.used_photos >= pkg.package.max_photos) {
|
||||
setCanUpload(false);
|
||||
setUploadError('Upload-Limit erreicht. Kontaktieren Sie den Organisator für ein Upgrade.');
|
||||
const maxLabel = pkg.package.max_photos == null
|
||||
? t('upload.limitUnlimited')
|
||||
: `${pkg.package.max_photos}`;
|
||||
setUploadError(
|
||||
t('upload.limitReached')
|
||||
.replace('{used}', `${pkg.used_photos}`)
|
||||
.replace('{max}', maxLabel)
|
||||
);
|
||||
} else {
|
||||
setCanUpload(true);
|
||||
setUploadError(null);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to check package limits', err);
|
||||
setCanUpload(false);
|
||||
setUploadError('Fehler beim Prüfen des Limits. Upload deaktiviert.');
|
||||
setUploadError(t('upload.limitCheckError'));
|
||||
}
|
||||
};
|
||||
|
||||
checkLimits();
|
||||
}, [eventKey, task]);
|
||||
}, [eventKey, task, t]);
|
||||
|
||||
const stopStream = useCallback(() => {
|
||||
if (streamRef.current) {
|
||||
@@ -265,7 +279,7 @@ export default function UploadPage() {
|
||||
const startCamera = useCallback(async () => {
|
||||
if (!supportsCamera) {
|
||||
setPermissionState('unsupported');
|
||||
setPermissionMessage('Dieses Gerät oder der Browser unterstützt keine Kamera-Zugriffe.');
|
||||
setPermissionMessage(t('upload.cameraUnsupported.message'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -286,18 +300,16 @@ export default function UploadPage() {
|
||||
|
||||
if (error?.name === 'NotAllowedError') {
|
||||
setPermissionState('denied');
|
||||
setPermissionMessage(
|
||||
'Kamera-Zugriff wurde blockiert. Prüfe die Berechtigungen deines Browsers und versuche es erneut.'
|
||||
);
|
||||
setPermissionMessage(t('upload.cameraDenied.explanation'));
|
||||
} else if (error?.name === 'NotFoundError') {
|
||||
setPermissionState('error');
|
||||
setPermissionMessage('Keine Kamera gefunden. Du kannst stattdessen ein Foto aus deiner Galerie wählen.');
|
||||
setPermissionMessage(t('upload.cameraUnsupported.message'));
|
||||
} else {
|
||||
setPermissionState('error');
|
||||
setPermissionMessage(`Kamera konnte nicht gestartet werden: ${error?.message || 'Unbekannter Fehler'}`);
|
||||
setPermissionMessage(t('upload.cameraError.explanation'));
|
||||
}
|
||||
}
|
||||
}, [attachStreamToVideo, createConstraint, mode, preferences.facingMode, stopStream, supportsCamera, task]);
|
||||
}, [attachStreamToVideo, createConstraint, mode, preferences.facingMode, stopStream, supportsCamera, task, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!task || loadingTask) return;
|
||||
@@ -311,15 +323,15 @@ export default function UploadPage() {
|
||||
useEffect(() => {
|
||||
if (!liveRegionRef.current) return;
|
||||
if (mode === 'countdown') {
|
||||
liveRegionRef.current.textContent = `Foto wird in ${countdownValue} Sekunden aufgenommen.`;
|
||||
liveRegionRef.current.textContent = t('upload.countdown.ready').replace('{count}', `${countdownValue}`);
|
||||
} else if (mode === 'review') {
|
||||
liveRegionRef.current.textContent = 'Foto aufgenommen. <20>berpr<70>fe die Vorschau.';
|
||||
liveRegionRef.current.textContent = t('upload.review.readyAnnouncement');
|
||||
} else if (mode === 'uploading') {
|
||||
liveRegionRef.current.textContent = 'Foto wird hochgeladen.';
|
||||
liveRegionRef.current.textContent = t('upload.status.uploading');
|
||||
} else {
|
||||
liveRegionRef.current.textContent = '';
|
||||
}
|
||||
}, [mode, countdownValue]);
|
||||
}, [mode, countdownValue, t]);
|
||||
|
||||
const dismissPrimer = useCallback(() => {
|
||||
setShowPrimer(false);
|
||||
@@ -360,7 +372,7 @@ export default function UploadPage() {
|
||||
|
||||
const performCapture = useCallback(() => {
|
||||
if (!videoRef.current || !canvasRef.current) {
|
||||
setUploadError('Kamera nicht bereit. Bitte versuche es erneut.');
|
||||
setUploadError(t('upload.captureError'));
|
||||
setMode('preview');
|
||||
return;
|
||||
}
|
||||
@@ -371,7 +383,7 @@ export default function UploadPage() {
|
||||
const height = video.videoHeight;
|
||||
|
||||
if (!width || !height) {
|
||||
setUploadError('Kamera liefert kein Bild. Bitte starte die Kamera neu.');
|
||||
setUploadError(t('upload.feedError'));
|
||||
setMode('preview');
|
||||
startCamera();
|
||||
return;
|
||||
@@ -381,7 +393,7 @@ export default function UploadPage() {
|
||||
canvas.height = height;
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
setUploadError('Canvas konnte nicht initialisiert werden.');
|
||||
setUploadError(t('upload.canvasError'));
|
||||
setMode('preview');
|
||||
return;
|
||||
}
|
||||
@@ -399,7 +411,7 @@ export default function UploadPage() {
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (!blob) {
|
||||
setUploadError('Foto konnte nicht erstellt werden.');
|
||||
setUploadError(t('upload.captureError'));
|
||||
setMode('preview');
|
||||
return;
|
||||
}
|
||||
@@ -413,7 +425,7 @@ export default function UploadPage() {
|
||||
'image/jpeg',
|
||||
0.92
|
||||
);
|
||||
}, [preferences.facingMode, preferences.mirrorFrontPreview, startCamera]);
|
||||
}, [preferences.facingMode, preferences.mirrorFrontPreview, startCamera, t]);
|
||||
|
||||
const beginCapture = useCallback(() => {
|
||||
setUploadError(null);
|
||||
@@ -461,7 +473,7 @@ export default function UploadPage() {
|
||||
setMode('uploading');
|
||||
setUploadProgress(5);
|
||||
setUploadError(null);
|
||||
setStatusMessage('Foto wird vorbereitet...');
|
||||
setStatusMessage(t('upload.status.preparing'));
|
||||
|
||||
if (uploadProgressTimerRef.current) {
|
||||
window.clearInterval(uploadProgressTimerRef.current);
|
||||
@@ -473,13 +485,13 @@ export default function UploadPage() {
|
||||
try {
|
||||
const photoId = await uploadPhoto(eventKey, reviewPhoto.file, task.id, emotionSlug || undefined);
|
||||
setUploadProgress(100);
|
||||
setStatusMessage('Upload abgeschlossen.');
|
||||
setStatusMessage(t('upload.status.completed'));
|
||||
markCompleted(task.id);
|
||||
stopStream();
|
||||
navigateAfterUpload(photoId);
|
||||
} catch (error: any) {
|
||||
console.error('Upload failed', error);
|
||||
setUploadError(error?.message || 'Upload fehlgeschlagen. Bitte versuche es erneut.');
|
||||
setUploadError(error?.message || t('upload.status.failed'));
|
||||
setMode('review');
|
||||
} finally {
|
||||
if (uploadProgressTimerRef.current) {
|
||||
@@ -488,7 +500,7 @@ export default function UploadPage() {
|
||||
}
|
||||
setStatusMessage('');
|
||||
}
|
||||
}, [emotionSlug, markCompleted, navigateAfterUpload, reviewPhoto, eventKey, stopStream, task, canUpload]);
|
||||
}, [emotionSlug, markCompleted, navigateAfterUpload, reviewPhoto, eventKey, stopStream, task, canUpload, t]);
|
||||
|
||||
const handleGalleryPick = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!canUpload) return;
|
||||
@@ -501,10 +513,10 @@ export default function UploadPage() {
|
||||
setMode('review');
|
||||
};
|
||||
reader.onerror = () => {
|
||||
setUploadError('Auswahl fehlgeschlagen. Bitte versuche es erneut.');
|
||||
setUploadError(t('upload.galleryPickError'));
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}, [canUpload]);
|
||||
}, [canUpload, t]);
|
||||
|
||||
const difficultyBadgeClass = useMemo(() => {
|
||||
if (!task) return 'text-white';
|
||||
@@ -533,12 +545,10 @@ export default function UploadPage() {
|
||||
if (!supportsCamera && !task) {
|
||||
return (
|
||||
<div className="pb-16">
|
||||
<Header slug={eventKey} title="Kamera" />
|
||||
<Header slug={eventKey} title={t('upload.cameraTitle')} />
|
||||
<main className="px-4 py-6">
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
Dieses Gerät unterstützt keine Kamera-Zugriffe. Du kannst stattdessen Fotos aus deiner Galerie hochladen.
|
||||
</AlertDescription>
|
||||
<AlertDescription>{t('upload.cameraUnsupported.message')}</AlertDescription>
|
||||
</Alert>
|
||||
</main>
|
||||
<BottomNav />
|
||||
@@ -549,10 +559,10 @@ export default function UploadPage() {
|
||||
if (loadingTask) {
|
||||
return (
|
||||
<div className="pb-16">
|
||||
<Header slug={eventKey} title="Kamera" />
|
||||
<Header slug={eventKey} title={t('upload.cameraTitle')} />
|
||||
<main className="px-4 py-6 flex flex-col items-center justify-center text-center">
|
||||
<Loader2 className="h-10 w-10 animate-spin text-pink-500 mb-4" />
|
||||
<p className="text-sm text-muted-foreground">Aufgabe und Kamera werden vorbereitet ...</p>
|
||||
<p className="text-sm text-muted-foreground">{t('upload.preparing')}</p>
|
||||
</main>
|
||||
<BottomNav />
|
||||
</div>
|
||||
@@ -562,13 +572,14 @@ export default function UploadPage() {
|
||||
if (!canUpload) {
|
||||
return (
|
||||
<div className="pb-16">
|
||||
<Header slug={eventKey} title="Kamera" />
|
||||
<Header slug={eventKey} title={t('upload.cameraTitle')} />
|
||||
<main className="px-4 py-6">
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Upload-Limit erreicht ({eventPackage?.used_photos || 0} / {eventPackage?.package.max_photos || 0} Fotos).
|
||||
Kontaktieren Sie den Organisator für ein Package-Upgrade.
|
||||
{t('upload.limitReached')
|
||||
.replace('{used}', `${eventPackage?.used_photos || 0}`)
|
||||
.replace('{max}', `${eventPackage?.package.max_photos || 0}`)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</main>
|
||||
@@ -583,13 +594,14 @@ export default function UploadPage() {
|
||||
<div className="flex items-start gap-3">
|
||||
<Info className="mt-0.5 h-5 w-5 flex-shrink-0" />
|
||||
<div className="text-left">
|
||||
<p className="font-semibold">Bereit für dein Shooting?</p>
|
||||
<p className="font-semibold">{t('upload.primer.title')}</p>
|
||||
<p className="mt-1">
|
||||
Suche dir gutes Licht, halte die Stimmung der Aufgabe fest und nutze die Kontrollleiste für Countdown, Grid und Kamerawechsel.
|
||||
{t('upload.primer.body.part1')}{' '}
|
||||
{t('upload.primer.body.part2')}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={dismissPrimer}>
|
||||
Alles klar
|
||||
{t('upload.primer.dismiss')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -601,9 +613,7 @@ export default function UploadPage() {
|
||||
if (permissionState === 'unsupported') {
|
||||
return (
|
||||
<Alert className="mx-4">
|
||||
<AlertDescription>
|
||||
Dieses Gerät unterstützt keine Kamera. Nutze den Button `Foto aus Galerie wählen`, um dennoch teilzunehmen.
|
||||
</AlertDescription>
|
||||
<AlertDescription>{t('upload.cameraUnsupported.message')}</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -613,24 +623,22 @@ export default function UploadPage() {
|
||||
<AlertDescription className="space-y-3">
|
||||
<div>{permissionMessage}</div>
|
||||
<Button size="sm" variant="outline" onClick={startCamera}>
|
||||
Erneut versuchen
|
||||
{t('upload.buttons.tryAgain')}
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Alert className="mx-4">
|
||||
<AlertDescription>
|
||||
Wir benötigen Zugriff auf deine Kamera. Bestätige die Browser-Abfrage oder nutze alternativ ein Foto aus deiner Galerie.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Alert className="mx-4">
|
||||
<AlertDescription>{t('upload.cameraDenied.prompt')}</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pb-16">
|
||||
<Header slug={eventKey} title="Kamera" />
|
||||
<Header slug={eventKey} title={t('upload.cameraTitle')} />
|
||||
<main className="relative flex flex-col gap-4 pb-4">
|
||||
<div className="absolute left-0 right-0 top-0" aria-hidden="true">
|
||||
{renderPrimer()}
|
||||
@@ -666,14 +674,17 @@ export default function UploadPage() {
|
||||
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center bg-black/70 text-center text-sm">
|
||||
<Camera className="mb-3 h-8 w-8 text-pink-400" />
|
||||
<p className="max-w-xs text-white/90">
|
||||
Kamera ist nicht aktiv. {permissionMessage || 'Tippe auf `Kamera starten`, um loszulegen.'}
|
||||
{t('upload.cameraInactive').replace(
|
||||
'{hint}',
|
||||
(permissionMessage ?? t('upload.cameraInactiveHint').replace('{label}', t('upload.buttons.startCamera')))
|
||||
)}
|
||||
</p>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<Button size="sm" onClick={startCamera}>
|
||||
Kamera starten
|
||||
{t('upload.buttons.startCamera')}
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => fileInputRef.current?.click()}>
|
||||
Foto aus Galerie wählen
|
||||
{t('upload.galleryButton')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -684,14 +695,10 @@ export default function UploadPage() {
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Badge variant="secondary" className="flex items-center gap-2 text-xs">
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
Aufgabe #{task.id}
|
||||
{t('upload.taskInfo.badge').replace('{id}', `${task.id}`)}
|
||||
</Badge>
|
||||
<span className={cn('text-xs font-medium uppercase tracking-wide', difficultyBadgeClass)}>
|
||||
{task.difficulty === 'easy'
|
||||
? 'Leicht'
|
||||
: task.difficulty === 'hard'
|
||||
? 'Herausfordernd'
|
||||
: 'Medium'}
|
||||
{t(`upload.taskInfo.difficulty.${task.difficulty ?? 'medium'}`)}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
@@ -699,13 +706,19 @@ export default function UploadPage() {
|
||||
<p className="mt-1 text-xs leading-relaxed text-white/80">{task.description}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-[11px] text-white/70">
|
||||
{task.instructions && <span>Hinweis: {task.instructions}</span>}
|
||||
{task.instructions && (
|
||||
<span>
|
||||
{t('upload.taskInfo.instructionsPrefix')}: {task.instructions}
|
||||
</span>
|
||||
)}
|
||||
{emotionSlug && (
|
||||
<span className="rounded-full border border-white/20 px-2 py-0.5">Stimmung: {task.emotion?.name || emotionSlug}</span>
|
||||
<span className="rounded-full border border-white/20 px-2 py-0.5">
|
||||
{t('upload.taskInfo.emotion').replace('{value}', `${task.emotion?.name || emotionSlug}`)}
|
||||
</span>
|
||||
)}
|
||||
{preferences.countdownEnabled && (
|
||||
<span className="rounded-full border border-white/20 px-2 py-0.5">
|
||||
Countdown: {preferences.countdownSeconds}s
|
||||
{t('upload.countdownLabel').replace('{seconds}', `${preferences.countdownSeconds}`)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -715,7 +728,7 @@ export default function UploadPage() {
|
||||
{mode === 'countdown' && (
|
||||
<div className="absolute inset-0 z-40 flex flex-col items-center justify-center bg-black/60 text-white">
|
||||
<div className="text-6xl font-bold">{countdownValue}</div>
|
||||
<p className="mt-2 text-sm text-white/70">Bereit machen ...</p>
|
||||
<p className="mt-2 text-sm text-white/70">{t('upload.countdownReady')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -760,7 +773,7 @@ export default function UploadPage() {
|
||||
onClick={handleToggleGrid}
|
||||
>
|
||||
<Grid3X3 className="h-5 w-5" />
|
||||
<span className="sr-only">Raster umschalten</span>
|
||||
<span className="sr-only">{t('upload.controls.toggleGrid')}</span>
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
@@ -769,7 +782,7 @@ export default function UploadPage() {
|
||||
onClick={handleToggleCountdown}
|
||||
>
|
||||
<span className="text-sm font-semibold">{preferences.countdownSeconds}s</span>
|
||||
<span className="sr-only">Countdown umschalten</span>
|
||||
<span className="sr-only">{t('upload.controls.toggleCountdown')}</span>
|
||||
</Button>
|
||||
{preferences.facingMode === 'user' && (
|
||||
<Button
|
||||
@@ -779,7 +792,7 @@ export default function UploadPage() {
|
||||
onClick={handleToggleMirror}
|
||||
>
|
||||
<span className="text-sm font-semibold">?</span>
|
||||
<span className="sr-only">Spiegelung für Frontkamera umschalten</span>
|
||||
<span className="sr-only">{t('upload.controls.toggleMirror')}</span>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
@@ -790,7 +803,7 @@ export default function UploadPage() {
|
||||
disabled={preferences.facingMode !== 'environment'}
|
||||
>
|
||||
{preferences.flashPreferred ? <Zap className="h-5 w-5 text-yellow-300" /> : <ZapOff className="h-5 w-5" />}
|
||||
<span className="sr-only">Blitzpräferenz umschalten</span>
|
||||
<span className="sr-only">{t('upload.controls.toggleFlash')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -802,7 +815,7 @@ export default function UploadPage() {
|
||||
onClick={handleSwitchCamera}
|
||||
>
|
||||
<RotateCcw className="mr-1 h-4 w-4" />
|
||||
Kamera wechseln
|
||||
{t('upload.switchCamera')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
@@ -811,7 +824,7 @@ export default function UploadPage() {
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<ImagePlus className="mr-1 h-4 w-4" />
|
||||
Foto aus Galerie
|
||||
{t('upload.galleryButton')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -820,10 +833,10 @@ export default function UploadPage() {
|
||||
{mode === 'review' && reviewPhoto ? (
|
||||
<div className="flex w-full max-w-md flex-col gap-3 sm:flex-row">
|
||||
<Button variant="secondary" className="flex-1" onClick={handleRetake}>
|
||||
Noch einmal
|
||||
{t('upload.review.retake')}
|
||||
</Button>
|
||||
<Button className="flex-1" onClick={handleUsePhoto}>
|
||||
Foto verwenden
|
||||
{t('upload.review.keep')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
@@ -834,7 +847,7 @@ export default function UploadPage() {
|
||||
disabled={!isCameraActive || mode === 'countdown'}
|
||||
>
|
||||
<Camera className="h-7 w-7" />
|
||||
<span className="sr-only">Foto aufnehmen</span>
|
||||
<span className="sr-only">{t('upload.captureButton')}</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import React from 'react';
|
||||
import { Page } from './_util';
|
||||
import { useTranslation } from '../i18n/useTranslation';
|
||||
|
||||
export default function UploadQueuePage() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Page title="Uploads">
|
||||
<p>Queue with progress/retry; background sync toggle.</p>
|
||||
<Page title={t('uploadQueue.title')}>
|
||||
<p>{t('uploadQueue.description')}</p>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,9 @@ import SlideshowPage from './pages/SlideshowPage';
|
||||
import SettingsPage from './pages/SettingsPage';
|
||||
import LegalPage from './pages/LegalPage';
|
||||
import NotFoundPage from './pages/NotFoundPage';
|
||||
import { LocaleProvider } from './i18n/LocaleContext';
|
||||
import { DEFAULT_LOCALE, isLocaleCode } from './i18n/messages';
|
||||
import { useTranslation, type TranslateFn } from './i18n/useTranslation';
|
||||
|
||||
function HomeLayout() {
|
||||
const { token } = useParams();
|
||||
@@ -85,41 +88,52 @@ function EventBoundary({ token }: { token: string }) {
|
||||
return <EventErrorView code={errorCode} message={error} />;
|
||||
}
|
||||
|
||||
const eventLocale = isLocaleCode(event.default_locale) ? event.default_locale : DEFAULT_LOCALE;
|
||||
const localeStorageKey = `guestLocale_event_${event.id ?? token}`;
|
||||
|
||||
return (
|
||||
<EventStatsProvider eventKey={token}>
|
||||
<div className="pb-16">
|
||||
<Header slug={token} />
|
||||
<div className="px-4 py-3">
|
||||
<Outlet />
|
||||
<LocaleProvider defaultLocale={eventLocale} storageKey={localeStorageKey}>
|
||||
<EventStatsProvider eventKey={token}>
|
||||
<div className="pb-16">
|
||||
<Header slug={token} />
|
||||
<div className="px-4 py-3">
|
||||
<Outlet />
|
||||
</div>
|
||||
<BottomNav />
|
||||
</div>
|
||||
<BottomNav />
|
||||
</div>
|
||||
</EventStatsProvider>
|
||||
</EventStatsProvider>
|
||||
</LocaleProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function SetupLayout() {
|
||||
const { token } = useParams<{ token: string }>();
|
||||
if (!token) return null;
|
||||
const { event } = useEventData();
|
||||
const eventLocale = event && isLocaleCode(event.default_locale) ? event.default_locale : DEFAULT_LOCALE;
|
||||
const localeStorageKey = event ? `guestLocale_event_${event.id}` : `guestLocale_event_${token}`;
|
||||
return (
|
||||
<GuestIdentityProvider eventKey={token}>
|
||||
<EventStatsProvider eventKey={token}>
|
||||
<div className="pb-0">
|
||||
<Header slug={token} />
|
||||
<Outlet />
|
||||
</div>
|
||||
</EventStatsProvider>
|
||||
<LocaleProvider defaultLocale={eventLocale} storageKey={localeStorageKey}>
|
||||
<EventStatsProvider eventKey={token}>
|
||||
<div className="pb-0">
|
||||
<Header slug={token} />
|
||||
<Outlet />
|
||||
</div>
|
||||
</EventStatsProvider>
|
||||
</LocaleProvider>
|
||||
</GuestIdentityProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function EventLoadingView() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center gap-4 px-6 text-center">
|
||||
<Loader2 className="h-10 w-10 animate-spin text-muted-foreground" aria-hidden />
|
||||
<div className="space-y-1">
|
||||
<p className="text-lg font-semibold text-foreground">Wir prüfen deinen Zugang...</p>
|
||||
<p className="text-sm text-muted-foreground">Einen Moment bitte.</p>
|
||||
<p className="text-lg font-semibold text-foreground">{t('eventAccess.loading.title')}</p>
|
||||
<p className="text-sm text-muted-foreground">{t('eventAccess.loading.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -131,7 +145,8 @@ interface EventErrorViewProps {
|
||||
}
|
||||
|
||||
function EventErrorView({ code, message }: EventErrorViewProps) {
|
||||
const content = getErrorContent(code, message);
|
||||
const { t } = useTranslation();
|
||||
const content = getErrorContent(t, code, message);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center gap-6 px-6 text-center">
|
||||
@@ -155,50 +170,39 @@ function EventErrorView({ code, message }: EventErrorViewProps) {
|
||||
}
|
||||
|
||||
function getErrorContent(
|
||||
t: TranslateFn,
|
||||
code: FetchEventErrorCode | null,
|
||||
message: string | null,
|
||||
) {
|
||||
const base = (fallbackTitle: string, fallbackDescription: string, options?: { ctaLabel?: string; ctaHref?: string; hint?: string }) => ({
|
||||
title: fallbackTitle,
|
||||
description: message ?? fallbackDescription,
|
||||
ctaLabel: options?.ctaLabel,
|
||||
ctaHref: options?.ctaHref,
|
||||
hint: options?.hint ?? null,
|
||||
});
|
||||
const build = (key: string, options?: { ctaHref?: string }) => {
|
||||
const ctaLabel = t(`eventAccess.error.${key}.ctaLabel`, '');
|
||||
const hint = t(`eventAccess.error.${key}.hint`, '');
|
||||
return {
|
||||
title: t(`eventAccess.error.${key}.title`),
|
||||
description: message ?? t(`eventAccess.error.${key}.description`),
|
||||
ctaLabel: ctaLabel.trim().length > 0 ? ctaLabel : undefined,
|
||||
ctaHref: options?.ctaHref,
|
||||
hint: hint.trim().length > 0 ? hint : null,
|
||||
};
|
||||
};
|
||||
|
||||
switch (code) {
|
||||
case 'invalid_token':
|
||||
return base('Zugriffscode ungültig', 'Der eingegebene Code konnte nicht verifiziert werden.', {
|
||||
ctaLabel: 'Neuen Code anfordern',
|
||||
ctaHref: '/event',
|
||||
});
|
||||
return build('invalid_token', { ctaHref: '/event' });
|
||||
case 'token_revoked':
|
||||
return base('Zugriffscode deaktiviert', 'Dieser Code wurde zurückgezogen. Bitte fordere einen neuen Code an.', {
|
||||
ctaLabel: 'Neuen Code anfordern',
|
||||
ctaHref: '/event',
|
||||
});
|
||||
return build('token_revoked', { ctaHref: '/event' });
|
||||
case 'token_expired':
|
||||
return base('Zugriffscode abgelaufen', 'Der Code ist nicht mehr gültig. Aktualisiere deinen Code, um fortzufahren.', {
|
||||
ctaLabel: 'Code aktualisieren',
|
||||
ctaHref: '/event',
|
||||
});
|
||||
return build('token_expired', { ctaHref: '/event' });
|
||||
case 'token_rate_limited':
|
||||
return base('Zu viele Versuche', 'Es gab zu viele Eingaben in kurzer Zeit. Warte kurz und versuche es erneut.', {
|
||||
hint: 'Tipp: Eine erneute Eingabe ist in wenigen Minuten möglich.',
|
||||
});
|
||||
return build('token_rate_limited');
|
||||
case 'event_not_public':
|
||||
return base('Event nicht öffentlich', 'Dieses Event ist aktuell nicht öffentlich zugänglich.', {
|
||||
hint: 'Nimm Kontakt mit den Veranstalter:innen auf, um Zugang zu erhalten.',
|
||||
});
|
||||
return build('event_not_public');
|
||||
case 'network_error':
|
||||
return base('Verbindungsproblem', 'Wir konnten keine Verbindung zum Server herstellen. Prüfe deine Internetverbindung und versuche es erneut.');
|
||||
return build('network_error');
|
||||
case 'server_error':
|
||||
return base('Server nicht erreichbar', 'Der Server reagiert derzeit nicht. Versuche es später erneut.');
|
||||
return build('server_error');
|
||||
default:
|
||||
return base('Event nicht erreichbar', 'Wir konnten dein Event nicht laden. Bitte versuche es erneut.', {
|
||||
ctaLabel: 'Zur Code-Eingabe',
|
||||
ctaHref: '/event',
|
||||
});
|
||||
return build('default', { ctaHref: '/event' });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,16 +14,21 @@ const Footer: React.FC = () => {
|
||||
return (
|
||||
<footer className="bg-white border-t border-gray-200 mt-auto">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
|
||||
<div className="col-span-1 md:col-span-2">
|
||||
<Link href="/marketing" className="text-2xl font-bold font-display text-pink-500">
|
||||
FotoSpiel.App
|
||||
</Link>
|
||||
<p className="text-gray-600 font-sans-marketing mt-2">
|
||||
Deine Plattform für Event-Fotos.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<div>
|
||||
<div className="flex items-center gap-4">
|
||||
<img src="logo-transparent-md.png" alt="FotoSpiel.App Logo" className="h-12 w-auto" />
|
||||
<div>
|
||||
<Link href="/" className="text-2xl font-bold font-display text-pink-500">
|
||||
FotoSpiel.App
|
||||
</Link>
|
||||
<p className="text-gray-600 font-sans-marketing mt-2">
|
||||
Deine Plattform für Event-Fotos.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold font-display text-gray-900 mb-4">Rechtliches</h3>
|
||||
<ul className="space-y-2 text-sm text-gray-600 font-sans-marketing">
|
||||
@@ -33,7 +38,7 @@ const Footer: React.FC = () => {
|
||||
<li><Link href="/kontakt" className="hover:text-pink-500">{t('nav.contact')}</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold font-display text-gray-900 mb-4">Social</h3>
|
||||
<ul className="space-y-2 text-sm text-gray-600 font-sans-marketing">
|
||||
|
||||
@@ -118,8 +118,11 @@ const Header: React.FC = () => {
|
||||
<header className="fixed top-0 z-50 w-full bg-white dark:bg-gray-900 shadow-lg border-b-2 border-gray-200 dark:border-gray-700">
|
||||
<div className="container mx-auto px-4 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Link href={localizedPath('/')} className="text-2xl font-bold text-gray-800 dark:text-gray-200">
|
||||
Die Fotospiel.App
|
||||
<Link href={localizedPath('/')} className="flex items-center gap-4">
|
||||
<img src="logo-transparent-md.png" alt="FotoSpiel.App Logo" className="h-12 w-auto" />
|
||||
<span className="text-2xl font-bold font-display text-pink-500">
|
||||
FotoSpiel.App
|
||||
</span>
|
||||
</Link>
|
||||
<NavigationMenu className="hidden lg:flex flex-1 justify-center" viewport={false}>
|
||||
<NavigationMenuList className="gap-2">
|
||||
@@ -246,6 +249,18 @@ const Header: React.FC = () => {
|
||||
<SheetHeader className="text-left">
|
||||
<SheetTitle className="text-xl font-semibold">Menü</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">Sprache</span>
|
||||
<Select value={i18n.language || 'de'} onValueChange={handleLanguageChange}>
|
||||
<SelectTrigger className="h-10 w-full">
|
||||
<SelectValue placeholder={t('common.ui.language_select')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="de">Deutsch</SelectItem>
|
||||
<SelectItem value="en">English</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<nav className="flex flex-col gap-2">
|
||||
{navItems.map((item) => (
|
||||
item.children ? (
|
||||
@@ -303,18 +318,6 @@ const Header: React.FC = () => {
|
||||
<span className="sr-only">Theme Toggle</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">Sprache</span>
|
||||
<Select value={i18n.language || 'de'} onValueChange={handleLanguageChange}>
|
||||
<SelectTrigger className="h-10 w-full">
|
||||
<SelectValue placeholder={t('common.ui.language_select')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="de">Deutsch</SelectItem>
|
||||
<SelectItem value="en">English</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
{auth.user ? (
|
||||
<>
|
||||
|
||||
Reference in New Issue
Block a user