diff --git a/AGENTS.md b/AGENTS.md index cc0d349..a829be8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # AGENTS.md — Agent Guidance for Event Photo Platform -This repository hosts a multi-tenant event photo platform (Laravel 12, PHP 8.3, Filament 4, React/Vite PWA). This document defines how AI agents should operate in this repo: roles, permissions, safety rules, and standard workflows. It is the single source of truth for agent behavior. Per-agent details live in docs/agents/. +This repository hosts a multi-tenant event photo platform (Laravel 12, PHP 8.3, Filament 4, React 19/Vite 7 PWA). This document defines how AI agents should operate in this repo: roles, permissions, safety rules, and standard workflows. It is the single source of truth for agent behavior. Per-agent details live in docs/agents/. ## Purpose & Scope - Provide clear guardrails and playbooks so agents can assist safely with code, docs, DevOps and project hygiene. @@ -24,90 +24,21 @@ This repository hosts a multi-tenant event photo platform (Laravel 12, PHP 8.3, - Keep this AGENTS.md authoritative. If per-agent docs diverge, update this file and link the rationale. ## Tools & Permissions -- Languages/Frameworks: PHP 8.3 (Laravel 12), JS/TS (React/Vite/Tailwind), Filament 4. -- Dev Commands: composer, npm, vite, artisan, PHPUnit, Pint/ESLint, Docker/Compose (for dev). -- Git Hosting: Gogs at http://192.168.78.2:10880 (token found locally in gogs.ini, never printed or committed). -- Issue API: Gogs REST /api/v1 for labels/issues/milestones (token auth). -- Libraries: simplesoftwareio/simple-qrcode for server-side QR generation. - -## Repo Structure (high-level) -- docs/prp/ — split PRP (authoritative). Start at docs/prp/README.md. -- docs/changes/ — session change logs. -- resources/js/guest/ — Guest PWA source (standalone entry, SW at public/guest-sw.js). -- resources/js/admin/ — Tenant Admin PWA source (standalone entry). -- fotospiel_prp.md — legacy monolithic PRP (historical reference; do not edit). -- TODO.md — prioritized backlog; mirrored into Issues by Ops Agent. - -## Standard Workflows -- Coding tasks (Codegen Agent): - 1) Understand scope; update or create a minimal plan. - 2) Edit code/docs via small, reviewable patches; keep changes focused. - 3) Add/update tests if behavior changes. - 4) Update docs when public surfaces change (PRP, docs/*). - 5) Propose follow-ups as Issues if out of scope. -- Issue hygiene (Ops Agent): - - Import TODO.md tasks as Issues with label TODO; group by Milestone (e.g., Now, Security & Compliance). - - Avoid duplicates by checking existing titles. -- Releases (Ops Agent): - - Tag with semantic version; generate changelog from commits/PRs; ensure legal pages and migration notes are updated. - -## Developer Utilities -- Artisan commands: - - media:backfill-thumbnails — generate thumbnails for existing photos. - - tenant:add-dummy — create a demo tenant and admin user (see --help for options). - - tenant:attach-demo-event — attach an existing demo event to a tenant. -- Public APIs for Guest PWA: stats/photos endpoints with ETag; likes; uploads; see docs/prp/03-api.md. - -## Constraints & Red-Lines -- Do not introduce tracking beyond what is documented (anonymous session_id only for guest PWA). -- Do not weaken auth, CSRF, CORS, or role checks. -- Do not expand data retention without updating Privacy policy. - -## Change Management -- Propose updates to this file via PR. Include: - - Motivation and scope, affected agents, roll-out plan. - - Links to updated docs in docs/agents/. - -## References -# AGENTS.md — Agent Guidance for Event Photo Platform - -This repository hosts a multi-tenant event photo platform (Laravel 12, PHP 8.3, Filament 4, React/Vite PWA). This document defines how AI agents should operate in this repo: roles, permissions, safety rules, and standard workflows. It is the single source of truth for agent behavior. Per-agent details live in docs/agents/. - -## Purpose & Scope -- Provide clear guardrails and playbooks so agents can assist safely with code, docs, DevOps and project hygiene. -- Applies to the whole repo unless a component has an explicit per-agent policy in docs/agents/. - -## Roles -- Codegen Agent — implements and edits application code, tests and documentation within scoped tasks. See docs/agents/codegen.md. -- Ops Agent — automates tasks around CI/CD, releases, issue hygiene, and repo maintenance. See docs/agents/ops.md. -- (Optional) Docs Agent — maintains documentation quality; follow Codegen Agent rules with writing focus. - -## Global Policies -- Secrets & Credentials: - - Never commit secrets. The local file gogs.ini (token=…) is ignored via .gitignore and must not be printed into logs. - - ENV values in .env are sensitive; do not commit them or echo to build logs. -- Data Protection: - - Respect GDPR. Do not introduce PII logging. Legal content (Impressum, Privacy, AGB) is managed via Legal Pages resource. -- Safety & Access: - - Prefer least privilege. Do not alter production data or infrastructure from code without explicit human approval. - - When uncertain about a destructive operation, open a PR or create an Issue with a proposal. -- Source of Truth: - - Keep this AGENTS.md authoritative. If per-agent docs diverge, update this file and link the rationale. - -## Tools & Permissions -- Languages/Frameworks: PHP 8.3 (Laravel 12), JS/TS (React/Vite/Tailwind), Filament 4. +- Languages/Frameworks: PHP 8.3 (Laravel 12), TypeScript/JavaScript (React 19/Vite 7/Tailwind 4), Filament 4. - Dev Commands: composer, npm, vite, artisan, PHPUnit, Pint/ESLint, Docker/Compose (for dev). - Git Hosting: Gogs at http://nas:10880 (token found locally in gogs.ini, never printed or committed). - Issue API: Gogs REST /api/v1 for labels/issues/milestones (token auth). -- Libraries: simplesoftwareio/simple-qrcode for server-side QR generation. +- Libraries: simplesoftwareio/simple-qrcode for server-side QR generation; Stripe PHP SDK for payments; PayPal Server SDK for payments; dompdf for PDF generation; spatie/laravel-translatable for i18n. +- Payment Systems: Stripe (subscriptions and one-time payments), PayPal (integrated payments), RevenueCat (mobile app subscriptions). +- PWA Technologies: React 19, Vite 7, Capacitor (iOS), Trusted Web Activity (Android), Service Workers, Background Sync. ## Repo Structure (high-level) - docs/prp/ — split PRP (authoritative). Start at docs/prp/README.md. - docs/changes/ — session change logs. -- resources/js/guest/ — Guest PWA source (standalone entry, SW at public/guest-sw.js). -- resources/js/admin/ — Tenant Admin PWA source (standalone entry). +- docs/todo/ — prioritized backlog items (replaces single TODO.md file). +- resources/js/guest/ — Guest PWA source (React 19, offline-first, installable). +- resources/js/admin/ — Tenant Admin PWA source (React 19, Capacitor/TWA ready). - fotospiel_prp.md — legacy monolithic PRP (historical reference; do not edit). -- TODO.md — prioritized backlog; mirrored into Issues by Ops Agent. ## Standard Workflows - Coding tasks (Codegen Agent): @@ -117,7 +48,7 @@ This repository hosts a multi-tenant event photo platform (Laravel 12, PHP 8.3, 4) Update docs when public surfaces change (PRP, docs/*). 5) Propose follow-ups as Issues if out of scope. - Issue hygiene (Ops Agent): - - Import TODO.md tasks as Issues with label TODO; group by Milestone (e.g., Now, Security & Compliance). + - Import docs/todo/ tasks as Issues with label TODO; group by Milestone (e.g., Now, Security & Compliance). - Avoid duplicates by checking existing titles. - Releases (Ops Agent): - Tag with semantic version; generate changelog from commits/PRs; ensure legal pages and migration notes are updated. @@ -128,11 +59,18 @@ This repository hosts a multi-tenant event photo platform (Laravel 12, PHP 8.3, - tenant:add-dummy — create a demo tenant and admin user (see --help for options). - tenant:attach-demo-event — attach an existing demo event to a tenant. - Public APIs for Guest PWA: stats/photos endpoints with ETag; likes; uploads; see docs/prp/03-api.md. +- Payment Integration: Stripe webhooks, PayPal API integration, RevenueCat mobile subscriptions. + +## PWA Architecture +- Guest PWA: Offline-first photo sharing app for event attendees (installable, background sync, no account required). +- Tenant Admin PWA: Store-ready mobile app for event management (Android TWA, iOS Capacitor, OAuth2 + PKCE). +- Core Features: Background upload, conflict resolution, push notifications, achievement system, emotion/task tagging. ## Constraints & Red-Lines - Do not introduce tracking beyond what is documented (anonymous session_id only for guest PWA). - Do not weaken auth, CSRF, CORS, or role checks. - Do not expand data retention without updating Privacy policy. +- PWA decisions are locked: Photos only (no videos), no facial recognition, no public profiles. ## Change Management - Propose updates to this file via PR. Include: @@ -140,3 +78,7 @@ This repository hosts a multi-tenant event photo platform (Laravel 12, PHP 8.3, - Links to updated docs in docs/agents/. ## References +- ADR-0006: Tenant Admin PWA architecture decision. +- docs/prp/06-tenant-admin-pwa.md: Detailed PWA specifications. +- docs/prp/07-guest-pwa.md: Guest PWA requirements and features. +- docs/prp/08-billing.md: Payment system architecture. diff --git a/database/seeders/LegalPagesSeeder.php b/database/seeders/LegalPagesSeeder.php index f18caa6..1155300 100644 --- a/database/seeders/LegalPagesSeeder.php +++ b/database/seeders/LegalPagesSeeder.php @@ -5,6 +5,7 @@ namespace Database\Seeders; use App\Models\LegalPage; use Illuminate\Database\Seeder; use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\File; class LegalPagesSeeder extends Seeder { @@ -12,157 +13,54 @@ class LegalPagesSeeder extends Seeder { $now = Carbon::now(); - // Impressum (DE) - $impressumDe = << [ + 'title' => [ + 'de' => 'Impressum', + 'en' => 'Legal Notice', + ], + 'files' => [ + 'de' => 'docs/legal/impressum-de.md', + 'en' => 'docs/legal/impressum-en.md', + ], + ], + 'datenschutz' => [ + 'title' => [ + 'de' => 'Datenschutzerklärung', + 'en' => 'Privacy Policy', + ], + 'files' => [ + 'de' => 'docs/legal/datenschutz-de.md', + 'en' => 'docs/legal/datenschutz-en.md', + ], + ], + 'agb' => [ + 'title' => [ + 'de' => 'Allgemeine Geschäftsbedingungen', + 'en' => 'Terms and Conditions', + ], + 'files' => [ + 'de' => 'docs/legal/agb-de.md', + 'en' => 'docs/legal/agb-en.md', + ], + ], + ]; -Anbieter dieser Seiten: + foreach ($pages as $slug => $config) { + $bodyByLocale = []; -Sören Eberhardt‑Biermann -Schweriner Str. 15 -19306 Neustadt‑Glewe, Deutschland + foreach ($config['files'] as $locale => $filePath) { + if (File::exists(base_path($filePath))) { + $bodyByLocale[$locale] = File::get(base_path($filePath)); + } else { + // Fallback to empty string if file doesn't exist + $bodyByLocale[$locale] = ''; + } + } -Kontakt: - -- Telefon mobil: 0173 / 9266802 -- Fax: 038757 / 54169 -- E‑Mail: soeren@sebfoto.de -- Website: https://sebfoto.de - -Umsatzsteuer‑Identifikationsnummer gemäß § 27a UStG: (falls vorhanden, bitte ergänzen) - -Verantwortlich für den Inhalt nach § 55 Abs. 2 RStV: -Sören Eberhardt‑Biermann, Anschrift wie oben - -Haftung für Inhalte: - -Als Diensteanbieter sind wir gemäß § 7 Abs.1 TMG für eigene Inhalte auf diesen Seiten nach den allgemeinen Gesetzen verantwortlich. Nach §§ 8 bis 10 TMG sind wir als Diensteanbieter jedoch nicht verpflichtet, übermittelte oder gespeicherte fremde Informationen zu überwachen oder nach Umständen zu forschen, die auf eine rechtswidrige Tätigkeit hinweisen. Verpflichtungen zur Entfernung oder Sperrung der Nutzung von Informationen nach den allgemeinen Gesetzen bleiben hiervon unberührt. - -Haftung für Links: - -Unser Angebot enthält ggf. Links zu externen Websites Dritter, auf deren Inhalte wir keinen Einfluss haben. Deshalb können wir für diese fremden Inhalte auch keine Gewähr übernehmen. Für die Inhalte der verlinkten Seiten ist stets der jeweilige Anbieter oder Betreiber verantwortlich. - -Urheberrecht: - -Die durch die Seitenbetreiber erstellten Inhalte und Werke auf diesen Seiten unterliegen dem deutschen Urheberrecht. Vervielfältigung, Bearbeitung, Verbreitung oder jede Art der Verwertung außerhalb der Grenzen des Urheberrechts bedürfen der schriftlichen Zustimmung des jeweiligen Autors bzw. Erstellers. -MD; - - $this->upsert('impressum', [ - 'de' => 'Impressum', - ], [ - 'de' => $impressumDe, - ], $now); - - // Datenschutz (DE) — baseline for Fotospiel platform - $datenschutzDe = <<format('Y-m-d')} -MD; - - $this->upsert('datenschutz', [ - 'de' => 'Datenschutzerklärung', - ], [ - 'de' => $datenschutzDe, - ], $now); - - // AGB (DE) — baseline Terms for Fotospiel - $agbDe = <<format('Y-m-d')} -MD; - - $this->upsert('agb', [ - 'de' => 'Allgemeine Geschäftsbedingungen', - ], [ - 'de' => $agbDe, - ], $now); + $this->upsert($slug, $config['title'], $bodyByLocale, $now); + } } private function upsert(string $slug, array $titleByLocale, array $bodyByLocale, \DateTimeInterface $effectiveFrom): void diff --git a/public/Fotospiel Logo Google-2.png b/public/Fotospiel Logo Google-2.png new file mode 100644 index 0000000..277edb6 Binary files /dev/null and b/public/Fotospiel Logo Google-2.png differ diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png index c2efef6..5516ec4 100644 Binary files a/public/apple-touch-icon.png and b/public/apple-touch-icon.png differ diff --git a/public/favicon.ico b/public/favicon.ico index 236fadb..05ce2a9 100644 Binary files a/public/favicon.ico and b/public/favicon.ico differ diff --git a/public/logo-transparent-lg.png b/public/logo-transparent-lg.png new file mode 100644 index 0000000..410b136 Binary files /dev/null and b/public/logo-transparent-lg.png differ diff --git a/public/logo-transparent-md.png b/public/logo-transparent-md.png new file mode 100644 index 0000000..6bdc2d9 Binary files /dev/null and b/public/logo-transparent-md.png differ diff --git a/resources/js/guest/components/BottomNav.tsx b/resources/js/guest/components/BottomNav.tsx index 9b226c1..22e5a6c 100644 --- a/resources/js/guest/components/BottomNav.tsx +++ b/resources/js/guest/components/BottomNav.tsx @@ -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() {
- {t.home} + {labels.home}
- {t.tasks} + {labels.tasks}
- {t.achievements} + {labels.achievements}
- {t.gallery} + {labels.gallery}
diff --git a/resources/js/guest/components/Header.tsx b/resources/js/guest/components/Header.tsx index 00636a8..f0d1eca 100644 --- a/resources/js/guest/components/Header.tsx +++ b/resources/js/guest/components/Header.tsx @@ -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> = { + 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 ( +
+ +
+ ); + } + + if (isLikelyEmoji(trimmed)) { + return ( +
+ {trimmed} + {name} +
+ ); + } + } + } + + return ( +
+ {getInitials(name)} +
+ ); +} 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
{title}
{guestName && ( - Hi {guestName} + + {`${t('common.hi')} ${guestName}`} + )}
@@ -35,7 +100,7 @@ export default function Header({ slug, title = '' }: { slug?: string; title?: st if (status === 'loading') { return (
-
Lade Event...
+
{t('header.loading')}
@@ -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 ( -
- {event.type.icon} -
- ); - } - - 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 ( -
- {getInitials(event.name)} -
- ); - }; - return (
- {getEventAvatar(event)} + {renderEventAvatar(event.name, event.type?.icon)}
{event.name}
{guestName && ( - Hi {guestName} + + {`${t('common.hi')} ${guestName}`} + )}
{stats && ( <> - {stats.onlineGuests} online + {`${stats.onlineGuests} ${t('header.stats.online')}`} | - {stats.tasksSolved} Aufgaben geloest + {stats.tasksSolved}{' '} + {t('header.stats.tasksSolved')} )} diff --git a/resources/js/guest/components/settings-sheet.tsx b/resources/js/guest/components/settings-sheet.tsx index 46b672c..ca5388f 100644 --- a/resources/js/guest/components/settings-sheet.tsx +++ b/resources/js/guest/components/settings-sheet.tsx @@ -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({ mode: 'home' }); const identity = useOptionalGuestIdentity(); + const localeContext = useLocale(); + const { t } = useTranslation(); const [nameDraft, setNameDraft] = React.useState(identity?.name ?? ''); const [nameStatus, setNameStatus] = React.useState('idle'); const [savingName, setSavingName] = React.useState(false); const isLegal = view.mode === 'legal'; - const legalDocument = useLegalDocument(isLegal ? view.slug : null); + 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() { @@ -115,32 +128,36 @@ export function SettingsSheet() { onClick={handleBack} > - Zurück + {t('settings.sheet.backLabel')}
{legalDocument.phase === 'ready' && legalDocument.title ? legalDocument.title - : view.label} + : t(view.translationKey)} - {legalDocument.phase === 'loading' ? 'Laedt...' : 'Rechtlicher Hinweis'} + {legalDocument.phase === 'loading' + ? t('common.actions.loading') + : t('settings.sheet.legalDescription')}
) : (
- Einstellungen - - Verwalte deinen Gastzugang, rechtliche Dokumente und lokale Daten. - + {t('settings.title')} + {t('settings.subtitle')}
)}
{isLegal ? ( - handleOpenChange(false)} /> + handleOpenChange(false)} + translationKey={view.mode === 'legal' ? view.translationKey : null} + /> ) : ( )}
-
Gastbereich - Daten werden lokal im Browser gespeichert.
+
{t('settings.footer.notice')}
@@ -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 (
- Das Dokument konnte nicht geladen werden. Bitte versuche es spaeter erneut. + {t('settings.legal.error')}
); } if (document.phase === 'loading' || document.phase === 'idle') { - return
Dokument wird geladen...
; + return
{t('settings.legal.loading')}
; } return (
- {document.title || 'Rechtlicher Hinweis'} + {document.title || t(translationKey ?? 'settings.legal.fallbackTitle')} @@ -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 (
+ + + {t('settings.language.title')} + {t('settings.language.description')} + + +
+ {localeContext.availableLocales.map((option) => { + const isActive = localeContext.locale === option.code; + return ( + + ); + })} +
+
+
+ {identity && ( - Dein Name - - Passe an, wie wir dich im Event begruessen. Der Name wird nur lokal gespeichert. - + {t('settings.name.title')} + {t('settings.name.description')}
@@ -239,12 +319,12 @@ function HomeView({
onNameChange(event.target.value)} autoComplete="name" disabled={!identity.hydrated || savingName} @@ -253,16 +333,16 @@ function HomeView({
{nameStatus === 'saved' && ( - Gespeichert (ok) + {t('settings.name.saved')} )} {!identity.hydrated && ( - Lade gespeicherten Namen... + {t('settings.name.loading')} )}
@@ -274,20 +354,18 @@ function HomeView({
- Rechtliches + {t('settings.legal.title')}
- - Die rechtlich verbindlichen Texte sind jederzeit hier abrufbar. - + {t('settings.legal.description')} - {legalPages.map((page) => ( + {legalLinks.map((page) => ( - {done &&
Cache geloescht.
} + {done &&
{t('settings.cache.cleared')}
}
); } diff --git a/resources/js/guest/i18n/LocaleContext.tsx b/resources/js/guest/i18n/LocaleContext.tsx new file mode 100644 index 0000000..af0ceb3 --- /dev/null +++ b/resources/js/guest/i18n/LocaleContext.tsx @@ -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(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(resolvedDefault); + const [userLocale, setUserLocale] = React.useState(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( + () => ({ + locale, + setLocale, + resetLocale, + hydrated, + defaultLocale: resolvedDefault, + storageKey, + availableLocales: SUPPORTED_LOCALES, + }), + [locale, setLocale, resetLocale, hydrated, resolvedDefault, storageKey], + ); + + return {children}; +} + +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); +} diff --git a/resources/js/guest/i18n/messages.ts b/resources/js/guest/i18n/messages.ts new file mode 100644 index 0000000..d6d2a5c --- /dev/null +++ b/resources/js/guest/i18n/messages.ts @@ -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 = { + 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); +} diff --git a/resources/js/guest/i18n/useTranslation.ts b/resources/js/guest/i18n/useTranslation.ts new file mode 100644 index 0000000..20a93fb --- /dev/null +++ b/resources/js/guest/i18n/useTranslation.ts @@ -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( + (key, fallback) => resolveTranslation(locale, key, fallback), + [locale], + ); + + return React.useMemo(() => ({ t, locale }), [t, locale]); +} + diff --git a/resources/js/guest/main.tsx b/resources/js/guest/main.tsx index d7e0b8c..cc93461 100644 --- a/resources/js/guest/main.tsx +++ b/resources/js/guest/main.tsx @@ -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( - - - + + + + + ); - diff --git a/resources/js/guest/pages/HomePage.tsx b/resources/js/guest/pages/HomePage.tsx index c159871..fa8e03a 100644 --- a/resources/js/guest/pages/HomePage.tsx +++ b/resources/js/guest/pages/HomePage.tsx @@ -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: , - }, - { - to: 'upload', - label: 'Direkt hochladen', - description: 'Teile deine neuesten Fotos', - icon: , - }, - { - to: 'gallery', - label: 'Galerie ansehen', - description: 'Lass dich von anderen inspirieren', - icon: , - }, - ]; + const primaryActions = React.useMemo( + () => [ + { + to: 'tasks', + label: t('home.actions.items.tasks.label'), + description: t('home.actions.items.tasks.description'), + icon: , + }, + { + to: 'upload', + label: t('home.actions.items.upload.label'), + description: t('home.actions.items.upload.description'), + icon: , + }, + { + to: 'gallery', + label: t('home.actions.items.gallery.label'), + description: t('home.actions.items.gallery.description'), + icon: , + }, + ], + [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 (
- + } - label="Gleichzeitig online" + label={t('home.stats.online')} value={`${stats.onlineGuests}`} /> } - label="Aufgaben gelöst" + label={t('home.stats.tasksSolved')} value={`${stats.tasksSolved}`} /> } - label="Letzter Upload" + label={t('home.stats.lastUpload')} value={latestUploadText} /> } - label="Deine erledigten Aufgaben" + label={t('home.stats.completedTasks')} value={`${completedCount}`} /> @@ -81,8 +95,10 @@ export default function HomePage() {
-

Deine Aktionen

- Waehle aus, womit du starten willst +

+ {t('home.actions.title')} +

+ {t('home.actions.subtitle')}
{primaryActions.map((action) => ( @@ -102,14 +118,14 @@ export default function HomePage() { ))}
- Dein Fortschritt - Halte dich an diese drei kurzen Schritte fuer die besten Ergebnisse. + {t('home.checklist.title')} + {t('home.checklist.description')} {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 ( - Willkommen zur Party - Hey {name}! -

Du bist bereit für "{eventName}". Fang die Highlights des Events ein und teile sie mit allen Gästen.

+ {t('home.hero.subtitle')} + {heroTitle} +

{heroDescription}

{progressMessage}

@@ -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}`); } diff --git a/resources/js/guest/pages/LandingPage.tsx b/resources/js/guest/pages/LandingPage.tsx index 848d5e2..4897558 100644 --- a/resources/js/guest/pages/LandingPage.tsx +++ b/resources/js/guest/pages/LandingPage.tsx @@ -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(null); + const [errorKey, setErrorKey] = useState(null); const [isScanning, setIsScanning] = useState(false); const [scanner, setScanner] = useState(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 ( - - {error && ( + + {errorMessage && ( - {error} + {errorMessage} )}
-

Willkommen bei der Fotobox!

-

Dein Schluessel zu unvergesslichen Momenten.

+

{t('landing.headline')}

+

{t('landing.subheadline')}

- Event beitreten - Scanne den QR-Code oder gib den Code manuell ein. + {t('landing.join.title')} + {t('landing.join.description')}
@@ -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')}
- Oder manuell eingeben + {t('landing.scan.manualDivider')}
setEventCode(event.target.value)} - placeholder="Event-Code eingeben" + placeholder={t('landing.input.placeholder')} disabled={loading} />
diff --git a/resources/js/guest/pages/NotFoundPage.tsx b/resources/js/guest/pages/NotFoundPage.tsx index 67a3f3e..b6fd826 100644 --- a/resources/js/guest/pages/NotFoundPage.tsx +++ b/resources/js/guest/pages/NotFoundPage.tsx @@ -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 ( - -

Die Seite konnte nicht gefunden werden.

+ +

{t('notFound.description')}

); } - diff --git a/resources/js/guest/pages/PhotoLightbox.tsx b/resources/js/guest/pages/PhotoLightbox.tsx index 7b31b5f..9d6aa34 100644 --- a/resources/js/guest/pages/PhotoLightbox.tsx +++ b/resources/js/guest/pages/PhotoLightbox.tsx @@ -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(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 && (
-
Task: {task.title}
+
{t('lightbox.taskLabel')}: {task.title}
{taskLoading && ( -
Lade Aufgabe...
+
{t('lightbox.loadingTask')}
)}
@@ -269,7 +271,14 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh > {`Foto { console.error('Image load error:', e); @@ -283,7 +292,7 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh
-
Lade Aufgabe...
+
{t('lightbox.loadingTask')}
)} diff --git a/resources/js/guest/pages/ProfileSetupPage.tsx b/resources/js/guest/pages/ProfileSetupPage.tsx index e4fc178..30f8ffc 100644 --- a/resources/js/guest/pages/ProfileSetupPage.tsx +++ b/resources/js/guest/pages/ProfileSetupPage.tsx @@ -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 (
-
Lade Event...
+
{t('profileSetup.loading')}
); } @@ -59,31 +60,30 @@ export default function ProfileSetupPage() { if (error || !event) { return (
-

{error || 'Event nicht gefunden.'}

- +

{error || t('profileSetup.error.default')}

+
); } return (
-
{event.name} - Fange den schoensten Moment ein! + {t('profileSetup.card.description')}
- + 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')} diff --git a/resources/js/guest/pages/SettingsPage.tsx b/resources/js/guest/pages/SettingsPage.tsx index faa0696..0002420 100644 --- a/resources/js/guest/pages/SettingsPage.tsx +++ b/resources/js/guest/pages/SettingsPage.tsx @@ -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 ( - -
    -
  • Sprache
  • -
  • Theme
  • -
  • Cache leeren
  • -
  • Rechtliches
  • -
+ +

{t('settings.subtitle')}

); } - diff --git a/resources/js/guest/pages/UploadPage.tsx b/resources/js/guest/pages/UploadPage.tsx index 76b2516..214cc76 100644 --- a/resources/js/guest/pages/UploadPage.tsx +++ b/resources/js/guest/pages/UploadPage.tsx @@ -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. �berpr�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) => { 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 (
-
+
- - Dieses Gerät unterstützt keine Kamera-Zugriffe. Du kannst stattdessen Fotos aus deiner Galerie hochladen. - + {t('upload.cameraUnsupported.message')}
@@ -549,10 +559,10 @@ export default function UploadPage() { if (loadingTask) { return (
-
+
-

Aufgabe und Kamera werden vorbereitet ...

+

{t('upload.preparing')}

@@ -562,13 +572,14 @@ export default function UploadPage() { if (!canUpload) { return (
-
+
- 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}`)}
@@ -583,13 +594,14 @@ export default function UploadPage() {
-

Bereit für dein Shooting?

+

{t('upload.primer.title')}

- 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')}

@@ -601,9 +613,7 @@ export default function UploadPage() { if (permissionState === 'unsupported') { return ( - - Dieses Gerät unterstützt keine Kamera. Nutze den Button `Foto aus Galerie wählen`, um dennoch teilzunehmen. - + {t('upload.cameraUnsupported.message')} ); } @@ -613,24 +623,22 @@ export default function UploadPage() {
{permissionMessage}
); } return ( - - - Wir benötigen Zugriff auf deine Kamera. Bestätige die Browser-Abfrage oder nutze alternativ ein Foto aus deiner Galerie. - - + + {t('upload.cameraDenied.prompt')} + ); }; return (
-
+
@@ -802,7 +815,7 @@ export default function UploadPage() { onClick={handleSwitchCamera} > - Kamera wechseln + {t('upload.switchCamera')}
@@ -820,10 +833,10 @@ export default function UploadPage() { {mode === 'review' && reviewPhoto ? (
) : ( @@ -834,7 +847,7 @@ export default function UploadPage() { disabled={!isCameraActive || mode === 'countdown'} > - Foto aufnehmen + {t('upload.captureButton')} )}
diff --git a/resources/js/guest/pages/UploadQueuePage.tsx b/resources/js/guest/pages/UploadQueuePage.tsx index 97d4ac8..f2dec3d 100644 --- a/resources/js/guest/pages/UploadQueuePage.tsx +++ b/resources/js/guest/pages/UploadQueuePage.tsx @@ -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 ( - -

Queue with progress/retry; background sync toggle.

+ +

{t('uploadQueue.description')}

); } - diff --git a/resources/js/guest/router.tsx b/resources/js/guest/router.tsx index 61b9dcb..fdca6e3 100644 --- a/resources/js/guest/router.tsx +++ b/resources/js/guest/router.tsx @@ -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 ; } + const eventLocale = isLocaleCode(event.default_locale) ? event.default_locale : DEFAULT_LOCALE; + const localeStorageKey = `guestLocale_event_${event.id ?? token}`; + return ( - -
-
-
- + + +
+
+
+ +
+
- -
- + + ); } 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 ( - -
-
- -
-
+ + +
+
+ +
+
+
); } function EventLoadingView() { + const { t } = useTranslation(); return (
-

Wir prüfen deinen Zugang...

-

Einen Moment bitte.

+

{t('eventAccess.loading.title')}

+

{t('eventAccess.loading.subtitle')}

); @@ -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 (
@@ -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' }); } } diff --git a/resources/js/layouts/app/Footer.tsx b/resources/js/layouts/app/Footer.tsx index fe310ab..4448d5a 100644 --- a/resources/js/layouts/app/Footer.tsx +++ b/resources/js/layouts/app/Footer.tsx @@ -14,16 +14,21 @@ const Footer: React.FC = () => { return (