Guest PWA vollständig lokalisiert

This commit is contained in:
Codex Agent
2025-10-17 15:00:07 +02:00
parent bd38decc23
commit 25e8f0511b
26 changed files with 1464 additions and 588 deletions

View File

@@ -1,6 +1,6 @@
# AGENTS.md — Agent Guidance for Event Photo Platform # 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 ## Purpose & Scope
- Provide clear guardrails and playbooks so agents can assist safely with code, docs, DevOps and project hygiene. - 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. - Keep this AGENTS.md authoritative. If per-agent docs diverge, update this file and link the rationale.
## Tools & Permissions ## 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://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.
- Dev Commands: composer, npm, vite, artisan, PHPUnit, Pint/ESLint, Docker/Compose (for dev). - 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). - 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). - 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) ## Repo Structure (high-level)
- docs/prp/ — split PRP (authoritative). Start at docs/prp/README.md. - docs/prp/ — split PRP (authoritative). Start at docs/prp/README.md.
- docs/changes/ — session change logs. - docs/changes/ — session change logs.
- resources/js/guest/ — Guest PWA source (standalone entry, SW at public/guest-sw.js). - docs/todo/ — prioritized backlog items (replaces single TODO.md file).
- resources/js/admin/ — Tenant Admin PWA source (standalone entry). - 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). - fotospiel_prp.md — legacy monolithic PRP (historical reference; do not edit).
- TODO.md — prioritized backlog; mirrored into Issues by Ops Agent.
## Standard Workflows ## Standard Workflows
- Coding tasks (Codegen Agent): - 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/*). 4) Update docs when public surfaces change (PRP, docs/*).
5) Propose follow-ups as Issues if out of scope. 5) Propose follow-ups as Issues if out of scope.
- Issue hygiene (Ops Agent): - 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. - Avoid duplicates by checking existing titles.
- Releases (Ops Agent): - Releases (Ops Agent):
- Tag with semantic version; generate changelog from commits/PRs; ensure legal pages and migration notes are updated. - 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: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. - 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. - 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 ## Constraints & Red-Lines
- Do not introduce tracking beyond what is documented (anonymous session_id only for guest PWA). - 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 weaken auth, CSRF, CORS, or role checks.
- Do not expand data retention without updating Privacy policy. - 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 ## Change Management
- Propose updates to this file via PR. Include: - 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/. - Links to updated docs in docs/agents/.
## References ## 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.

View File

@@ -5,6 +5,7 @@ namespace Database\Seeders;
use App\Models\LegalPage; use App\Models\LegalPage;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\File;
class LegalPagesSeeder extends Seeder class LegalPagesSeeder extends Seeder
{ {
@@ -12,157 +13,54 @@ class LegalPagesSeeder extends Seeder
{ {
$now = Carbon::now(); $now = Carbon::now();
// Impressum (DE) // Define the legal pages and their corresponding file patterns
$impressumDe = <<<MD $pages = [
# Impressum 'impressum' => [
'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 EberhardtBiermann foreach ($config['files'] as $locale => $filePath) {
Schweriner Str. 15 if (File::exists(base_path($filePath))) {
19306 NeustadtGlewe, Deutschland $bodyByLocale[$locale] = File::get(base_path($filePath));
} else {
// Fallback to empty string if file doesn't exist
$bodyByLocale[$locale] = '';
}
}
Kontakt: $this->upsert($slug, $config['title'], $bodyByLocale, $now);
}
- Telefon mobil: 0173 / 9266802
- Fax: 038757 / 54169
- EMail: soeren@sebfoto.de
- Website: https://sebfoto.de
UmsatzsteuerIdentifikationsnummer gemäß § 27a UStG: (falls vorhanden, bitte ergänzen)
Verantwortlich für den Inhalt nach § 55 Abs. 2 RStV:
Sören EberhardtBiermann, 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 = <<<MD
# Datenschutzerklärung
Diese Datenschutzerklärung informiert dich über die Verarbeitung personenbezogener Daten bei Nutzung der FotospielPlattform (GastPWA, AdminPWA, SuperAdmin Backend).
Verantwortlicher:
Sören EberhardtBiermann, Schweriner Str. 15, 19306 NeustadtGlewe, Deutschland
EMail: soeren@sebfoto.de
Zwecke der Verarbeitung:
- Bereitstellung der Plattform, Ausspielen von Inhalten, Upload und Anzeige von Veranstaltungsfotos
- Moderation und Auswertung (z.B. Likes)
- Sicherheit, Missbrauchs/Fehleranalyse, Systemlogs
Rechtsgrundlagen (DSGVO):
- Art. 6 Abs. 1 lit. b (Vertrag/Teilnahmebedingungen der Veranstaltung)
- Art. 6 Abs. 1 lit. f (berechtigtes Interesse an sicherem, funktionsfähigem Betrieb)
- Art. 6 Abs. 1 lit. a (Einwilligung, sofern erforderlich; z.B. für Push/Benachrichtigungen)
Kategorien verarbeiteter Daten:
- FotoUploads (Bilddateien, optional Metadaten wie Emotion/Task), Gerätekennung (pseudonym) für Likes/Uploads
- Ereignis/Nutzungsdaten (Zeitpunkt, Anzahl der Uploads/Likes), Protokolldaten (IP, HTTPHeader, Fehlerlogs)
Speicherdauer:
Fotos und Ereignisdaten werden für die Dauer der Veranstaltung und eine anschließende Veröffentlichungs/Auswahlphase gespeichert. Logdaten werden in der Regel nach 1430 Tagen gelöscht. Abweichungen werden tenantspezifisch dokumentiert.
Empfänger/Weitergabe:
Interne Empfänger (Administratoren/Moderatoren der jeweiligen Veranstaltung). Keine Weitergabe an Dritte außer im Rahmen der Auftragsverarbeitung (Hosting/Backup) oder bei gesetzlicher Verpflichtung.
Hosting/Verarbeitung:
Die Plattform wird auf eigenem/angemietetem Server gehostet. Mediendateien werden über die Anwendung bereitgestellt (Symlink `/storage`).
Cookies/Tracking:
Die GastPWA nutzt keine TrackingCookies. Es werden nur technisch notwendige LocalStorage/IndexedDBEinträge und ein PseudonymGeräteIdentifikator für Uploads/Likes verwendet. Optionaler ServiceWorker Cache dient der OfflineNutzung.
Deine Rechte:
Auskunft, Berichtigung, Löschung, Einschränkung, Datenübertragbarkeit, Widerspruch (Art. 1521 DSGVO). Beschwerden an die zuständige Aufsichtsbehörde sind möglich.
Kontakt für Datenschutzanfragen:
EMail: soeren@sebfoto.de
Stand: {$now->format('Y-m-d')}
MD;
$this->upsert('datenschutz', [
'de' => 'Datenschutzerklärung',
], [
'de' => $datenschutzDe,
], $now);
// AGB (DE) — baseline Terms for Fotospiel
$agbDe = <<<MD
# Allgemeine Geschäftsbedingungen (AGB)
Diese AGB regeln die Nutzung der FotospielPlattform durch Veranstalter (Tenant) und Gäste (Teilnehmer).
1. Leistungsumfang
Fotospiel ermöglicht das Erstellen, Hochladen, Moderieren und Anzeigen von Fotos im Rahmen veranstaltungsbezogener Galerien. Zusatzfunktionen (Likes, Aufgaben/Emotions, Slideshow) können variieren.
2. Registrierung/Vertragsschluss
Veranstalter erhalten einen Zugang (TenantAdmin). Gäste nutzen die PWA ohne Registrierung; Teilnahmebedingungen werden durch den Veranstalter kommuniziert.
3. Nutzungsrechte an Uploads
Gäste räumen dem Veranstalter und dem Plattformbetreiber ein einfaches, auf die jeweilige Veranstaltung beschränktes Nutzungsrecht zur Anzeige/Moderation/Präsentation ein. Weitergehende Nutzung (z.B. SocialMedia/Marketing) bedarf der Einwilligung des Rechteinhabers, sofern nicht gesetzlich zulässig.
4. Verantwortlichkeiten/Moderation
Veranstalter sind inhaltlich verantwortlich für ihre Events und moderieren Inhalte. Der Betreiber kann Inhalte bei Rechtsverstößen entfernen oder den Zugang sperren.
5. Verbotene Inhalte
Keine rechtswidrigen, diskriminierenden, pornografischen, gewaltverherrlichenden oder die Rechte Dritter verletzenden Inhalte. Keine Uploads mit personenbezogenen Daten Dritter ohne Rechtsgrundlage/Einwilligung.
6. Verfügbarkeit/Haftung
Es besteht kein Anspruch auf permanente Verfügbarkeit. Der Betreiber haftet bei Vorsatz und grober Fahrlässigkeit; im Übrigen nur bei Verletzung wesentlicher Vertragspflichten, begrenzt auf vorhersehbare Schäden.
7. Datenschutz
Es gilt die Datenschutzerklärung. Veranstalter sind für ihre rechtliche Grundlage gegenüber Gästen verantwortlich (z.B. Einwilligungen/Aushänge).
8. Laufzeit/Kündigung
Nutzung ist eventbezogen. Der Betreiber kann bei Verstößen kündigen oder den Zugang sperren.
9. Schlussbestimmungen
Es gilt deutsches Recht. Gerichtsstand ist, soweit zulässig, der Sitz des Betreibers. Sollten einzelne Bestimmungen unwirksam sein, bleibt der Vertrag im Übrigen wirksam.
Stand: {$now->format('Y-m-d')}
MD;
$this->upsert('agb', [
'de' => 'Allgemeine Geschäftsbedingungen',
], [
'de' => $agbDe,
], $now);
} }
private function upsert(string $slug, array $titleByLocale, array $bodyByLocale, \DateTimeInterface $effectiveFrom): void private function upsert(string $slug, array $titleByLocale, array $bodyByLocale, \DateTimeInterface $effectiveFrom): void

Binary file not shown.

After

Width:  |  Height:  |  Size: 362 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 526 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

View File

@@ -2,6 +2,7 @@ import React from 'react';
import { NavLink, useParams, useLocation } from 'react-router-dom'; import { NavLink, useParams, useLocation } from 'react-router-dom';
import { CheckSquare, GalleryHorizontal, Home, Trophy } from 'lucide-react'; import { CheckSquare, GalleryHorizontal, Home, Trophy } from 'lucide-react';
import { useEventData } from '../hooks/useEventData'; import { useEventData } from '../hooks/useEventData';
import { useTranslation } from '../i18n/useTranslation';
function TabLink({ function TabLink({
to, to,
@@ -31,32 +32,21 @@ export default function BottomNav() {
const { token } = useParams(); const { token } = useParams();
const location = useLocation(); const location = useLocation();
const { event, status } = useEventData(); const { event, status } = useEventData();
const { t } = useTranslation();
const isReady = status === 'ready' && !!event; const isReady = status === 'ready' && !!event;
if (!token || !isReady) return null; // Only show bottom nav within event context if (!token || !isReady) return null; // Only show bottom nav within event context
const base = `/e/${encodeURIComponent(token)}`; const base = `/e/${encodeURIComponent(token)}`;
const currentPath = location.pathname; const currentPath = location.pathname;
const locale = event?.default_locale || 'de';
// Translations const labels = {
const translations = { home: t('navigation.home'),
de: { tasks: t('navigation.tasks'),
home: 'Start', achievements: t('navigation.achievements'),
tasks: 'Aufgaben', gallery: t('navigation.gallery'),
achievements: 'Erfolge',
gallery: 'Galerie'
},
en: {
home: 'Home',
tasks: 'Tasks',
achievements: 'Achievements',
gallery: 'Gallery'
}
}; };
const t = translations[locale as keyof typeof translations] || translations.de;
// Improved active state logic // Improved active state logic
const isHomeActive = currentPath === base || currentPath === `/${token}`; const isHomeActive = currentPath === base || currentPath === `/${token}`;
const isTasksActive = currentPath.startsWith(`${base}/tasks`) || currentPath === `${base}/upload`; 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"> <div className="mx-auto flex max-w-sm items-center justify-around">
<TabLink to={`${base}`} isActive={isHomeActive}> <TabLink to={`${base}`} isActive={isHomeActive}>
<div className="flex flex-col items-center gap-1"> <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> </div>
</TabLink> </TabLink>
<TabLink to={`${base}/tasks`} isActive={isTasksActive}> <TabLink to={`${base}/tasks`} isActive={isTasksActive}>
<div className="flex flex-col items-center gap-1"> <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> </div>
</TabLink> </TabLink>
<TabLink to={`${base}/achievements`} isActive={isAchievementsActive}> <TabLink to={`${base}/achievements`} isActive={isAchievementsActive}>
<div className="flex flex-col items-center gap-1"> <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> </div>
</TabLink> </TabLink>
<TabLink to={`${base}/gallery`} isActive={isGalleryActive}> <TabLink to={`${base}/gallery`} isActive={isGalleryActive}>
<div className="flex flex-col items-center gap-1"> <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> </div>
</TabLink> </TabLink>
</div> </div>

View File

@@ -1,14 +1,77 @@
import React from 'react'; import React from 'react';
import AppearanceToggleDropdown from '@/components/appearance-dropdown'; 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 { useEventData } from '../hooks/useEventData';
import { useOptionalEventStats } from '../context/EventStatsContext'; import { useOptionalEventStats } from '../context/EventStatsContext';
import { useOptionalGuestIdentity } from '../context/GuestIdentityContext'; import { useOptionalGuestIdentity } from '../context/GuestIdentityContext';
import { SettingsSheet } from './settings-sheet'; 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 }) { export default function Header({ slug, title = '' }: { slug?: string; title?: string }) {
const statsContext = useOptionalEventStats(); const statsContext = useOptionalEventStats();
const identity = useOptionalGuestIdentity(); const identity = useOptionalGuestIdentity();
const { t } = useTranslation();
if (!slug) { if (!slug) {
const guestName = identity?.name && identity?.hydrated ? identity.name : null; 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="flex flex-col">
<div className="font-semibold">{title}</div> <div className="font-semibold">{title}</div>
{guestName && ( {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>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -35,7 +100,7 @@ export default function Header({ slug, title = '' }: { slug?: string; title?: st
if (status === 'loading') { if (status === 'loading') {
return ( 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="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"> <div className="flex items-center gap-2">
<AppearanceToggleDropdown /> <AppearanceToggleDropdown />
<SettingsSheet /> <SettingsSheet />
@@ -51,49 +116,28 @@ export default function Header({ slug, title = '' }: { slug?: string; title?: st
const stats = const stats =
statsContext && statsContext.eventKey === slug ? statsContext : undefined; 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 ( 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="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"> <div className="flex items-center gap-3">
{getEventAvatar(event)} {renderEventAvatar(event.name, event.type?.icon)}
<div className="flex flex-col"> <div className="flex flex-col">
<div className="font-semibold text-base">{event.name}</div> <div className="font-semibold text-base">{event.name}</div>
{guestName && ( {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"> <div className="flex items-center gap-2 text-xs text-muted-foreground">
{stats && ( {stats && (
<> <>
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<User className="h-3 w-3" /> <User className="h-3 w-3" />
<span>{stats.onlineGuests} online</span> <span>{`${stats.onlineGuests} ${t('header.stats.online')}`}</span>
</span> </span>
<span className="text-muted-foreground">|</span> <span className="text-muted-foreground">|</span>
<span className="flex items-center gap-1"> <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> </span>
</> </>
)} )}

View File

@@ -1,5 +1,6 @@
import React from "react"; import React from "react";
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { import {
Sheet, Sheet,
SheetTrigger, SheetTrigger,
@@ -15,16 +16,23 @@ import { Label } from '@/components/ui/label';
import { Settings, ArrowLeft, FileText, RefreshCcw, ChevronRight, UserCircle } from 'lucide-react'; import { Settings, ArrowLeft, FileText, RefreshCcw, ChevronRight, UserCircle } from 'lucide-react';
import { useOptionalGuestIdentity } from '../context/GuestIdentityContext'; import { useOptionalGuestIdentity } from '../context/GuestIdentityContext';
import { LegalMarkdown } from './legal-markdown'; 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 = [ const legalPages = [
{ slug: 'impressum', label: 'Impressum' }, { slug: 'impressum', translationKey: 'settings.legal.section.impressum' },
{ slug: 'datenschutz', label: 'Datenschutz' }, { slug: 'datenschutz', translationKey: 'settings.legal.section.privacy' },
{ slug: 'agb', label: 'AGB' }, { slug: 'agb', translationKey: 'settings.legal.section.terms' },
] as const; ] as const;
type ViewState = type ViewState =
| { mode: 'home' } | { 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 = type LegalDocumentState =
| { phase: 'idle'; title: string; body: string } | { phase: 'idle'; title: string; body: string }
@@ -38,11 +46,13 @@ export function SettingsSheet() {
const [open, setOpen] = React.useState(false); const [open, setOpen] = React.useState(false);
const [view, setView] = React.useState<ViewState>({ mode: 'home' }); const [view, setView] = React.useState<ViewState>({ mode: 'home' });
const identity = useOptionalGuestIdentity(); const identity = useOptionalGuestIdentity();
const localeContext = useLocale();
const { t } = useTranslation();
const [nameDraft, setNameDraft] = React.useState(identity?.name ?? ''); const [nameDraft, setNameDraft] = React.useState(identity?.name ?? '');
const [nameStatus, setNameStatus] = React.useState<NameStatus>('idle'); const [nameStatus, setNameStatus] = React.useState<NameStatus>('idle');
const [savingName, setSavingName] = React.useState(false); const [savingName, setSavingName] = React.useState(false);
const isLegal = view.mode === 'legal'; const isLegal = view.mode === 'legal';
const legalDocument = useLegalDocument(isLegal ? view.slug : null); const legalDocument = useLegalDocument(isLegal ? view.slug : null, localeContext.locale);
React.useEffect(() => { React.useEffect(() => {
if (open && identity?.hydrated) { if (open && identity?.hydrated) {
@@ -56,10 +66,13 @@ export function SettingsSheet() {
}, []); }, []);
const handleOpenLegal = React.useCallback( 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) => { const handleOpenChange = React.useCallback((next: boolean) => {
@@ -100,7 +113,7 @@ export function SettingsSheet() {
<SheetTrigger asChild> <SheetTrigger asChild>
<Button variant="ghost" size="icon" className="h-9 w-9 rounded-md"> <Button variant="ghost" size="icon" className="h-9 w-9 rounded-md">
<Settings className="h-5 w-5" /> <Settings className="h-5 w-5" />
<span className="sr-only">Einstellungen oeffnen</span> <span className="sr-only">{t('settings.sheet.openLabel')}</span>
</Button> </Button>
</SheetTrigger> </SheetTrigger>
<SheetContent side="right" className="sm:max-w-md"> <SheetContent side="right" className="sm:max-w-md">
@@ -115,32 +128,36 @@ export function SettingsSheet() {
onClick={handleBack} onClick={handleBack}
> >
<ArrowLeft className="h-5 w-5" /> <ArrowLeft className="h-5 w-5" />
<span className="sr-only">Zurück</span> <span className="sr-only">{t('settings.sheet.backLabel')}</span>
</Button> </Button>
<div className="min-w-0"> <div className="min-w-0">
<SheetTitle className="truncate"> <SheetTitle className="truncate">
{legalDocument.phase === 'ready' && legalDocument.title {legalDocument.phase === 'ready' && legalDocument.title
? legalDocument.title ? legalDocument.title
: view.label} : t(view.translationKey)}
</SheetTitle> </SheetTitle>
<SheetDescription> <SheetDescription>
{legalDocument.phase === 'loading' ? 'Laedt...' : 'Rechtlicher Hinweis'} {legalDocument.phase === 'loading'
? t('common.actions.loading')
: t('settings.sheet.legalDescription')}
</SheetDescription> </SheetDescription>
</div> </div>
</div> </div>
) : ( ) : (
<div> <div>
<SheetTitle>Einstellungen</SheetTitle> <SheetTitle>{t('settings.title')}</SheetTitle>
<SheetDescription> <SheetDescription>{t('settings.subtitle')}</SheetDescription>
Verwalte deinen Gastzugang, rechtliche Dokumente und lokale Daten.
</SheetDescription>
</div> </div>
)} )}
</header> </header>
<main className="flex-1 overflow-y-auto px-6 py-4"> <main className="flex-1 overflow-y-auto px-6 py-4">
{isLegal ? ( {isLegal ? (
<LegalView document={legalDocument} onClose={() => handleOpenChange(false)} /> <LegalView
document={legalDocument}
onClose={() => handleOpenChange(false)}
translationKey={view.mode === 'legal' ? view.translationKey : null}
/>
) : ( ) : (
<HomeView <HomeView
identity={identity} identity={identity}
@@ -151,13 +168,14 @@ export function SettingsSheet() {
canSaveName={canSaveName} canSaveName={canSaveName}
savingName={savingName} savingName={savingName}
nameStatus={nameStatus} nameStatus={nameStatus}
localeContext={localeContext}
onOpenLegal={handleOpenLegal} onOpenLegal={handleOpenLegal}
/> />
)} )}
</main> </main>
<SheetFooter className="border-t bg-muted/40 px-6 py-3 text-xs text-muted-foreground"> <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> </SheetFooter>
</div> </div>
</SheetContent> </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') { if (document.phase === 'error') {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<Alert variant="destructive"> <Alert variant="destructive">
<AlertDescription> <AlertDescription>
Das Dokument konnte nicht geladen werden. Bitte versuche es spaeter erneut. {t('settings.legal.error')}
</AlertDescription> </AlertDescription>
</Alert> </Alert>
<Button variant="secondary" onClick={onClose}> <Button variant="secondary" onClick={onClose}>
Schliessen {t('common.actions.close')}
</Button> </Button>
</div> </div>
); );
} }
if (document.phase === 'loading' || document.phase === 'idle') { 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 ( return (
<div className="space-y-4"> <div className="space-y-4">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>{document.title || 'Rechtlicher Hinweis'}</CardTitle> <CardTitle>{document.title || t(translationKey ?? 'settings.legal.fallbackTitle')}</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="prose prose-sm max-w-none dark:prose-invert"> <CardContent className="prose prose-sm max-w-none dark:prose-invert">
<LegalMarkdown markdown={document.body} /> <LegalMarkdown markdown={document.body} />
@@ -208,7 +236,11 @@ interface HomeViewProps {
canSaveName: boolean; canSaveName: boolean;
savingName: boolean; savingName: boolean;
nameStatus: NameStatus; 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({ function HomeView({
@@ -220,17 +252,65 @@ function HomeView({
canSaveName, canSaveName,
savingName, savingName,
nameStatus, nameStatus,
localeContext,
onOpenLegal, onOpenLegal,
}: HomeViewProps) { }: HomeViewProps) {
const { t } = useTranslation();
const legalLinks = React.useMemo(
() =>
legalPages.map((page) => ({
slug: page.slug,
translationKey: page.translationKey,
label: t(page.translationKey),
})),
[t],
);
return ( return (
<div className="space-y-6"> <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 && ( {identity && (
<Card> <Card>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle>Dein Name</CardTitle> <CardTitle>{t('settings.name.title')}</CardTitle>
<CardDescription> <CardDescription>{t('settings.name.description')}</CardDescription>
Passe an, wie wir dich im Event begruessen. Der Name wird nur lokal gespeichert.
</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -239,12 +319,12 @@ function HomeView({
</div> </div>
<div className="flex-1 space-y-2"> <div className="flex-1 space-y-2">
<Label htmlFor="guest-name" className="text-sm font-medium"> <Label htmlFor="guest-name" className="text-sm font-medium">
Anzeigename {t('settings.name.label')}
</Label> </Label>
<Input <Input
id="guest-name" id="guest-name"
value={nameDraft} value={nameDraft}
placeholder="z.B. Anna" placeholder={t('settings.name.placeholder')}
onChange={(event) => onNameChange(event.target.value)} onChange={(event) => onNameChange(event.target.value)}
autoComplete="name" autoComplete="name"
disabled={!identity.hydrated || savingName} disabled={!identity.hydrated || savingName}
@@ -253,16 +333,16 @@ function HomeView({
</div> </div>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Button onClick={onSaveName} disabled={!canSaveName || savingName}> <Button onClick={onSaveName} disabled={!canSaveName || savingName}>
{savingName ? 'Speichere...' : 'Name speichern'} {savingName ? t('settings.name.saving') : t('settings.name.save')}
</Button> </Button>
<Button type="button" variant="ghost" onClick={onResetName} disabled={savingName}> <Button type="button" variant="ghost" onClick={onResetName} disabled={savingName}>
zurücksetzen {t('settings.name.reset')}
</Button> </Button>
{nameStatus === 'saved' && ( {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 && ( {!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> </div>
</CardContent> </CardContent>
@@ -274,20 +354,18 @@ function HomeView({
<CardTitle> <CardTitle>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-pink-500" /> <FileText className="h-4 w-4 text-pink-500" />
Rechtliches {t('settings.legal.title')}
</div> </div>
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>{t('settings.legal.description')}</CardDescription>
Die rechtlich verbindlichen Texte sind jederzeit hier abrufbar.
</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-2"> <CardContent className="space-y-2">
{legalPages.map((page) => ( {legalLinks.map((page) => (
<Button <Button
key={page.slug} key={page.slug}
variant="ghost" variant="ghost"
className="w-full justify-between px-3" 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> <span className="text-left text-sm">{page.label}</span>
<ChevronRight className="h-4 w-4" /> <ChevronRight className="h-4 w-4" />
@@ -298,16 +376,14 @@ function HomeView({
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Offline Cache</CardTitle> <CardTitle>{t('settings.cache.title')}</CardTitle>
<CardDescription> <CardDescription>{t('settings.cache.description')}</CardDescription>
Loesche lokale Daten, falls Inhalte veraltet erscheinen oder Uploads haengen bleiben.
</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
<ClearCacheButton /> <ClearCacheButton />
<div className="flex items-start gap-2 text-xs text-muted-foreground"> <div className="flex items-start gap-2 text-xs text-muted-foreground">
<RefreshCcw className="mt-0.5 h-3.5 w-3.5" /> <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> </div>
</CardContent> </CardContent>
</Card> </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>({ const [state, setState] = React.useState<LegalDocumentState>({
phase: 'idle', phase: 'idle',
title: '', title: '',
@@ -331,7 +407,8 @@ function useLegalDocument(slug: string | null): LegalDocumentState {
const controller = new AbortController(); const controller = new AbortController();
setState({ phase: 'loading', title: '', body: '' }); 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' }, headers: { 'Cache-Control': 'no-store' },
signal: controller.signal, signal: controller.signal,
}) })
@@ -355,7 +432,7 @@ function useLegalDocument(slug: string | null): LegalDocumentState {
}); });
return () => controller.abort(); return () => controller.abort();
}, [slug]); }, [slug, locale]);
return state; return state;
} }
@@ -363,6 +440,7 @@ function useLegalDocument(slug: string | null): LegalDocumentState {
function ClearCacheButton() { function ClearCacheButton() {
const [busy, setBusy] = React.useState(false); const [busy, setBusy] = React.useState(false);
const [done, setDone] = React.useState(false); const [done, setDone] = React.useState(false);
const { t } = useTranslation();
async function clearAll() { async function clearAll() {
setBusy(true); setBusy(true);
@@ -393,9 +471,9 @@ function ClearCacheButton() {
return ( return (
<div className="space-y-2"> <div className="space-y-2">
<Button variant="secondary" onClick={clearAll} disabled={busy} className="w-full"> <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> </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> </div>
); );
} }

View 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);
}

View 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);
}

View 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]);
}

View File

@@ -5,6 +5,7 @@ import { router } from './router';
import '../../css/app.css'; import '../../css/app.css';
import { initializeTheme } from '@/hooks/use-appearance'; import { initializeTheme } from '@/hooks/use-appearance';
import { ToastProvider } from './components/ToastHost'; import { ToastProvider } from './components/ToastHost';
import { LocaleProvider } from './i18n/LocaleContext';
initializeTheme(); initializeTheme();
const rootEl = document.getElementById('root')!; const rootEl = document.getElementById('root')!;
@@ -25,9 +26,10 @@ if ('serviceWorker' in navigator) {
} }
createRoot(rootEl).render( createRoot(rootEl).render(
<React.StrictMode> <React.StrictMode>
<ToastProvider> <LocaleProvider>
<RouterProvider router={router} /> <ToastProvider>
</ToastProvider> <RouterProvider router={router} />
</ToastProvider>
</LocaleProvider>
</React.StrictMode> </React.StrictMode>
); );

View File

@@ -10,6 +10,7 @@ import { useEventStats } from '../context/EventStatsContext';
import { useEventData } from '../hooks/useEventData'; import { useEventData } from '../hooks/useEventData';
import { useGuestTaskProgress } from '../hooks/useGuestTaskProgress'; import { useGuestTaskProgress } from '../hooks/useGuestTaskProgress';
import { Sparkles, UploadCloud, Images, CheckCircle2, Users, TimerReset } from 'lucide-react'; import { Sparkles, UploadCloud, Images, CheckCircle2, Users, TimerReset } from 'lucide-react';
import { useTranslation, type TranslateFn } from '../i18n/useTranslation';
export default function HomePage() { export default function HomePage() {
const { token } = useParams<{ token: string }>(); const { token } = useParams<{ token: string }>();
@@ -17,63 +18,76 @@ export default function HomePage() {
const stats = useEventStats(); const stats = useEventStats();
const { event } = useEventData(); const { event } = useEventData();
const { completedCount } = useGuestTaskProgress(token); const { completedCount } = useGuestTaskProgress(token);
const { t } = useTranslation();
if (!token) return null; if (!token) return null;
const displayName = hydrated && name ? name : 'Gast'; const displayName = hydrated && name ? name : t('home.fallbackGuestName');
const latestUploadText = formatLatestUpload(stats.latestPhotoAt); 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 }> = [ const primaryActions = React.useMemo(
{ () => [
to: 'tasks', {
label: 'Aufgabe ziehen', to: 'tasks',
description: 'Hol dir deine naechste Challenge', label: t('home.actions.items.tasks.label'),
icon: <Sparkles className="h-5 w-5" />, description: t('home.actions.items.tasks.description'),
}, icon: <Sparkles className="h-5 w-5" />,
{ },
to: 'upload', {
label: 'Direkt hochladen', to: 'upload',
description: 'Teile deine neuesten Fotos', label: t('home.actions.items.upload.label'),
icon: <UploadCloud className="h-5 w-5" />, description: t('home.actions.items.upload.description'),
}, icon: <UploadCloud className="h-5 w-5" />,
{ },
to: 'gallery', {
label: 'Galerie ansehen', to: 'gallery',
description: 'Lass dich von anderen inspirieren', label: t('home.actions.items.gallery.label'),
icon: <Images className="h-5 w-5" />, description: t('home.actions.items.gallery.description'),
}, icon: <Images className="h-5 w-5" />,
]; },
],
[t],
);
const checklistItems = [ const checklistItems = React.useMemo(
'Aufgabe auswaehlen oder starten', () => [
'Emotion festhalten und Foto schiessen', t('home.checklist.steps.first'),
'Bild hochladen und Credits sammeln', t('home.checklist.steps.second'),
]; t('home.checklist.steps.third'),
],
[t],
);
return ( return (
<div className="space-y-6 pb-24"> <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> <Card>
<CardContent className="grid grid-cols-1 gap-4 py-4 sm:grid-cols-4"> <CardContent className="grid grid-cols-1 gap-4 py-4 sm:grid-cols-4">
<StatTile <StatTile
icon={<Users className="h-4 w-4" />} icon={<Users className="h-4 w-4" />}
label="Gleichzeitig online" label={t('home.stats.online')}
value={`${stats.onlineGuests}`} value={`${stats.onlineGuests}`}
/> />
<StatTile <StatTile
icon={<Sparkles className="h-4 w-4" />} icon={<Sparkles className="h-4 w-4" />}
label="Aufgaben gelöst" label={t('home.stats.tasksSolved')}
value={`${stats.tasksSolved}`} value={`${stats.tasksSolved}`}
/> />
<StatTile <StatTile
icon={<TimerReset className="h-4 w-4" />} icon={<TimerReset className="h-4 w-4" />}
label="Letzter Upload" label={t('home.stats.lastUpload')}
value={latestUploadText} value={latestUploadText}
/> />
<StatTile <StatTile
icon={<CheckCircle2 className="h-4 w-4" />} icon={<CheckCircle2 className="h-4 w-4" />}
label="Deine erledigten Aufgaben" label={t('home.stats.completedTasks')}
value={`${completedCount}`} value={`${completedCount}`}
/> />
</CardContent> </CardContent>
@@ -81,8 +95,10 @@ export default function HomePage() {
<section className="space-y-3"> <section className="space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">Deine Aktionen</h2> <h2 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
<span className="text-xs text-muted-foreground">Waehle aus, womit du starten willst</span> {t('home.actions.title')}
</h2>
<span className="text-xs text-muted-foreground">{t('home.actions.subtitle')}</span>
</div> </div>
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
{primaryActions.map((action) => ( {primaryActions.map((action) => (
@@ -102,14 +118,14 @@ export default function HomePage() {
))} ))}
</div> </div>
<Button variant="outline" asChild className="w-full"> <Button variant="outline" asChild className="w-full">
<Link to="queue">Uploads in Warteschlange ansehen</Link> <Link to="queue">{t('home.actions.queueButton')}</Link>
</Button> </Button>
</section> </section>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Dein Fortschritt</CardTitle> <CardTitle>{t('home.checklist.title')}</CardTitle>
<CardDescription>Halte dich an diese drei kurzen Schritte fuer die besten Ergebnisse.</CardDescription> <CardDescription>{t('home.checklist.description')}</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
{checklistItems.map((item) => ( {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 const progressMessage = tasksCompleted > 0
? `Schon ${tasksCompleted} Aufgaben erledigt - weiter so!` ? t('home.hero.progress.some').replace('{count}', `${tasksCompleted}`)
: 'Starte mit deiner ersten Aufgabe - wir zählen auf dich!'; : t('home.hero.progress.none');
return ( return (
<Card className="overflow-hidden border-0 bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 text-white shadow-md"> <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"> <CardHeader className="space-y-1">
<CardDescription className="text-sm text-white/80">Willkommen zur Party</CardDescription> <CardDescription className="text-sm text-white/80">{t('home.hero.subtitle')}</CardDescription>
<CardTitle className="text-2xl font-bold">Hey {name}!</CardTitle> <CardTitle className="text-2xl font-bold">{heroTitle}</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> <p className="text-sm text-white/80">{heroDescription}</p>
<p className="text-sm font-medium text-white/90">{progressMessage}</p> <p className="text-sm font-medium text-white/90">{progressMessage}</p>
</CardHeader> </CardHeader>
</Card> </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) { if (!isoDate) {
return 'Noch kein Upload'; return t('home.latestUpload.none');
} }
const date = new Date(isoDate); const date = new Date(isoDate);
if (Number.isNaN(date.getTime())) { if (Number.isNaN(date.getTime())) {
return 'Noch kein Upload'; return t('home.latestUpload.invalid');
} }
const diffMs = Date.now() - date.getTime(); const diffMs = Date.now() - date.getTime();
const diffMinutes = Math.round(diffMs / 60000); const diffMinutes = Math.round(diffMs / 60000);
if (diffMinutes < 1) { if (diffMinutes < 1) {
return 'Gerade eben'; return t('home.latestUpload.justNow');
} }
if (diffMinutes < 60) { if (diffMinutes < 60) {
return `vor ${diffMinutes} Min`; return t('home.latestUpload.minutes').replace('{count}', `${diffMinutes}`);
} }
const diffHours = Math.round(diffMinutes / 60); const diffHours = Math.round(diffMinutes / 60);
if (diffHours < 24) { if (diffHours < 24) {
return `vor ${diffHours} Std`; return t('home.latestUpload.hours').replace('{count}', `${diffHours}`);
} }
const diffDays = Math.round(diffHours / 24); const diffDays = Math.round(diffHours / 24);
return `vor ${diffDays} Tagen`; return t('home.latestUpload.days').replace('{count}', `${diffDays}`);
} }

View File

@@ -7,14 +7,19 @@ import { Alert, AlertDescription } from '@/components/ui/alert';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Html5Qrcode } from 'html5-qrcode'; import { Html5Qrcode } from 'html5-qrcode';
import { readGuestName } from '../context/GuestIdentityContext'; import { readGuestName } from '../context/GuestIdentityContext';
import { useTranslation } from '../i18n/useTranslation';
type LandingErrorKey = 'eventClosed' | 'network' | 'camera';
export default function LandingPage() { export default function LandingPage() {
const nav = useNavigate(); const nav = useNavigate();
const { t } = useTranslation();
const [eventCode, setEventCode] = useState(''); const [eventCode, setEventCode] = useState('');
const [loading, setLoading] = useState(false); 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 [isScanning, setIsScanning] = useState(false);
const [scanner, setScanner] = useState<Html5Qrcode | null>(null); const [scanner, setScanner] = useState<Html5Qrcode | null>(null);
const errorMessage = errorKey ? t(`landing.errors.${errorKey}`) : null;
function extractEventKey(raw: string): string { function extractEventKey(raw: string): string {
const trimmed = raw.trim(); const trimmed = raw.trim();
@@ -48,11 +53,11 @@ export default function LandingPage() {
const normalized = extractEventKey(provided); const normalized = extractEventKey(provided);
if (!normalized) return; if (!normalized) return;
setLoading(true); setLoading(true);
setError(null); setErrorKey(null);
try { try {
const res = await fetch(`/api/v1/events/${encodeURIComponent(normalized)}`); const res = await fetch(`/api/v1/events/${encodeURIComponent(normalized)}`);
if (!res.ok) { if (!res.ok) {
setError('Event nicht gefunden oder geschlossen.'); setErrorKey('eventClosed');
return; return;
} }
const data = await res.json(); const data = await res.json();
@@ -65,7 +70,7 @@ export default function LandingPage() {
} }
} catch (e) { } catch (e) {
console.error('Join request failed', e); console.error('Join request failed', e);
setError('Netzwerkfehler. Bitte spaeter erneut versuchen.'); setErrorKey('network');
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -80,7 +85,7 @@ export default function LandingPage() {
setIsScanning(true); setIsScanning(true);
} catch (err) { } catch (err) {
console.error('Scanner start failed', err); console.error('Scanner start failed', err);
setError('Kamera-Zugriff fehlgeschlagen. Bitte erlaube den Zugriff und versuche es erneut.'); setErrorKey('camera');
} }
return; return;
} }
@@ -92,7 +97,7 @@ export default function LandingPage() {
setIsScanning(true); setIsScanning(true);
} catch (err) { } catch (err) {
console.error('Scanner initialisation failed', 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]); }, [scanner]);
return ( return (
<Page title="Willkommen bei der Fotobox!"> <Page title={t('landing.pageTitle')}>
{error && ( {errorMessage && (
<Alert className="mb-3" variant="destructive"> <Alert className="mb-3" variant="destructive">
<AlertDescription>{error}</AlertDescription> <AlertDescription>{errorMessage}</AlertDescription>
</Alert> </Alert>
)} )}
<div className="space-y-6 pb-20"> <div className="space-y-6 pb-20">
<div className="text-center space-y-2"> <div className="text-center space-y-2">
<h1 className="text-2xl font-bold text-gray-900">Willkommen bei der Fotobox!</h1> <h1 className="text-2xl font-bold text-gray-900">{t('landing.headline')}</h1>
<p className="text-lg text-gray-600">Dein Schluessel zu unvergesslichen Momenten.</p> <p className="text-lg text-gray-600">{t('landing.subheadline')}</p>
</div> </div>
<Card className="mx-auto w-full max-w-md"> <Card className="mx-auto w-full max-w-md">
<CardHeader className="text-center"> <CardHeader className="text-center">
<CardTitle className="text-xl font-semibold">Event beitreten</CardTitle> <CardTitle className="text-xl font-semibold">{t('landing.join.title')}</CardTitle>
<CardDescription>Scanne den QR-Code oder gib den Code manuell ein.</CardDescription> <CardDescription>{t('landing.join.description')}</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4 p-6"> <CardContent className="space-y-4 p-6">
<div className="flex flex-col items-center space-y-3"> <div className="flex flex-col items-center space-y-3">
@@ -155,20 +160,20 @@ export default function LandingPage() {
onClick={isScanning ? stopScanner : startScanner} onClick={isScanning ? stopScanner : startScanner}
disabled={loading} disabled={loading}
> >
{isScanning ? 'Scanner stoppen' : 'QR-Code scannen'} {isScanning ? t('landing.scan.stop') : t('landing.scan.start')}
</Button> </Button>
</div> </div>
</div> </div>
<div className="border-t border-gray-200 py-2 text-center text-sm text-gray-500"> <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>
<div className="space-y-2"> <div className="space-y-2">
<Input <Input
value={eventCode} value={eventCode}
onChange={(event) => setEventCode(event.target.value)} onChange={(event) => setEventCode(event.target.value)}
placeholder="Event-Code eingeben" placeholder={t('landing.input.placeholder')}
disabled={loading} disabled={loading}
/> />
<Button <Button
@@ -176,7 +181,7 @@ export default function LandingPage() {
disabled={loading || !eventCode.trim()} disabled={loading || !eventCode.trim()}
onClick={() => join()} onClick={() => join()}
> >
{loading ? 'Pruefe...' : 'Event beitreten'} {loading ? t('landing.join.buttonLoading') : t('landing.join.button')}
</Button> </Button>
</div> </div>
</CardContent> </CardContent>

View File

@@ -1,11 +1,12 @@
import React from 'react'; import React from 'react';
import { Page } from './_util'; import { Page } from './_util';
import { useTranslation } from '../i18n/useTranslation';
export default function NotFoundPage() { export default function NotFoundPage() {
const { t } = useTranslation();
return ( return (
<Page title="Nicht gefunden"> <Page title={t('notFound.title')}>
<p>Die Seite konnte nicht gefunden werden.</p> <p>{t('notFound.description')}</p>
</Page> </Page>
); );
} }

View File

@@ -4,6 +4,7 @@ import { Dialog, DialogContent } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Heart, ChevronLeft, ChevronRight, X } from 'lucide-react'; import { Heart, ChevronLeft, ChevronRight, X } from 'lucide-react';
import { likePhoto } from '../services/photosApi'; import { likePhoto } from '../services/photosApi';
import { useTranslation } from '../i18n/useTranslation';
type Photo = { type Photo = {
id: number; id: number;
@@ -31,6 +32,7 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh
const navigate = useNavigate(); const navigate = useNavigate();
const photoId = params.photoId; const photoId = params.photoId;
const eventSlug = params.token || slug; const eventSlug = params.token || slug;
const { t } = useTranslation();
const [standalonePhoto, setStandalonePhoto] = useState<Photo | null>(null); const [standalonePhoto, setStandalonePhoto] = useState<Photo | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -65,10 +67,10 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh
setStandalonePhoto(location.state.photo); setStandalonePhoto(location.state.photo);
} }
} else { } else {
setError('Foto nicht gefunden'); setError(t('lightbox.errors.notFound'));
} }
} catch (err) { } catch (err) {
setError('Fehler beim Laden des Fotos'); setError(t('lightbox.errors.loadFailed'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -78,7 +80,7 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh
} else if (!isStandalone) { } else if (!isStandalone) {
setLoading(false); setLoading(false);
} }
}, [isStandalone, photoId, eventSlug, standalonePhoto, location.state]); }, [isStandalone, photoId, eventSlug, standalonePhoto, location.state, t]);
// Update likes when photo changes // Update likes when photo changes
React.useEffect(() => { React.useEffect(() => {
@@ -149,31 +151,31 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh
if (foundTask) { if (foundTask) {
setTask({ setTask({
id: foundTask.id, id: foundTask.id,
title: foundTask.title || `Aufgabe ${taskId}` title: foundTask.title || t('lightbox.fallbackTitle').replace('{id}', `${taskId}`)
}); });
} else { } else {
setTask({ setTask({
id: taskId, id: taskId,
title: `Unbekannte Aufgabe ${taskId}` title: t('lightbox.unknownTitle').replace('{id}', `${taskId}`)
}); });
} }
} else { } else {
setTask({ setTask({
id: taskId, id: taskId,
title: `Unbekannte Aufgabe ${taskId}` title: t('lightbox.unknownTitle').replace('{id}', `${taskId}`)
}); });
} }
} catch (error) { } catch (error) {
console.error('Failed to load task:', error); console.error('Failed to load task:', error);
setTask({ setTask({
id: taskId, id: taskId,
title: `Unbekannte Aufgabe ${taskId}` title: t('lightbox.unknownTitle').replace('{id}', `${taskId}`)
}); });
} finally { } finally {
setTaskLoading(false); setTaskLoading(false);
} }
})(); })();
}, [photo?.task_id, eventSlug]); }, [photo?.task_id, eventSlug, t]);
async function onLike() { async function onLike() {
if (liked || !photo) return; if (liked || !photo) return;
@@ -251,9 +253,9 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh
{task && ( {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="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="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 && ( {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>
</div> </div>
@@ -269,7 +271,14 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh
> >
<img <img
src={photo?.file_path || photo?.thumbnail_path} 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" className="max-h-[80vh] max-w-full object-contain transition-transform duration-200"
onError={(e) => { onError={(e) => {
console.error('Image load error:', 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="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="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="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>
</div> </div>
)} )}

View File

@@ -6,7 +6,7 @@ import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import Header from '../components/Header'; import { useTranslation } from '../i18n/useTranslation';
export default function ProfileSetupPage() { export default function ProfileSetupPage() {
const { token } = useParams<{ token: string }>(); const { token } = useParams<{ token: string }>();
@@ -15,6 +15,7 @@ export default function ProfileSetupPage() {
const { name: storedName, setName: persistName, hydrated } = useGuestIdentity(); const { name: storedName, setName: persistName, hydrated } = useGuestIdentity();
const [name, setName] = useState(storedName); const [name, setName] = useState(storedName);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const { t } = useTranslation();
useEffect(() => { useEffect(() => {
if (!token) { if (!token) {
@@ -51,7 +52,7 @@ export default function ProfileSetupPage() {
if (loading) { if (loading) {
return ( return (
<div className="flex justify-center items-center h-32"> <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> </div>
); );
} }
@@ -59,31 +60,30 @@ export default function ProfileSetupPage() {
if (error || !event) { if (error || !event) {
return ( return (
<div className="text-center p-4"> <div className="text-center p-4">
<p className="text-red-600 mb-4">{error || 'Event nicht gefunden.'}</p> <p className="text-red-600 mb-4">{error || t('profileSetup.error.default')}</p>
<Button onClick={() => nav('/')}>Zurück zur Startseite</Button> <Button onClick={() => nav('/')}>{t('profileSetup.error.backToStart')}</Button>
</div> </div>
); );
} }
return ( return (
<div className="min-h-screen bg-gradient-to-b from-pink-50 to-purple-50 flex flex-col"> <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"> <div className="flex-1 flex flex-col justify-center items-center px-4 py-8">
<Card className="w-full max-w-md"> <Card className="w-full max-w-md">
<CardHeader className="text-center space-y-2"> <CardHeader className="text-center space-y-2">
<CardTitle className="text-2xl font-bold text-gray-900">{event.name}</CardTitle> <CardTitle className="text-2xl font-bold text-gray-900">{event.name}</CardTitle>
<CardDescription className="text-lg text-gray-600"> <CardDescription className="text-lg text-gray-600">
Fange den schoensten Moment ein! {t('profileSetup.card.description')}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4 p-6"> <CardContent className="space-y-4 p-6">
<div className="space-y-2"> <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 <Input
id="name" id="name"
value={name} value={name}
onChange={(e) => handleChange(e.target.value)} onChange={(e) => handleChange(e.target.value)}
placeholder="Dein Name" placeholder={t('profileSetup.form.placeholder')}
className="text-lg" className="text-lg"
disabled={submitting || !hydrated} disabled={submitting || !hydrated}
autoComplete="name" autoComplete="name"
@@ -94,7 +94,7 @@ export default function ProfileSetupPage() {
onClick={submitName} onClick={submitName}
disabled={submitting || !name.trim() || !hydrated} disabled={submitting || !name.trim() || !hydrated}
> >
{submitting ? 'Speichere...' : "Let's go!"} {submitting ? t('profileSetup.form.submitting') : t('profileSetup.form.submit')}
</Button> </Button>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -1,16 +1,12 @@
import React from 'react'; import React from 'react';
import { Page } from './_util'; import { Page } from './_util';
import { useTranslation } from '../i18n/useTranslation';
export default function SettingsPage() { export default function SettingsPage() {
const { t } = useTranslation();
return ( return (
<Page title="Einstellungen"> <Page title={t('settings.title')}>
<ul> <p style={{ fontSize: 14 }}>{t('settings.subtitle')}</p>
<li>Sprache</li>
<li>Theme</li>
<li>Cache leeren</li>
<li>Rechtliches</li>
</ul>
</Page> </Page>
); );
} }

View File

@@ -22,6 +22,7 @@ import {
ZapOff, ZapOff,
} from 'lucide-react'; } from 'lucide-react';
import { getEventPackage, type EventPackage } from '../services/eventApi'; import { getEventPackage, type EventPackage } from '../services/eventApi';
import { useTranslation } from '../i18n/useTranslation';
interface Task { interface Task {
id: number; id: number;
@@ -62,6 +63,7 @@ export default function UploadPage() {
const { appearance } = useAppearance(); const { appearance } = useAppearance();
const isDarkMode = appearance === 'dark'; const isDarkMode = appearance === 'dark';
const { markCompleted } = useGuestTaskProgress(slug); const { markCompleted } = useGuestTaskProgress(slug);
const { t } = useTranslation();
const taskIdParam = searchParams.get('task'); const taskIdParam = searchParams.get('task');
const emotionSlug = searchParams.get('emotion') || ''; const emotionSlug = searchParams.get('emotion') || '';
@@ -137,7 +139,7 @@ export default function UploadPage() {
// Load task metadata // Load task metadata
useEffect(() => { useEffect(() => {
if (!slug || !taskId) { if (!slug || !taskId) {
setTaskError('Keine Aufgabeninformationen gefunden.'); setTaskError(t('upload.loadError.title'));
setLoadingTask(false); setLoadingTask(false);
return; return;
} }
@@ -145,6 +147,10 @@ export default function UploadPage() {
let active = true; let active = true;
async function loadTask() { 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 { try {
setLoadingTask(true); setLoadingTask(true);
setTaskError(null); setTaskError(null);
@@ -159,9 +165,9 @@ export default function UploadPage() {
if (found) { if (found) {
setTask({ setTask({
id: found.id, id: found.id,
title: found.title || `Aufgabe ${taskId!}`, title: found.title || fallbackTitle,
description: found.description || 'Halte den Moment fest und teile ihn mit allen Gästen.', description: found.description || fallbackDescription,
instructions: found.instructions, instructions: found.instructions ?? fallbackInstructions,
duration: found.duration || 2, duration: found.duration || 2,
emotion: found.emotion, emotion: found.emotion,
difficulty: found.difficulty ?? 'medium', difficulty: found.difficulty ?? 'medium',
@@ -169,9 +175,9 @@ export default function UploadPage() {
} else { } else {
setTask({ setTask({
id: taskId!, id: taskId!,
title: `Aufgabe ${taskId!}`, title: fallbackTitle,
description: 'Halte den Moment fest und teile ihn mit allen Gästen.', description: fallbackDescription,
instructions: 'Positioniere alle im Bild, starte den Countdown und lass die Emotion wirken.', instructions: fallbackInstructions,
duration: 2, duration: 2,
emotion: emotionSlug emotion: emotionSlug
? { slug: emotionSlug, name: emotionSlug.replace('-', ' ').replace(/\b\w/g, (l) => l.toUpperCase()) } ? { slug: emotionSlug, name: emotionSlug.replace('-', ' ').replace(/\b\w/g, (l) => l.toUpperCase()) }
@@ -182,12 +188,12 @@ export default function UploadPage() {
} catch (error) { } catch (error) {
console.error('Failed to fetch task', error); console.error('Failed to fetch task', error);
if (active) { if (active) {
setTaskError('Aufgabe konnte nicht geladen werden. Du kannst trotzdem ein Foto machen.'); setTaskError(t('upload.loadError.title'));
setTask({ setTask({
id: taskId!, id: taskId!,
title: `Aufgabe ${taskId!}`, title: fallbackTitle,
description: 'Halte den Moment fest und teile ihn mit allen Gästen.', description: fallbackDescription,
instructions: 'Positioniere alle im Bild, starte den Countdown und lass die Emotion wirken.', instructions: fallbackInstructions,
duration: 2, duration: 2,
emotion: emotionSlug emotion: emotionSlug
? { slug: emotionSlug, name: emotionSlug.replace('-', ' ').replace(/\b\w/g, (l) => l.toUpperCase()) } ? { slug: emotionSlug, name: emotionSlug.replace('-', ' ').replace(/\b\w/g, (l) => l.toUpperCase()) }
@@ -204,7 +210,7 @@ export default function UploadPage() {
return () => { return () => {
active = false; active = false;
}; };
}, [eventKey, taskId, emotionSlug]); }, [eventKey, taskId, emotionSlug, t]);
// Check upload limits // Check upload limits
useEffect(() => { useEffect(() => {
@@ -216,19 +222,27 @@ export default function UploadPage() {
setEventPackage(pkg); setEventPackage(pkg);
if (pkg && pkg.used_photos >= pkg.package.max_photos) { if (pkg && pkg.used_photos >= pkg.package.max_photos) {
setCanUpload(false); 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 { } else {
setCanUpload(true); setCanUpload(true);
setUploadError(null);
} }
} catch (err) { } catch (err) {
console.error('Failed to check package limits', err); console.error('Failed to check package limits', err);
setCanUpload(false); setCanUpload(false);
setUploadError('Fehler beim Prüfen des Limits. Upload deaktiviert.'); setUploadError(t('upload.limitCheckError'));
} }
}; };
checkLimits(); checkLimits();
}, [eventKey, task]); }, [eventKey, task, t]);
const stopStream = useCallback(() => { const stopStream = useCallback(() => {
if (streamRef.current) { if (streamRef.current) {
@@ -265,7 +279,7 @@ export default function UploadPage() {
const startCamera = useCallback(async () => { const startCamera = useCallback(async () => {
if (!supportsCamera) { if (!supportsCamera) {
setPermissionState('unsupported'); setPermissionState('unsupported');
setPermissionMessage('Dieses Gerät oder der Browser unterstützt keine Kamera-Zugriffe.'); setPermissionMessage(t('upload.cameraUnsupported.message'));
return; return;
} }
@@ -286,18 +300,16 @@ export default function UploadPage() {
if (error?.name === 'NotAllowedError') { if (error?.name === 'NotAllowedError') {
setPermissionState('denied'); setPermissionState('denied');
setPermissionMessage( setPermissionMessage(t('upload.cameraDenied.explanation'));
'Kamera-Zugriff wurde blockiert. Prüfe die Berechtigungen deines Browsers und versuche es erneut.'
);
} else if (error?.name === 'NotFoundError') { } else if (error?.name === 'NotFoundError') {
setPermissionState('error'); setPermissionState('error');
setPermissionMessage('Keine Kamera gefunden. Du kannst stattdessen ein Foto aus deiner Galerie wählen.'); setPermissionMessage(t('upload.cameraUnsupported.message'));
} else { } else {
setPermissionState('error'); 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(() => { useEffect(() => {
if (!task || loadingTask) return; if (!task || loadingTask) return;
@@ -311,15 +323,15 @@ export default function UploadPage() {
useEffect(() => { useEffect(() => {
if (!liveRegionRef.current) return; if (!liveRegionRef.current) return;
if (mode === 'countdown') { 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') { } 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') { } else if (mode === 'uploading') {
liveRegionRef.current.textContent = 'Foto wird hochgeladen.'; liveRegionRef.current.textContent = t('upload.status.uploading');
} else { } else {
liveRegionRef.current.textContent = ''; liveRegionRef.current.textContent = '';
} }
}, [mode, countdownValue]); }, [mode, countdownValue, t]);
const dismissPrimer = useCallback(() => { const dismissPrimer = useCallback(() => {
setShowPrimer(false); setShowPrimer(false);
@@ -360,7 +372,7 @@ export default function UploadPage() {
const performCapture = useCallback(() => { const performCapture = useCallback(() => {
if (!videoRef.current || !canvasRef.current) { if (!videoRef.current || !canvasRef.current) {
setUploadError('Kamera nicht bereit. Bitte versuche es erneut.'); setUploadError(t('upload.captureError'));
setMode('preview'); setMode('preview');
return; return;
} }
@@ -371,7 +383,7 @@ export default function UploadPage() {
const height = video.videoHeight; const height = video.videoHeight;
if (!width || !height) { if (!width || !height) {
setUploadError('Kamera liefert kein Bild. Bitte starte die Kamera neu.'); setUploadError(t('upload.feedError'));
setMode('preview'); setMode('preview');
startCamera(); startCamera();
return; return;
@@ -381,7 +393,7 @@ export default function UploadPage() {
canvas.height = height; canvas.height = height;
const context = canvas.getContext('2d'); const context = canvas.getContext('2d');
if (!context) { if (!context) {
setUploadError('Canvas konnte nicht initialisiert werden.'); setUploadError(t('upload.canvasError'));
setMode('preview'); setMode('preview');
return; return;
} }
@@ -399,7 +411,7 @@ export default function UploadPage() {
canvas.toBlob( canvas.toBlob(
(blob) => { (blob) => {
if (!blob) { if (!blob) {
setUploadError('Foto konnte nicht erstellt werden.'); setUploadError(t('upload.captureError'));
setMode('preview'); setMode('preview');
return; return;
} }
@@ -413,7 +425,7 @@ export default function UploadPage() {
'image/jpeg', 'image/jpeg',
0.92 0.92
); );
}, [preferences.facingMode, preferences.mirrorFrontPreview, startCamera]); }, [preferences.facingMode, preferences.mirrorFrontPreview, startCamera, t]);
const beginCapture = useCallback(() => { const beginCapture = useCallback(() => {
setUploadError(null); setUploadError(null);
@@ -461,7 +473,7 @@ export default function UploadPage() {
setMode('uploading'); setMode('uploading');
setUploadProgress(5); setUploadProgress(5);
setUploadError(null); setUploadError(null);
setStatusMessage('Foto wird vorbereitet...'); setStatusMessage(t('upload.status.preparing'));
if (uploadProgressTimerRef.current) { if (uploadProgressTimerRef.current) {
window.clearInterval(uploadProgressTimerRef.current); window.clearInterval(uploadProgressTimerRef.current);
@@ -473,13 +485,13 @@ export default function UploadPage() {
try { try {
const photoId = await uploadPhoto(eventKey, reviewPhoto.file, task.id, emotionSlug || undefined); const photoId = await uploadPhoto(eventKey, reviewPhoto.file, task.id, emotionSlug || undefined);
setUploadProgress(100); setUploadProgress(100);
setStatusMessage('Upload abgeschlossen.'); setStatusMessage(t('upload.status.completed'));
markCompleted(task.id); markCompleted(task.id);
stopStream(); stopStream();
navigateAfterUpload(photoId); navigateAfterUpload(photoId);
} catch (error: any) { } catch (error: any) {
console.error('Upload failed', error); console.error('Upload failed', error);
setUploadError(error?.message || 'Upload fehlgeschlagen. Bitte versuche es erneut.'); setUploadError(error?.message || t('upload.status.failed'));
setMode('review'); setMode('review');
} finally { } finally {
if (uploadProgressTimerRef.current) { if (uploadProgressTimerRef.current) {
@@ -488,7 +500,7 @@ export default function UploadPage() {
} }
setStatusMessage(''); 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>) => { const handleGalleryPick = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
if (!canUpload) return; if (!canUpload) return;
@@ -501,10 +513,10 @@ export default function UploadPage() {
setMode('review'); setMode('review');
}; };
reader.onerror = () => { reader.onerror = () => {
setUploadError('Auswahl fehlgeschlagen. Bitte versuche es erneut.'); setUploadError(t('upload.galleryPickError'));
}; };
reader.readAsDataURL(file); reader.readAsDataURL(file);
}, [canUpload]); }, [canUpload, t]);
const difficultyBadgeClass = useMemo(() => { const difficultyBadgeClass = useMemo(() => {
if (!task) return 'text-white'; if (!task) return 'text-white';
@@ -533,12 +545,10 @@ export default function UploadPage() {
if (!supportsCamera && !task) { if (!supportsCamera && !task) {
return ( return (
<div className="pb-16"> <div className="pb-16">
<Header slug={eventKey} title="Kamera" /> <Header slug={eventKey} title={t('upload.cameraTitle')} />
<main className="px-4 py-6"> <main className="px-4 py-6">
<Alert> <Alert>
<AlertDescription> <AlertDescription>{t('upload.cameraUnsupported.message')}</AlertDescription>
Dieses Gerät unterstützt keine Kamera-Zugriffe. Du kannst stattdessen Fotos aus deiner Galerie hochladen.
</AlertDescription>
</Alert> </Alert>
</main> </main>
<BottomNav /> <BottomNav />
@@ -549,10 +559,10 @@ export default function UploadPage() {
if (loadingTask) { if (loadingTask) {
return ( return (
<div className="pb-16"> <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"> <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" /> <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> </main>
<BottomNav /> <BottomNav />
</div> </div>
@@ -562,13 +572,14 @@ export default function UploadPage() {
if (!canUpload) { if (!canUpload) {
return ( return (
<div className="pb-16"> <div className="pb-16">
<Header slug={eventKey} title="Kamera" /> <Header slug={eventKey} title={t('upload.cameraTitle')} />
<main className="px-4 py-6"> <main className="px-4 py-6">
<Alert variant="destructive"> <Alert variant="destructive">
<AlertTriangle className="h-4 w-4" /> <AlertTriangle className="h-4 w-4" />
<AlertDescription> <AlertDescription>
Upload-Limit erreicht ({eventPackage?.used_photos || 0} / {eventPackage?.package.max_photos || 0} Fotos). {t('upload.limitReached')
Kontaktieren Sie den Organisator für ein Package-Upgrade. .replace('{used}', `${eventPackage?.used_photos || 0}`)
.replace('{max}', `${eventPackage?.package.max_photos || 0}`)}
</AlertDescription> </AlertDescription>
</Alert> </Alert>
</main> </main>
@@ -583,13 +594,14 @@ export default function UploadPage() {
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<Info className="mt-0.5 h-5 w-5 flex-shrink-0" /> <Info className="mt-0.5 h-5 w-5 flex-shrink-0" />
<div className="text-left"> <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"> <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> </p>
</div> </div>
<Button variant="ghost" size="sm" onClick={dismissPrimer}> <Button variant="ghost" size="sm" onClick={dismissPrimer}>
Alles klar {t('upload.primer.dismiss')}
</Button> </Button>
</div> </div>
</div> </div>
@@ -601,9 +613,7 @@ export default function UploadPage() {
if (permissionState === 'unsupported') { if (permissionState === 'unsupported') {
return ( return (
<Alert className="mx-4"> <Alert className="mx-4">
<AlertDescription> <AlertDescription>{t('upload.cameraUnsupported.message')}</AlertDescription>
Dieses Gerät unterstützt keine Kamera. Nutze den Button `Foto aus Galerie wählen`, um dennoch teilzunehmen.
</AlertDescription>
</Alert> </Alert>
); );
} }
@@ -613,24 +623,22 @@ export default function UploadPage() {
<AlertDescription className="space-y-3"> <AlertDescription className="space-y-3">
<div>{permissionMessage}</div> <div>{permissionMessage}</div>
<Button size="sm" variant="outline" onClick={startCamera}> <Button size="sm" variant="outline" onClick={startCamera}>
Erneut versuchen {t('upload.buttons.tryAgain')}
</Button> </Button>
</AlertDescription> </AlertDescription>
</Alert> </Alert>
); );
} }
return ( return (
<Alert className="mx-4"> <Alert className="mx-4">
<AlertDescription> <AlertDescription>{t('upload.cameraDenied.prompt')}</AlertDescription>
Wir benötigen Zugriff auf deine Kamera. Bestätige die Browser-Abfrage oder nutze alternativ ein Foto aus deiner Galerie. </Alert>
</AlertDescription>
</Alert>
); );
}; };
return ( return (
<div className="pb-16"> <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"> <main className="relative flex flex-col gap-4 pb-4">
<div className="absolute left-0 right-0 top-0" aria-hidden="true"> <div className="absolute left-0 right-0 top-0" aria-hidden="true">
{renderPrimer()} {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"> <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" /> <Camera className="mb-3 h-8 w-8 text-pink-400" />
<p className="max-w-xs text-white/90"> <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> </p>
<div className="mt-4 flex flex-wrap gap-2"> <div className="mt-4 flex flex-wrap gap-2">
<Button size="sm" onClick={startCamera}> <Button size="sm" onClick={startCamera}>
Kamera starten {t('upload.buttons.startCamera')}
</Button> </Button>
<Button size="sm" variant="secondary" onClick={() => fileInputRef.current?.click()}> <Button size="sm" variant="secondary" onClick={() => fileInputRef.current?.click()}>
Foto aus Galerie wählen {t('upload.galleryButton')}
</Button> </Button>
</div> </div>
</div> </div>
@@ -684,14 +695,10 @@ export default function UploadPage() {
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<Badge variant="secondary" className="flex items-center gap-2 text-xs"> <Badge variant="secondary" className="flex items-center gap-2 text-xs">
<Sparkles className="h-3.5 w-3.5" /> <Sparkles className="h-3.5 w-3.5" />
Aufgabe #{task.id} {t('upload.taskInfo.badge').replace('{id}', `${task.id}`)}
</Badge> </Badge>
<span className={cn('text-xs font-medium uppercase tracking-wide', difficultyBadgeClass)}> <span className={cn('text-xs font-medium uppercase tracking-wide', difficultyBadgeClass)}>
{task.difficulty === 'easy' {t(`upload.taskInfo.difficulty.${task.difficulty ?? 'medium'}`)}
? 'Leicht'
: task.difficulty === 'hard'
? 'Herausfordernd'
: 'Medium'}
</span> </span>
</div> </div>
<div> <div>
@@ -699,13 +706,19 @@ export default function UploadPage() {
<p className="mt-1 text-xs leading-relaxed text-white/80">{task.description}</p> <p className="mt-1 text-xs leading-relaxed text-white/80">{task.description}</p>
</div> </div>
<div className="flex flex-wrap items-center gap-2 text-[11px] text-white/70"> <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 && ( {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 && ( {preferences.countdownEnabled && (
<span className="rounded-full border border-white/20 px-2 py-0.5"> <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> </span>
)} )}
</div> </div>
@@ -715,7 +728,7 @@ export default function UploadPage() {
{mode === 'countdown' && ( {mode === 'countdown' && (
<div className="absolute inset-0 z-40 flex flex-col items-center justify-center bg-black/60 text-white"> <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> <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> </div>
)} )}
@@ -760,7 +773,7 @@ export default function UploadPage() {
onClick={handleToggleGrid} onClick={handleToggleGrid}
> >
<Grid3X3 className="h-5 w-5" /> <Grid3X3 className="h-5 w-5" />
<span className="sr-only">Raster umschalten</span> <span className="sr-only">{t('upload.controls.toggleGrid')}</span>
</Button> </Button>
<Button <Button
size="icon" size="icon"
@@ -769,7 +782,7 @@ export default function UploadPage() {
onClick={handleToggleCountdown} onClick={handleToggleCountdown}
> >
<span className="text-sm font-semibold">{preferences.countdownSeconds}s</span> <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> </Button>
{preferences.facingMode === 'user' && ( {preferences.facingMode === 'user' && (
<Button <Button
@@ -779,7 +792,7 @@ export default function UploadPage() {
onClick={handleToggleMirror} onClick={handleToggleMirror}
> >
<span className="text-sm font-semibold">?</span> <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>
)} )}
<Button <Button
@@ -790,7 +803,7 @@ export default function UploadPage() {
disabled={preferences.facingMode !== 'environment'} disabled={preferences.facingMode !== 'environment'}
> >
{preferences.flashPreferred ? <Zap className="h-5 w-5 text-yellow-300" /> : <ZapOff className="h-5 w-5" />} {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> </Button>
</div> </div>
@@ -802,7 +815,7 @@ export default function UploadPage() {
onClick={handleSwitchCamera} onClick={handleSwitchCamera}
> >
<RotateCcw className="mr-1 h-4 w-4" /> <RotateCcw className="mr-1 h-4 w-4" />
Kamera wechseln {t('upload.switchCamera')}
</Button> </Button>
<Button <Button
variant="secondary" variant="secondary"
@@ -811,7 +824,7 @@ export default function UploadPage() {
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
> >
<ImagePlus className="mr-1 h-4 w-4" /> <ImagePlus className="mr-1 h-4 w-4" />
Foto aus Galerie {t('upload.galleryButton')}
</Button> </Button>
</div> </div>
</div> </div>
@@ -820,10 +833,10 @@ export default function UploadPage() {
{mode === 'review' && reviewPhoto ? ( {mode === 'review' && reviewPhoto ? (
<div className="flex w-full max-w-md flex-col gap-3 sm:flex-row"> <div className="flex w-full max-w-md flex-col gap-3 sm:flex-row">
<Button variant="secondary" className="flex-1" onClick={handleRetake}> <Button variant="secondary" className="flex-1" onClick={handleRetake}>
Noch einmal {t('upload.review.retake')}
</Button> </Button>
<Button className="flex-1" onClick={handleUsePhoto}> <Button className="flex-1" onClick={handleUsePhoto}>
Foto verwenden {t('upload.review.keep')}
</Button> </Button>
</div> </div>
) : ( ) : (
@@ -834,7 +847,7 @@ export default function UploadPage() {
disabled={!isCameraActive || mode === 'countdown'} disabled={!isCameraActive || mode === 'countdown'}
> >
<Camera className="h-7 w-7" /> <Camera className="h-7 w-7" />
<span className="sr-only">Foto aufnehmen</span> <span className="sr-only">{t('upload.captureButton')}</span>
</Button> </Button>
)} )}
</div> </div>

View File

@@ -1,11 +1,12 @@
import React from 'react'; import React from 'react';
import { Page } from './_util'; import { Page } from './_util';
import { useTranslation } from '../i18n/useTranslation';
export default function UploadQueuePage() { export default function UploadQueuePage() {
const { t } = useTranslation();
return ( return (
<Page title="Uploads"> <Page title={t('uploadQueue.title')}>
<p>Queue with progress/retry; background sync toggle.</p> <p>{t('uploadQueue.description')}</p>
</Page> </Page>
); );
} }

View File

@@ -22,6 +22,9 @@ import SlideshowPage from './pages/SlideshowPage';
import SettingsPage from './pages/SettingsPage'; import SettingsPage from './pages/SettingsPage';
import LegalPage from './pages/LegalPage'; import LegalPage from './pages/LegalPage';
import NotFoundPage from './pages/NotFoundPage'; 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() { function HomeLayout() {
const { token } = useParams(); const { token } = useParams();
@@ -85,41 +88,52 @@ function EventBoundary({ token }: { token: string }) {
return <EventErrorView code={errorCode} message={error} />; 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 ( return (
<EventStatsProvider eventKey={token}> <LocaleProvider defaultLocale={eventLocale} storageKey={localeStorageKey}>
<div className="pb-16"> <EventStatsProvider eventKey={token}>
<Header slug={token} /> <div className="pb-16">
<div className="px-4 py-3"> <Header slug={token} />
<Outlet /> <div className="px-4 py-3">
<Outlet />
</div>
<BottomNav />
</div> </div>
<BottomNav /> </EventStatsProvider>
</div> </LocaleProvider>
</EventStatsProvider>
); );
} }
function SetupLayout() { function SetupLayout() {
const { token } = useParams<{ token: string }>(); const { token } = useParams<{ token: string }>();
if (!token) return null; 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 ( return (
<GuestIdentityProvider eventKey={token}> <GuestIdentityProvider eventKey={token}>
<EventStatsProvider eventKey={token}> <LocaleProvider defaultLocale={eventLocale} storageKey={localeStorageKey}>
<div className="pb-0"> <EventStatsProvider eventKey={token}>
<Header slug={token} /> <div className="pb-0">
<Outlet /> <Header slug={token} />
</div> <Outlet />
</EventStatsProvider> </div>
</EventStatsProvider>
</LocaleProvider>
</GuestIdentityProvider> </GuestIdentityProvider>
); );
} }
function EventLoadingView() { function EventLoadingView() {
const { t } = useTranslation();
return ( return (
<div className="flex min-h-screen flex-col items-center justify-center gap-4 px-6 text-center"> <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 /> <Loader2 className="h-10 w-10 animate-spin text-muted-foreground" aria-hidden />
<div className="space-y-1"> <div className="space-y-1">
<p className="text-lg font-semibold text-foreground">Wir prüfen deinen Zugang...</p> <p className="text-lg font-semibold text-foreground">{t('eventAccess.loading.title')}</p>
<p className="text-sm text-muted-foreground">Einen Moment bitte.</p> <p className="text-sm text-muted-foreground">{t('eventAccess.loading.subtitle')}</p>
</div> </div>
</div> </div>
); );
@@ -131,7 +145,8 @@ interface EventErrorViewProps {
} }
function EventErrorView({ code, message }: EventErrorViewProps) { function EventErrorView({ code, message }: EventErrorViewProps) {
const content = getErrorContent(code, message); const { t } = useTranslation();
const content = getErrorContent(t, code, message);
return ( return (
<div className="flex min-h-screen flex-col items-center justify-center gap-6 px-6 text-center"> <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( function getErrorContent(
t: TranslateFn,
code: FetchEventErrorCode | null, code: FetchEventErrorCode | null,
message: string | null, message: string | null,
) { ) {
const base = (fallbackTitle: string, fallbackDescription: string, options?: { ctaLabel?: string; ctaHref?: string; hint?: string }) => ({ const build = (key: string, options?: { ctaHref?: string }) => {
title: fallbackTitle, const ctaLabel = t(`eventAccess.error.${key}.ctaLabel`, '');
description: message ?? fallbackDescription, const hint = t(`eventAccess.error.${key}.hint`, '');
ctaLabel: options?.ctaLabel, return {
ctaHref: options?.ctaHref, title: t(`eventAccess.error.${key}.title`),
hint: options?.hint ?? null, 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) { switch (code) {
case 'invalid_token': case 'invalid_token':
return base('Zugriffscode ungültig', 'Der eingegebene Code konnte nicht verifiziert werden.', { return build('invalid_token', { ctaHref: '/event' });
ctaLabel: 'Neuen Code anfordern',
ctaHref: '/event',
});
case 'token_revoked': case 'token_revoked':
return base('Zugriffscode deaktiviert', 'Dieser Code wurde zurückgezogen. Bitte fordere einen neuen Code an.', { return build('token_revoked', { ctaHref: '/event' });
ctaLabel: 'Neuen Code anfordern',
ctaHref: '/event',
});
case 'token_expired': case 'token_expired':
return base('Zugriffscode abgelaufen', 'Der Code ist nicht mehr gültig. Aktualisiere deinen Code, um fortzufahren.', { return build('token_expired', { ctaHref: '/event' });
ctaLabel: 'Code aktualisieren',
ctaHref: '/event',
});
case 'token_rate_limited': case 'token_rate_limited':
return base('Zu viele Versuche', 'Es gab zu viele Eingaben in kurzer Zeit. Warte kurz und versuche es erneut.', { return build('token_rate_limited');
hint: 'Tipp: Eine erneute Eingabe ist in wenigen Minuten möglich.',
});
case 'event_not_public': case 'event_not_public':
return base('Event nicht öffentlich', 'Dieses Event ist aktuell nicht öffentlich zugänglich.', { return build('event_not_public');
hint: 'Nimm Kontakt mit den Veranstalter:innen auf, um Zugang zu erhalten.',
});
case 'network_error': 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': case 'server_error':
return base('Server nicht erreichbar', 'Der Server reagiert derzeit nicht. Versuche es später erneut.'); return build('server_error');
default: default:
return base('Event nicht erreichbar', 'Wir konnten dein Event nicht laden. Bitte versuche es erneut.', { return build('default', { ctaHref: '/event' });
ctaLabel: 'Zur Code-Eingabe',
ctaHref: '/event',
});
} }
} }

View File

@@ -14,16 +14,21 @@ const Footer: React.FC = () => {
return ( return (
<footer className="bg-white border-t border-gray-200 mt-auto"> <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="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="grid grid-cols-1 md:grid-cols-3 gap-8">
<div className="col-span-1 md:col-span-2"> <div>
<Link href="/marketing" className="text-2xl font-bold font-display text-pink-500"> <div className="flex items-center gap-4">
FotoSpiel.App <img src="logo-transparent-md.png" alt="FotoSpiel.App Logo" className="h-12 w-auto" />
</Link> <div>
<p className="text-gray-600 font-sans-marketing mt-2"> <Link href="/" className="text-2xl font-bold font-display text-pink-500">
Deine Plattform für Event-Fotos. FotoSpiel.App
</p> </Link>
<p className="text-gray-600 font-sans-marketing mt-2">
Deine Plattform für Event-Fotos.
</p>
</div>
</div>
</div> </div>
<div> <div>
<h3 className="font-semibold font-display text-gray-900 mb-4">Rechtliches</h3> <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"> <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> <li><Link href="/kontakt" className="hover:text-pink-500">{t('nav.contact')}</Link></li>
</ul> </ul>
</div> </div>
<div> <div>
<h3 className="font-semibold font-display text-gray-900 mb-4">Social</h3> <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"> <ul className="space-y-2 text-sm text-gray-600 font-sans-marketing">

View File

@@ -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"> <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="container mx-auto px-4 py-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Link href={localizedPath('/')} className="text-2xl font-bold text-gray-800 dark:text-gray-200"> <Link href={localizedPath('/')} className="flex items-center gap-4">
Die Fotospiel.App <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> </Link>
<NavigationMenu className="hidden lg:flex flex-1 justify-center" viewport={false}> <NavigationMenu className="hidden lg:flex flex-1 justify-center" viewport={false}>
<NavigationMenuList className="gap-2"> <NavigationMenuList className="gap-2">
@@ -246,6 +249,18 @@ const Header: React.FC = () => {
<SheetHeader className="text-left"> <SheetHeader className="text-left">
<SheetTitle className="text-xl font-semibold">Menü</SheetTitle> <SheetTitle className="text-xl font-semibold">Menü</SheetTitle>
</SheetHeader> </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"> <nav className="flex flex-col gap-2">
{navItems.map((item) => ( {navItems.map((item) => (
item.children ? ( item.children ? (
@@ -303,18 +318,6 @@ const Header: React.FC = () => {
<span className="sr-only">Theme Toggle</span> <span className="sr-only">Theme Toggle</span>
</Button> </Button>
</div> </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"> <div className="flex flex-col gap-3">
{auth.user ? ( {auth.user ? (
<> <>

View File

@@ -34,6 +34,19 @@
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script> <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
</head> </head>
<body class="bg-gray-50 text-gray-900"> <body class="bg-gray-50 text-gray-900">
<noscript>
<div class="bg-yellow-100 text-yellow-900 text-sm md:text-base">
<div class="mx-auto max-w-5xl px-4 py-3">
@if ($currentLocale === 'en')
<strong>JavaScript disabled.</strong>
This site offers limited functionality without JavaScript. Please enable JavaScript for the full experience.
@else
<strong>JavaScript deaktiviert.</strong>
Diese Seite bietet nur eingeschränkte Funktionen ohne JavaScript. Bitte aktiviere JavaScript, um alle Inhalte zu nutzen.
@endif
</div>
</div>
</noscript>
<main> <main>
@yield('content') @yield('content')
@@ -43,4 +56,4 @@
@stack('scripts') @stack('scripts')
</body> </body>
</html> </html>