Guest PWA vollständig lokalisiert
98
AGENTS.md
@@ -1,6 +1,6 @@
|
||||
# AGENTS.md — Agent Guidance for Event Photo Platform
|
||||
|
||||
This repository hosts a multi-tenant event photo platform (Laravel 12, PHP 8.3, Filament 4, React/Vite PWA). This document defines how AI agents should operate in this repo: roles, permissions, safety rules, and standard workflows. It is the single source of truth for agent behavior. Per-agent details live in docs/agents/.
|
||||
This repository hosts a multi-tenant event photo platform (Laravel 12, PHP 8.3, Filament 4, React 19/Vite 7 PWA). This document defines how AI agents should operate in this repo: roles, permissions, safety rules, and standard workflows. It is the single source of truth for agent behavior. Per-agent details live in docs/agents/.
|
||||
|
||||
## Purpose & Scope
|
||||
- Provide clear guardrails and playbooks so agents can assist safely with code, docs, DevOps and project hygiene.
|
||||
@@ -24,90 +24,21 @@ This repository hosts a multi-tenant event photo platform (Laravel 12, PHP 8.3,
|
||||
- Keep this AGENTS.md authoritative. If per-agent docs diverge, update this file and link the rationale.
|
||||
|
||||
## Tools & Permissions
|
||||
- Languages/Frameworks: PHP 8.3 (Laravel 12), JS/TS (React/Vite/Tailwind), Filament 4.
|
||||
- Dev Commands: composer, npm, vite, artisan, PHPUnit, Pint/ESLint, Docker/Compose (for dev).
|
||||
- Git Hosting: Gogs at http://192.168.78.2:10880 (token found locally in gogs.ini, never printed or committed).
|
||||
- Issue API: Gogs REST /api/v1 for labels/issues/milestones (token auth).
|
||||
- Libraries: simplesoftwareio/simple-qrcode for server-side QR generation.
|
||||
|
||||
## Repo Structure (high-level)
|
||||
- docs/prp/ — split PRP (authoritative). Start at docs/prp/README.md.
|
||||
- docs/changes/ — session change logs.
|
||||
- resources/js/guest/ — Guest PWA source (standalone entry, SW at public/guest-sw.js).
|
||||
- resources/js/admin/ — Tenant Admin PWA source (standalone entry).
|
||||
- fotospiel_prp.md — legacy monolithic PRP (historical reference; do not edit).
|
||||
- TODO.md — prioritized backlog; mirrored into Issues by Ops Agent.
|
||||
|
||||
## Standard Workflows
|
||||
- Coding tasks (Codegen Agent):
|
||||
1) Understand scope; update or create a minimal plan.
|
||||
2) Edit code/docs via small, reviewable patches; keep changes focused.
|
||||
3) Add/update tests if behavior changes.
|
||||
4) Update docs when public surfaces change (PRP, docs/*).
|
||||
5) Propose follow-ups as Issues if out of scope.
|
||||
- Issue hygiene (Ops Agent):
|
||||
- Import TODO.md tasks as Issues with label TODO; group by Milestone (e.g., Now, Security & Compliance).
|
||||
- Avoid duplicates by checking existing titles.
|
||||
- Releases (Ops Agent):
|
||||
- Tag with semantic version; generate changelog from commits/PRs; ensure legal pages and migration notes are updated.
|
||||
|
||||
## Developer Utilities
|
||||
- Artisan commands:
|
||||
- media:backfill-thumbnails — generate thumbnails for existing photos.
|
||||
- tenant:add-dummy — create a demo tenant and admin user (see --help for options).
|
||||
- tenant:attach-demo-event — attach an existing demo event to a tenant.
|
||||
- Public APIs for Guest PWA: stats/photos endpoints with ETag; likes; uploads; see docs/prp/03-api.md.
|
||||
|
||||
## Constraints & Red-Lines
|
||||
- Do not introduce tracking beyond what is documented (anonymous session_id only for guest PWA).
|
||||
- Do not weaken auth, CSRF, CORS, or role checks.
|
||||
- Do not expand data retention without updating Privacy policy.
|
||||
|
||||
## Change Management
|
||||
- Propose updates to this file via PR. Include:
|
||||
- Motivation and scope, affected agents, roll-out plan.
|
||||
- Links to updated docs in docs/agents/.
|
||||
|
||||
## References
|
||||
# AGENTS.md — Agent Guidance for Event Photo Platform
|
||||
|
||||
This repository hosts a multi-tenant event photo platform (Laravel 12, PHP 8.3, Filament 4, React/Vite PWA). This document defines how AI agents should operate in this repo: roles, permissions, safety rules, and standard workflows. It is the single source of truth for agent behavior. Per-agent details live in docs/agents/.
|
||||
|
||||
## Purpose & Scope
|
||||
- Provide clear guardrails and playbooks so agents can assist safely with code, docs, DevOps and project hygiene.
|
||||
- Applies to the whole repo unless a component has an explicit per-agent policy in docs/agents/.
|
||||
|
||||
## Roles
|
||||
- Codegen Agent — implements and edits application code, tests and documentation within scoped tasks. See docs/agents/codegen.md.
|
||||
- Ops Agent — automates tasks around CI/CD, releases, issue hygiene, and repo maintenance. See docs/agents/ops.md.
|
||||
- (Optional) Docs Agent — maintains documentation quality; follow Codegen Agent rules with writing focus.
|
||||
|
||||
## Global Policies
|
||||
- Secrets & Credentials:
|
||||
- Never commit secrets. The local file gogs.ini (token=…) is ignored via .gitignore and must not be printed into logs.
|
||||
- ENV values in .env are sensitive; do not commit them or echo to build logs.
|
||||
- Data Protection:
|
||||
- Respect GDPR. Do not introduce PII logging. Legal content (Impressum, Privacy, AGB) is managed via Legal Pages resource.
|
||||
- Safety & Access:
|
||||
- Prefer least privilege. Do not alter production data or infrastructure from code without explicit human approval.
|
||||
- When uncertain about a destructive operation, open a PR or create an Issue with a proposal.
|
||||
- Source of Truth:
|
||||
- Keep this AGENTS.md authoritative. If per-agent docs diverge, update this file and link the rationale.
|
||||
|
||||
## Tools & Permissions
|
||||
- Languages/Frameworks: PHP 8.3 (Laravel 12), JS/TS (React/Vite/Tailwind), Filament 4.
|
||||
- Languages/Frameworks: PHP 8.3 (Laravel 12), TypeScript/JavaScript (React 19/Vite 7/Tailwind 4), Filament 4.
|
||||
- Dev Commands: composer, npm, vite, artisan, PHPUnit, Pint/ESLint, Docker/Compose (for dev).
|
||||
- Git Hosting: Gogs at http://nas:10880 (token found locally in gogs.ini, never printed or committed).
|
||||
- Issue API: Gogs REST /api/v1 for labels/issues/milestones (token auth).
|
||||
- Libraries: simplesoftwareio/simple-qrcode for server-side QR generation.
|
||||
- Libraries: simplesoftwareio/simple-qrcode for server-side QR generation; Stripe PHP SDK for payments; PayPal Server SDK for payments; dompdf for PDF generation; spatie/laravel-translatable for i18n.
|
||||
- Payment Systems: Stripe (subscriptions and one-time payments), PayPal (integrated payments), RevenueCat (mobile app subscriptions).
|
||||
- PWA Technologies: React 19, Vite 7, Capacitor (iOS), Trusted Web Activity (Android), Service Workers, Background Sync.
|
||||
|
||||
## Repo Structure (high-level)
|
||||
- docs/prp/ — split PRP (authoritative). Start at docs/prp/README.md.
|
||||
- docs/changes/ — session change logs.
|
||||
- resources/js/guest/ — Guest PWA source (standalone entry, SW at public/guest-sw.js).
|
||||
- resources/js/admin/ — Tenant Admin PWA source (standalone entry).
|
||||
- docs/todo/ — prioritized backlog items (replaces single TODO.md file).
|
||||
- resources/js/guest/ — Guest PWA source (React 19, offline-first, installable).
|
||||
- resources/js/admin/ — Tenant Admin PWA source (React 19, Capacitor/TWA ready).
|
||||
- fotospiel_prp.md — legacy monolithic PRP (historical reference; do not edit).
|
||||
- TODO.md — prioritized backlog; mirrored into Issues by Ops Agent.
|
||||
|
||||
## Standard Workflows
|
||||
- Coding tasks (Codegen Agent):
|
||||
@@ -117,7 +48,7 @@ This repository hosts a multi-tenant event photo platform (Laravel 12, PHP 8.3,
|
||||
4) Update docs when public surfaces change (PRP, docs/*).
|
||||
5) Propose follow-ups as Issues if out of scope.
|
||||
- Issue hygiene (Ops Agent):
|
||||
- Import TODO.md tasks as Issues with label TODO; group by Milestone (e.g., Now, Security & Compliance).
|
||||
- Import docs/todo/ tasks as Issues with label TODO; group by Milestone (e.g., Now, Security & Compliance).
|
||||
- Avoid duplicates by checking existing titles.
|
||||
- Releases (Ops Agent):
|
||||
- Tag with semantic version; generate changelog from commits/PRs; ensure legal pages and migration notes are updated.
|
||||
@@ -128,11 +59,18 @@ This repository hosts a multi-tenant event photo platform (Laravel 12, PHP 8.3,
|
||||
- tenant:add-dummy — create a demo tenant and admin user (see --help for options).
|
||||
- tenant:attach-demo-event — attach an existing demo event to a tenant.
|
||||
- Public APIs for Guest PWA: stats/photos endpoints with ETag; likes; uploads; see docs/prp/03-api.md.
|
||||
- Payment Integration: Stripe webhooks, PayPal API integration, RevenueCat mobile subscriptions.
|
||||
|
||||
## PWA Architecture
|
||||
- Guest PWA: Offline-first photo sharing app for event attendees (installable, background sync, no account required).
|
||||
- Tenant Admin PWA: Store-ready mobile app for event management (Android TWA, iOS Capacitor, OAuth2 + PKCE).
|
||||
- Core Features: Background upload, conflict resolution, push notifications, achievement system, emotion/task tagging.
|
||||
|
||||
## Constraints & Red-Lines
|
||||
- Do not introduce tracking beyond what is documented (anonymous session_id only for guest PWA).
|
||||
- Do not weaken auth, CSRF, CORS, or role checks.
|
||||
- Do not expand data retention without updating Privacy policy.
|
||||
- PWA decisions are locked: Photos only (no videos), no facial recognition, no public profiles.
|
||||
|
||||
## Change Management
|
||||
- Propose updates to this file via PR. Include:
|
||||
@@ -140,3 +78,7 @@ This repository hosts a multi-tenant event photo platform (Laravel 12, PHP 8.3,
|
||||
- Links to updated docs in docs/agents/.
|
||||
|
||||
## References
|
||||
- ADR-0006: Tenant Admin PWA architecture decision.
|
||||
- docs/prp/06-tenant-admin-pwa.md: Detailed PWA specifications.
|
||||
- docs/prp/07-guest-pwa.md: Guest PWA requirements and features.
|
||||
- docs/prp/08-billing.md: Payment system architecture.
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace Database\Seeders;
|
||||
use App\Models\LegalPage;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
class LegalPagesSeeder extends Seeder
|
||||
{
|
||||
@@ -12,157 +13,54 @@ class LegalPagesSeeder extends Seeder
|
||||
{
|
||||
$now = Carbon::now();
|
||||
|
||||
// Impressum (DE)
|
||||
$impressumDe = <<<MD
|
||||
# Impressum
|
||||
|
||||
Anbieter dieser Seiten:
|
||||
|
||||
Sören Eberhardt‑Biermann
|
||||
Schweriner Str. 15
|
||||
19306 Neustadt‑Glewe, Deutschland
|
||||
|
||||
Kontakt:
|
||||
|
||||
- Telefon mobil: 0173 / 9266802
|
||||
- Fax: 038757 / 54169
|
||||
- E‑Mail: soeren@sebfoto.de
|
||||
- Website: https://sebfoto.de
|
||||
|
||||
Umsatzsteuer‑Identifikationsnummer gemäß § 27a UStG: (falls vorhanden, bitte ergänzen)
|
||||
|
||||
Verantwortlich für den Inhalt nach § 55 Abs. 2 RStV:
|
||||
Sören Eberhardt‑Biermann, Anschrift wie oben
|
||||
|
||||
Haftung für Inhalte:
|
||||
|
||||
Als Diensteanbieter sind wir gemäß § 7 Abs.1 TMG für eigene Inhalte auf diesen Seiten nach den allgemeinen Gesetzen verantwortlich. Nach §§ 8 bis 10 TMG sind wir als Diensteanbieter jedoch nicht verpflichtet, übermittelte oder gespeicherte fremde Informationen zu überwachen oder nach Umständen zu forschen, die auf eine rechtswidrige Tätigkeit hinweisen. Verpflichtungen zur Entfernung oder Sperrung der Nutzung von Informationen nach den allgemeinen Gesetzen bleiben hiervon unberührt.
|
||||
|
||||
Haftung für Links:
|
||||
|
||||
Unser Angebot enthält ggf. Links zu externen Websites Dritter, auf deren Inhalte wir keinen Einfluss haben. Deshalb können wir für diese fremden Inhalte auch keine Gewähr übernehmen. Für die Inhalte der verlinkten Seiten ist stets der jeweilige Anbieter oder Betreiber verantwortlich.
|
||||
|
||||
Urheberrecht:
|
||||
|
||||
Die durch die Seitenbetreiber erstellten Inhalte und Werke auf diesen Seiten unterliegen dem deutschen Urheberrecht. Vervielfältigung, Bearbeitung, Verbreitung oder jede Art der Verwertung außerhalb der Grenzen des Urheberrechts bedürfen der schriftlichen Zustimmung des jeweiligen Autors bzw. Erstellers.
|
||||
MD;
|
||||
|
||||
$this->upsert('impressum', [
|
||||
// Define the legal pages and their corresponding file patterns
|
||||
$pages = [
|
||||
'impressum' => [
|
||||
'title' => [
|
||||
'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 Fotospiel‑Plattform (Gast‑PWA, Admin‑PWA, Super‑Admin Backend).
|
||||
|
||||
Verantwortlicher:
|
||||
|
||||
Sören Eberhardt‑Biermann, Schweriner Str. 15, 19306 Neustadt‑Glewe, Deutschland
|
||||
E‑Mail: 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:
|
||||
|
||||
- Foto‑Uploads (Bilddateien, optional Metadaten wie Emotion/Task), Gerätekennung (pseudonym) für Likes/Uploads
|
||||
- Ereignis‑/Nutzungsdaten (Zeitpunkt, Anzahl der Uploads/Likes), Protokolldaten (IP, HTTP‑Header, 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 14–30 Tagen gelöscht. Abweichungen werden tenant‑spezifisch 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 Gast‑PWA nutzt keine Tracking‑Cookies. Es werden nur technisch notwendige Local‑Storage/IndexedDB‑Einträge und ein Pseudonym‑Geräte‑Identifikator für Uploads/Likes verwendet. Optionaler Service‑Worker Cache dient der Offline‑Nutzung.
|
||||
|
||||
Deine Rechte:
|
||||
|
||||
Auskunft, Berichtigung, Löschung, Einschränkung, Datenübertragbarkeit, Widerspruch (Art. 15–21 DSGVO). Beschwerden an die zuständige Aufsichtsbehörde sind möglich.
|
||||
|
||||
Kontakt für Datenschutzanfragen:
|
||||
E‑Mail: soeren@sebfoto.de
|
||||
|
||||
Stand: {$now->format('Y-m-d')}
|
||||
MD;
|
||||
|
||||
$this->upsert('datenschutz', [
|
||||
'en' => 'Legal Notice',
|
||||
],
|
||||
'files' => [
|
||||
'de' => 'docs/legal/impressum-de.md',
|
||||
'en' => 'docs/legal/impressum-en.md',
|
||||
],
|
||||
],
|
||||
'datenschutz' => [
|
||||
'title' => [
|
||||
'de' => 'Datenschutzerklärung',
|
||||
], [
|
||||
'de' => $datenschutzDe,
|
||||
], $now);
|
||||
|
||||
// AGB (DE) — baseline Terms for Fotospiel
|
||||
$agbDe = <<<MD
|
||||
# Allgemeine Geschäftsbedingungen (AGB)
|
||||
|
||||
Diese AGB regeln die Nutzung der Fotospiel‑Plattform 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 (Tenant‑Admin). 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. Social‑Media/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', [
|
||||
'en' => 'Privacy Policy',
|
||||
],
|
||||
'files' => [
|
||||
'de' => 'docs/legal/datenschutz-de.md',
|
||||
'en' => 'docs/legal/datenschutz-en.md',
|
||||
],
|
||||
],
|
||||
'agb' => [
|
||||
'title' => [
|
||||
'de' => 'Allgemeine Geschäftsbedingungen',
|
||||
], [
|
||||
'de' => $agbDe,
|
||||
], $now);
|
||||
'en' => 'Terms and Conditions',
|
||||
],
|
||||
'files' => [
|
||||
'de' => 'docs/legal/agb-de.md',
|
||||
'en' => 'docs/legal/agb-en.md',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($pages as $slug => $config) {
|
||||
$bodyByLocale = [];
|
||||
|
||||
foreach ($config['files'] as $locale => $filePath) {
|
||||
if (File::exists(base_path($filePath))) {
|
||||
$bodyByLocale[$locale] = File::get(base_path($filePath));
|
||||
} else {
|
||||
// Fallback to empty string if file doesn't exist
|
||||
$bodyByLocale[$locale] = '';
|
||||
}
|
||||
}
|
||||
|
||||
$this->upsert($slug, $config['title'], $bodyByLocale, $now);
|
||||
}
|
||||
}
|
||||
|
||||
private function upsert(string $slug, array $titleByLocale, array $bodyByLocale, \DateTimeInterface $effectiveFrom): void
|
||||
|
||||
BIN
public/Fotospiel Logo Google-2.png
Normal file
|
After Width: | Height: | Size: 362 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.1 KiB |
BIN
public/logo-transparent-lg.png
Normal file
|
After Width: | Height: | Size: 526 KiB |
BIN
public/logo-transparent-md.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { NavLink, useParams, useLocation } from 'react-router-dom';
|
||||
import { CheckSquare, GalleryHorizontal, Home, Trophy } from 'lucide-react';
|
||||
import { useEventData } from '../hooks/useEventData';
|
||||
import { useTranslation } from '../i18n/useTranslation';
|
||||
|
||||
function TabLink({
|
||||
to,
|
||||
@@ -31,32 +32,21 @@ export default function BottomNav() {
|
||||
const { token } = useParams();
|
||||
const location = useLocation();
|
||||
const { event, status } = useEventData();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const isReady = status === 'ready' && !!event;
|
||||
|
||||
if (!token || !isReady) return null; // Only show bottom nav within event context
|
||||
const base = `/e/${encodeURIComponent(token)}`;
|
||||
const currentPath = location.pathname;
|
||||
const locale = event?.default_locale || 'de';
|
||||
|
||||
// Translations
|
||||
const translations = {
|
||||
de: {
|
||||
home: 'Start',
|
||||
tasks: 'Aufgaben',
|
||||
achievements: 'Erfolge',
|
||||
gallery: 'Galerie'
|
||||
},
|
||||
en: {
|
||||
home: 'Home',
|
||||
tasks: 'Tasks',
|
||||
achievements: 'Achievements',
|
||||
gallery: 'Gallery'
|
||||
}
|
||||
const labels = {
|
||||
home: t('navigation.home'),
|
||||
tasks: t('navigation.tasks'),
|
||||
achievements: t('navigation.achievements'),
|
||||
gallery: t('navigation.gallery'),
|
||||
};
|
||||
|
||||
const t = translations[locale as keyof typeof translations] || translations.de;
|
||||
|
||||
// Improved active state logic
|
||||
const isHomeActive = currentPath === base || currentPath === `/${token}`;
|
||||
const isTasksActive = currentPath.startsWith(`${base}/tasks`) || currentPath === `${base}/upload`;
|
||||
@@ -68,22 +58,22 @@ export default function BottomNav() {
|
||||
<div className="mx-auto flex max-w-sm items-center justify-around">
|
||||
<TabLink to={`${base}`} isActive={isHomeActive}>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<Home className="h-5 w-5" /> <span className="text-xs">{t.home}</span>
|
||||
<Home className="h-5 w-5" /> <span className="text-xs">{labels.home}</span>
|
||||
</div>
|
||||
</TabLink>
|
||||
<TabLink to={`${base}/tasks`} isActive={isTasksActive}>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<CheckSquare className="h-5 w-5" /> <span className="text-xs">{t.tasks}</span>
|
||||
<CheckSquare className="h-5 w-5" /> <span className="text-xs">{labels.tasks}</span>
|
||||
</div>
|
||||
</TabLink>
|
||||
<TabLink to={`${base}/achievements`} isActive={isAchievementsActive}>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<Trophy className="h-5 w-5" /> <span className="text-xs">{t.achievements}</span>
|
||||
<Trophy className="h-5 w-5" /> <span className="text-xs">{labels.achievements}</span>
|
||||
</div>
|
||||
</TabLink>
|
||||
<TabLink to={`${base}/gallery`} isActive={isGalleryActive}>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<GalleryHorizontal className="h-5 w-5" /> <span className="text-xs">{t.gallery}</span>
|
||||
<GalleryHorizontal className="h-5 w-5" /> <span className="text-xs">{labels.gallery}</span>
|
||||
</div>
|
||||
</TabLink>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,77 @@
|
||||
import React from 'react';
|
||||
import AppearanceToggleDropdown from '@/components/appearance-dropdown';
|
||||
import { User } from 'lucide-react';
|
||||
import { User, Heart, Users, PartyPopper, Camera } from 'lucide-react';
|
||||
import { useEventData } from '../hooks/useEventData';
|
||||
import { useOptionalEventStats } from '../context/EventStatsContext';
|
||||
import { useOptionalGuestIdentity } from '../context/GuestIdentityContext';
|
||||
import { SettingsSheet } from './settings-sheet';
|
||||
import { useTranslation } from '../i18n/useTranslation';
|
||||
|
||||
const EVENT_ICON_COMPONENTS: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
heart: Heart,
|
||||
guests: Users,
|
||||
party: PartyPopper,
|
||||
camera: Camera,
|
||||
};
|
||||
|
||||
function isLikelyEmoji(value: string): boolean {
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
const characters = Array.from(value.trim());
|
||||
if (characters.length === 0 || characters.length > 2) {
|
||||
return false;
|
||||
}
|
||||
return characters.some((char) => {
|
||||
const codePoint = char.codePointAt(0) ?? 0;
|
||||
return codePoint > 0x2600;
|
||||
});
|
||||
}
|
||||
|
||||
function getInitials(name: string): string {
|
||||
const words = name.split(' ').filter(Boolean);
|
||||
if (words.length >= 2) {
|
||||
return `${words[0][0]}${words[1][0]}`.toUpperCase();
|
||||
}
|
||||
return name.substring(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
function renderEventAvatar(name: string, icon: unknown) {
|
||||
if (typeof icon === 'string') {
|
||||
const trimmed = icon.trim();
|
||||
if (trimmed) {
|
||||
const normalized = trimmed.toLowerCase();
|
||||
const IconComponent = EVENT_ICON_COMPONENTS[normalized];
|
||||
if (IconComponent) {
|
||||
return (
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-pink-100 text-pink-600">
|
||||
<IconComponent className="h-5 w-5" aria-hidden />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLikelyEmoji(trimmed)) {
|
||||
return (
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-pink-100 text-pink-600 text-xl">
|
||||
<span aria-hidden>{trimmed}</span>
|
||||
<span className="sr-only">{name}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-pink-100 text-pink-600 font-semibold text-sm">
|
||||
{getInitials(name)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Header({ slug, title = '' }: { slug?: string; title?: string }) {
|
||||
const statsContext = useOptionalEventStats();
|
||||
const identity = useOptionalGuestIdentity();
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!slug) {
|
||||
const guestName = identity?.name && identity?.hydrated ? identity.name : null;
|
||||
@@ -17,7 +80,9 @@ export default function Header({ slug, title = '' }: { slug?: string; title?: st
|
||||
<div className="flex flex-col">
|
||||
<div className="font-semibold">{title}</div>
|
||||
{guestName && (
|
||||
<span className="text-xs text-muted-foreground">Hi {guestName}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{`${t('common.hi')} ${guestName}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -35,7 +100,7 @@ export default function Header({ slug, title = '' }: { slug?: string; title?: st
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<div className="sticky top-0 z-20 flex items-center justify-between border-b bg-white/70 px-4 py-2 backdrop-blur dark:bg-black/40">
|
||||
<div className="font-semibold">Lade Event...</div>
|
||||
<div className="font-semibold">{t('header.loading')}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<AppearanceToggleDropdown />
|
||||
<SettingsSheet />
|
||||
@@ -51,49 +116,28 @@ export default function Header({ slug, title = '' }: { slug?: string; title?: st
|
||||
const stats =
|
||||
statsContext && statsContext.eventKey === slug ? statsContext : undefined;
|
||||
|
||||
const getEventAvatar = (event: any) => {
|
||||
if (event.type?.icon) {
|
||||
return (
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-pink-100 text-pink-600 text-xl">
|
||||
{event.type.icon}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getInitials = (name: string) => {
|
||||
const words = name.split(' ');
|
||||
if (words.length >= 2) {
|
||||
return `${words[0][0]}${words[1][0]}`.toUpperCase();
|
||||
}
|
||||
return name.substring(0, 2).toUpperCase();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-pink-100 text-pink-600 font-semibold text-sm">
|
||||
{getInitials(event.name)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="sticky top-0 z-20 flex items-center justify-between border-b bg-white/70 px-4 py-2 backdrop-blur dark:bg-black/40">
|
||||
<div className="flex items-center gap-3">
|
||||
{getEventAvatar(event)}
|
||||
{renderEventAvatar(event.name, event.type?.icon)}
|
||||
<div className="flex flex-col">
|
||||
<div className="font-semibold text-base">{event.name}</div>
|
||||
{guestName && (
|
||||
<span className="text-xs text-muted-foreground">Hi {guestName}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{`${t('common.hi')} ${guestName}`}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{stats && (
|
||||
<>
|
||||
<span className="flex items-center gap-1">
|
||||
<User className="h-3 w-3" />
|
||||
<span>{stats.onlineGuests} online</span>
|
||||
<span>{`${stats.onlineGuests} ${t('header.stats.online')}`}</span>
|
||||
</span>
|
||||
<span className="text-muted-foreground">|</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="font-medium">{stats.tasksSolved}</span> Aufgaben geloest
|
||||
<span className="font-medium">{stats.tasksSolved}</span>{' '}
|
||||
{t('header.stats.tasksSolved')}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from "react";
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
@@ -15,16 +16,23 @@ import { Label } from '@/components/ui/label';
|
||||
import { Settings, ArrowLeft, FileText, RefreshCcw, ChevronRight, UserCircle } from 'lucide-react';
|
||||
import { useOptionalGuestIdentity } from '../context/GuestIdentityContext';
|
||||
import { LegalMarkdown } from './legal-markdown';
|
||||
import { useLocale, type LocaleContextValue } from '../i18n/LocaleContext';
|
||||
import { useTranslation } from '../i18n/useTranslation';
|
||||
import type { LocaleCode } from '../i18n/messages';
|
||||
|
||||
const legalPages = [
|
||||
{ slug: 'impressum', label: 'Impressum' },
|
||||
{ slug: 'datenschutz', label: 'Datenschutz' },
|
||||
{ slug: 'agb', label: 'AGB' },
|
||||
{ slug: 'impressum', translationKey: 'settings.legal.section.impressum' },
|
||||
{ slug: 'datenschutz', translationKey: 'settings.legal.section.privacy' },
|
||||
{ slug: 'agb', translationKey: 'settings.legal.section.terms' },
|
||||
] as const;
|
||||
|
||||
type ViewState =
|
||||
| { mode: 'home' }
|
||||
| { mode: 'legal'; slug: (typeof legalPages)[number]['slug']; label: string };
|
||||
| {
|
||||
mode: 'legal';
|
||||
slug: (typeof legalPages)[number]['slug'];
|
||||
translationKey: (typeof legalPages)[number]['translationKey'];
|
||||
};
|
||||
|
||||
type LegalDocumentState =
|
||||
| { phase: 'idle'; title: string; body: string }
|
||||
@@ -38,11 +46,13 @@ export function SettingsSheet() {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [view, setView] = React.useState<ViewState>({ mode: 'home' });
|
||||
const identity = useOptionalGuestIdentity();
|
||||
const localeContext = useLocale();
|
||||
const { t } = useTranslation();
|
||||
const [nameDraft, setNameDraft] = React.useState(identity?.name ?? '');
|
||||
const [nameStatus, setNameStatus] = React.useState<NameStatus>('idle');
|
||||
const [savingName, setSavingName] = React.useState(false);
|
||||
const isLegal = view.mode === 'legal';
|
||||
const legalDocument = useLegalDocument(isLegal ? view.slug : null);
|
||||
const legalDocument = useLegalDocument(isLegal ? view.slug : null, localeContext.locale);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open && identity?.hydrated) {
|
||||
@@ -56,10 +66,13 @@ export function SettingsSheet() {
|
||||
}, []);
|
||||
|
||||
const handleOpenLegal = React.useCallback(
|
||||
(slug: (typeof legalPages)[number]['slug'], label: string) => {
|
||||
setView({ mode: 'legal', slug, label });
|
||||
(
|
||||
slug: (typeof legalPages)[number]['slug'],
|
||||
translationKey: (typeof legalPages)[number]['translationKey'],
|
||||
) => {
|
||||
setView({ mode: 'legal', slug, translationKey });
|
||||
},
|
||||
[]
|
||||
[],
|
||||
);
|
||||
|
||||
const handleOpenChange = React.useCallback((next: boolean) => {
|
||||
@@ -100,7 +113,7 @@ export function SettingsSheet() {
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-9 w-9 rounded-md">
|
||||
<Settings className="h-5 w-5" />
|
||||
<span className="sr-only">Einstellungen oeffnen</span>
|
||||
<span className="sr-only">{t('settings.sheet.openLabel')}</span>
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="right" className="sm:max-w-md">
|
||||
@@ -115,32 +128,36 @@ export function SettingsSheet() {
|
||||
onClick={handleBack}
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
<span className="sr-only">Zurück</span>
|
||||
<span className="sr-only">{t('settings.sheet.backLabel')}</span>
|
||||
</Button>
|
||||
<div className="min-w-0">
|
||||
<SheetTitle className="truncate">
|
||||
{legalDocument.phase === 'ready' && legalDocument.title
|
||||
? legalDocument.title
|
||||
: view.label}
|
||||
: t(view.translationKey)}
|
||||
</SheetTitle>
|
||||
<SheetDescription>
|
||||
{legalDocument.phase === 'loading' ? 'Laedt...' : 'Rechtlicher Hinweis'}
|
||||
{legalDocument.phase === 'loading'
|
||||
? t('common.actions.loading')
|
||||
: t('settings.sheet.legalDescription')}
|
||||
</SheetDescription>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<SheetTitle>Einstellungen</SheetTitle>
|
||||
<SheetDescription>
|
||||
Verwalte deinen Gastzugang, rechtliche Dokumente und lokale Daten.
|
||||
</SheetDescription>
|
||||
<SheetTitle>{t('settings.title')}</SheetTitle>
|
||||
<SheetDescription>{t('settings.subtitle')}</SheetDescription>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<main className="flex-1 overflow-y-auto px-6 py-4">
|
||||
{isLegal ? (
|
||||
<LegalView document={legalDocument} onClose={() => handleOpenChange(false)} />
|
||||
<LegalView
|
||||
document={legalDocument}
|
||||
onClose={() => handleOpenChange(false)}
|
||||
translationKey={view.mode === 'legal' ? view.translationKey : null}
|
||||
/>
|
||||
) : (
|
||||
<HomeView
|
||||
identity={identity}
|
||||
@@ -151,13 +168,14 @@ export function SettingsSheet() {
|
||||
canSaveName={canSaveName}
|
||||
savingName={savingName}
|
||||
nameStatus={nameStatus}
|
||||
localeContext={localeContext}
|
||||
onOpenLegal={handleOpenLegal}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<SheetFooter className="border-t bg-muted/40 px-6 py-3 text-xs text-muted-foreground">
|
||||
<div>Gastbereich - Daten werden lokal im Browser gespeichert.</div>
|
||||
<div>{t('settings.footer.notice')}</div>
|
||||
</SheetFooter>
|
||||
</div>
|
||||
</SheetContent>
|
||||
@@ -165,31 +183,41 @@ export function SettingsSheet() {
|
||||
);
|
||||
}
|
||||
|
||||
function LegalView({ document, onClose }: { document: LegalDocumentState; onClose: () => void }) {
|
||||
function LegalView({
|
||||
document,
|
||||
onClose,
|
||||
translationKey,
|
||||
}: {
|
||||
document: LegalDocumentState;
|
||||
onClose: () => void;
|
||||
translationKey: string | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (document.phase === 'error') {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
Das Dokument konnte nicht geladen werden. Bitte versuche es spaeter erneut.
|
||||
{t('settings.legal.error')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Schliessen
|
||||
{t('common.actions.close')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (document.phase === 'loading' || document.phase === 'idle') {
|
||||
return <div className="text-sm text-muted-foreground">Dokument wird geladen...</div>;
|
||||
return <div className="text-sm text-muted-foreground">{t('settings.legal.loading')}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{document.title || 'Rechtlicher Hinweis'}</CardTitle>
|
||||
<CardTitle>{document.title || t(translationKey ?? 'settings.legal.fallbackTitle')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="prose prose-sm max-w-none dark:prose-invert">
|
||||
<LegalMarkdown markdown={document.body} />
|
||||
@@ -208,7 +236,11 @@ interface HomeViewProps {
|
||||
canSaveName: boolean;
|
||||
savingName: boolean;
|
||||
nameStatus: NameStatus;
|
||||
onOpenLegal: (slug: (typeof legalPages)[number]['slug'], label: string) => void;
|
||||
localeContext: LocaleContextValue;
|
||||
onOpenLegal: (
|
||||
slug: (typeof legalPages)[number]['slug'],
|
||||
translationKey: (typeof legalPages)[number]['translationKey'],
|
||||
) => void;
|
||||
}
|
||||
|
||||
function HomeView({
|
||||
@@ -220,17 +252,65 @@ function HomeView({
|
||||
canSaveName,
|
||||
savingName,
|
||||
nameStatus,
|
||||
localeContext,
|
||||
onOpenLegal,
|
||||
}: HomeViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const legalLinks = React.useMemo(
|
||||
() =>
|
||||
legalPages.map((page) => ({
|
||||
slug: page.slug,
|
||||
translationKey: page.translationKey,
|
||||
label: t(page.translationKey),
|
||||
})),
|
||||
[t],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle>{t('settings.language.title')}</CardTitle>
|
||||
<CardDescription>{t('settings.language.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{localeContext.availableLocales.map((option) => {
|
||||
const isActive = localeContext.locale === option.code;
|
||||
return (
|
||||
<Button
|
||||
key={option.code}
|
||||
type="button"
|
||||
variant={isActive ? 'default' : 'outline'}
|
||||
className={`flex h-12 flex-col justify-center gap-1 rounded-lg border text-sm ${
|
||||
isActive ? 'bg-pink-500 text-white hover:bg-pink-600' : 'bg-background'
|
||||
}`}
|
||||
onClick={() => localeContext.setLocale(option.code)}
|
||||
aria-pressed={isActive}
|
||||
disabled={!localeContext.hydrated}
|
||||
>
|
||||
<span aria-hidden className="text-lg leading-none">{option.flag}</span>
|
||||
<span className="font-medium">{t(`settings.language.option.${option.code}`)}</span>
|
||||
{isActive && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="border border-white/40 bg-white/10 text-[10px] uppercase tracking-wide text-white"
|
||||
>
|
||||
{t('settings.language.activeBadge')}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{identity && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle>Dein Name</CardTitle>
|
||||
<CardDescription>
|
||||
Passe an, wie wir dich im Event begruessen. Der Name wird nur lokal gespeichert.
|
||||
</CardDescription>
|
||||
<CardTitle>{t('settings.name.title')}</CardTitle>
|
||||
<CardDescription>{t('settings.name.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -239,12 +319,12 @@ function HomeView({
|
||||
</div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<Label htmlFor="guest-name" className="text-sm font-medium">
|
||||
Anzeigename
|
||||
{t('settings.name.label')}
|
||||
</Label>
|
||||
<Input
|
||||
id="guest-name"
|
||||
value={nameDraft}
|
||||
placeholder="z.B. Anna"
|
||||
placeholder={t('settings.name.placeholder')}
|
||||
onChange={(event) => onNameChange(event.target.value)}
|
||||
autoComplete="name"
|
||||
disabled={!identity.hydrated || savingName}
|
||||
@@ -253,16 +333,16 @@ function HomeView({
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button onClick={onSaveName} disabled={!canSaveName || savingName}>
|
||||
{savingName ? 'Speichere...' : 'Name speichern'}
|
||||
{savingName ? t('settings.name.saving') : t('settings.name.save')}
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" onClick={onResetName} disabled={savingName}>
|
||||
zurücksetzen
|
||||
{t('settings.name.reset')}
|
||||
</Button>
|
||||
{nameStatus === 'saved' && (
|
||||
<span className="text-xs text-muted-foreground">Gespeichert (ok)</span>
|
||||
<span className="text-xs text-muted-foreground">{t('settings.name.saved')}</span>
|
||||
)}
|
||||
{!identity.hydrated && (
|
||||
<span className="text-xs text-muted-foreground">Lade gespeicherten Namen...</span>
|
||||
<span className="text-xs text-muted-foreground">{t('settings.name.loading')}</span>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -274,20 +354,18 @@ function HomeView({
|
||||
<CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-4 w-4 text-pink-500" />
|
||||
Rechtliches
|
||||
{t('settings.legal.title')}
|
||||
</div>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Die rechtlich verbindlichen Texte sind jederzeit hier abrufbar.
|
||||
</CardDescription>
|
||||
<CardDescription>{t('settings.legal.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{legalPages.map((page) => (
|
||||
{legalLinks.map((page) => (
|
||||
<Button
|
||||
key={page.slug}
|
||||
variant="ghost"
|
||||
className="w-full justify-between px-3"
|
||||
onClick={() => onOpenLegal(page.slug, page.label)}
|
||||
onClick={() => onOpenLegal(page.slug, page.translationKey)}
|
||||
>
|
||||
<span className="text-left text-sm">{page.label}</span>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
@@ -298,16 +376,14 @@ function HomeView({
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Offline Cache</CardTitle>
|
||||
<CardDescription>
|
||||
Loesche lokale Daten, falls Inhalte veraltet erscheinen oder Uploads haengen bleiben.
|
||||
</CardDescription>
|
||||
<CardTitle>{t('settings.cache.title')}</CardTitle>
|
||||
<CardDescription>{t('settings.cache.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<ClearCacheButton />
|
||||
<div className="flex items-start gap-2 text-xs text-muted-foreground">
|
||||
<RefreshCcw className="mt-0.5 h-3.5 w-3.5" />
|
||||
<span>Dies betrifft nur diesen Browser und muss pro Geraet erneut ausgefuehrt werden.</span>
|
||||
<span>{t('settings.cache.note')}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -315,7 +391,7 @@ function HomeView({
|
||||
);
|
||||
}
|
||||
|
||||
function useLegalDocument(slug: string | null): LegalDocumentState {
|
||||
function useLegalDocument(slug: string | null, locale: LocaleCode): LegalDocumentState {
|
||||
const [state, setState] = React.useState<LegalDocumentState>({
|
||||
phase: 'idle',
|
||||
title: '',
|
||||
@@ -331,7 +407,8 @@ function useLegalDocument(slug: string | null): LegalDocumentState {
|
||||
const controller = new AbortController();
|
||||
setState({ phase: 'loading', title: '', body: '' });
|
||||
|
||||
fetch(`/api/v1/legal/${encodeURIComponent(slug)}?lang=de`, {
|
||||
const langParam = encodeURIComponent(locale);
|
||||
fetch(`/api/v1/legal/${encodeURIComponent(slug)}?lang=${langParam}`, {
|
||||
headers: { 'Cache-Control': 'no-store' },
|
||||
signal: controller.signal,
|
||||
})
|
||||
@@ -355,7 +432,7 @@ function useLegalDocument(slug: string | null): LegalDocumentState {
|
||||
});
|
||||
|
||||
return () => controller.abort();
|
||||
}, [slug]);
|
||||
}, [slug, locale]);
|
||||
|
||||
return state;
|
||||
}
|
||||
@@ -363,6 +440,7 @@ function useLegalDocument(slug: string | null): LegalDocumentState {
|
||||
function ClearCacheButton() {
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const [done, setDone] = React.useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
async function clearAll() {
|
||||
setBusy(true);
|
||||
@@ -393,9 +471,9 @@ function ClearCacheButton() {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Button variant="secondary" onClick={clearAll} disabled={busy} className="w-full">
|
||||
{busy ? 'Leere Cache...' : 'Cache leeren'}
|
||||
{busy ? t('settings.cache.clearing') : t('settings.cache.clear')}
|
||||
</Button>
|
||||
{done && <div className="text-xs text-muted-foreground">Cache geloescht.</div>}
|
||||
{done && <div className="text-xs text-muted-foreground">{t('settings.cache.cleared')}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
124
resources/js/guest/i18n/LocaleContext.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import React from 'react';
|
||||
import { DEFAULT_LOCALE, SUPPORTED_LOCALES, type LocaleCode, isLocaleCode } from './messages';
|
||||
|
||||
export interface LocaleContextValue {
|
||||
locale: LocaleCode;
|
||||
setLocale: (next: LocaleCode) => void;
|
||||
resetLocale: () => void;
|
||||
hydrated: boolean;
|
||||
defaultLocale: LocaleCode;
|
||||
storageKey: string;
|
||||
availableLocales: typeof SUPPORTED_LOCALES;
|
||||
}
|
||||
|
||||
const LocaleContext = React.createContext<LocaleContextValue | undefined>(undefined);
|
||||
|
||||
function sanitizeLocale(value: string | null | undefined, fallback: LocaleCode = DEFAULT_LOCALE): LocaleCode {
|
||||
if (value && isLocaleCode(value)) {
|
||||
return value;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export interface LocaleProviderProps {
|
||||
children: React.ReactNode;
|
||||
defaultLocale?: LocaleCode;
|
||||
storageKey?: string;
|
||||
}
|
||||
|
||||
export function LocaleProvider({
|
||||
children,
|
||||
defaultLocale = DEFAULT_LOCALE,
|
||||
storageKey = 'guestLocale_global',
|
||||
}: LocaleProviderProps) {
|
||||
const resolvedDefault = sanitizeLocale(defaultLocale, DEFAULT_LOCALE);
|
||||
const [locale, setLocaleState] = React.useState<LocaleCode>(resolvedDefault);
|
||||
const [userLocale, setUserLocale] = React.useState<LocaleCode | null>(null);
|
||||
const [hydrated, setHydrated] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setHydrated(false);
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
setLocaleState(resolvedDefault);
|
||||
setUserLocale(null);
|
||||
setHydrated(true);
|
||||
return;
|
||||
}
|
||||
|
||||
let stored: string | null = null;
|
||||
try {
|
||||
stored = window.localStorage.getItem(storageKey);
|
||||
} catch (error) {
|
||||
console.warn('Failed to read stored locale', error);
|
||||
}
|
||||
|
||||
const nextLocale = sanitizeLocale(stored, resolvedDefault);
|
||||
setLocaleState(nextLocale);
|
||||
setUserLocale(isLocaleCode(stored) ? stored : null);
|
||||
setHydrated(true);
|
||||
}, [storageKey, resolvedDefault]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!hydrated || userLocale !== null) {
|
||||
return;
|
||||
}
|
||||
setLocaleState(resolvedDefault);
|
||||
}, [hydrated, userLocale, resolvedDefault]);
|
||||
|
||||
const setLocale = React.useCallback(
|
||||
(next: LocaleCode) => {
|
||||
const safeLocale = sanitizeLocale(next, resolvedDefault);
|
||||
setLocaleState(safeLocale);
|
||||
setUserLocale(safeLocale);
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
window.localStorage.setItem(storageKey, safeLocale);
|
||||
} catch (error) {
|
||||
console.warn('Failed to persist locale', error);
|
||||
}
|
||||
}
|
||||
},
|
||||
[storageKey, resolvedDefault],
|
||||
);
|
||||
|
||||
const resetLocale = React.useCallback(() => {
|
||||
setUserLocale(null);
|
||||
setLocaleState(resolvedDefault);
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
window.localStorage.removeItem(storageKey);
|
||||
} catch (error) {
|
||||
console.warn('Failed to clear stored locale', error);
|
||||
}
|
||||
}
|
||||
}, [resolvedDefault, storageKey]);
|
||||
|
||||
const value = React.useMemo<LocaleContextValue>(
|
||||
() => ({
|
||||
locale,
|
||||
setLocale,
|
||||
resetLocale,
|
||||
hydrated,
|
||||
defaultLocale: resolvedDefault,
|
||||
storageKey,
|
||||
availableLocales: SUPPORTED_LOCALES,
|
||||
}),
|
||||
[locale, setLocale, resetLocale, hydrated, resolvedDefault, storageKey],
|
||||
);
|
||||
|
||||
return <LocaleContext.Provider value={value}>{children}</LocaleContext.Provider>;
|
||||
}
|
||||
|
||||
export function useLocale(): LocaleContextValue {
|
||||
const ctx = React.useContext(LocaleContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useLocale must be used within a LocaleProvider');
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function useOptionalLocale(): LocaleContextValue | undefined {
|
||||
return React.useContext(LocaleContext);
|
||||
}
|
||||
699
resources/js/guest/i18n/messages.ts
Normal file
@@ -0,0 +1,699 @@
|
||||
export const SUPPORTED_LOCALES = [
|
||||
{ code: 'de', label: 'Deutsch', flag: '🇩🇪' },
|
||||
{ code: 'en', label: 'English', flag: '🇬🇧' },
|
||||
] as const;
|
||||
|
||||
export type LocaleCode = typeof SUPPORTED_LOCALES[number]['code'];
|
||||
|
||||
type NestedMessages = {
|
||||
[key: string]: string | NestedMessages;
|
||||
};
|
||||
|
||||
export const DEFAULT_LOCALE: LocaleCode = 'de';
|
||||
|
||||
export const messages: Record<LocaleCode, NestedMessages> = {
|
||||
de: {
|
||||
common: {
|
||||
hi: 'Hi',
|
||||
actions: {
|
||||
close: 'Schliessen',
|
||||
loading: 'Laedt...',
|
||||
},
|
||||
},
|
||||
navigation: {
|
||||
home: 'Start',
|
||||
tasks: 'Aufgaben',
|
||||
achievements: 'Erfolge',
|
||||
gallery: 'Galerie',
|
||||
},
|
||||
header: {
|
||||
loading: 'Lade Event...',
|
||||
stats: {
|
||||
online: 'online',
|
||||
tasksSolved: 'Aufgaben geloest',
|
||||
},
|
||||
},
|
||||
eventAccess: {
|
||||
loading: {
|
||||
title: 'Wir pruefen deinen Zugang...',
|
||||
subtitle: 'Einen Moment bitte.',
|
||||
},
|
||||
error: {
|
||||
invalid_token: {
|
||||
title: 'Zugriffscode ungueltig',
|
||||
description: 'Der eingegebene Code konnte nicht verifiziert werden.',
|
||||
ctaLabel: 'Neuen Code anfordern',
|
||||
},
|
||||
token_revoked: {
|
||||
title: 'Zugriffscode deaktiviert',
|
||||
description: 'Dieser Code wurde zurueckgezogen. Bitte fordere einen neuen Code an.',
|
||||
ctaLabel: 'Neuen Code anfordern',
|
||||
},
|
||||
token_expired: {
|
||||
title: 'Zugriffscode abgelaufen',
|
||||
description: 'Der Code ist nicht mehr gueltig. Aktualisiere deinen Code, um fortzufahren.',
|
||||
ctaLabel: 'Code aktualisieren',
|
||||
},
|
||||
token_rate_limited: {
|
||||
title: 'Zu viele Versuche',
|
||||
description: 'Es gab zu viele Eingaben in kurzer Zeit. Warte kurz und versuche es erneut.',
|
||||
hint: 'Tipp: Eine erneute Eingabe ist in wenigen Minuten moeglich.',
|
||||
},
|
||||
event_not_public: {
|
||||
title: 'Event nicht oeffentlich',
|
||||
description: 'Dieses Event ist aktuell nicht oeffentlich zugaenglich.',
|
||||
hint: 'Nimm Kontakt mit den Veranstalter:innen auf, um Zugang zu erhalten.',
|
||||
},
|
||||
network_error: {
|
||||
title: 'Verbindungsproblem',
|
||||
description: 'Wir konnten keine Verbindung zum Server herstellen. Pruefe deine Internetverbindung und versuche es erneut.',
|
||||
},
|
||||
server_error: {
|
||||
title: 'Server nicht erreichbar',
|
||||
description: 'Der Server reagiert derzeit nicht. Versuche es spaeter erneut.',
|
||||
},
|
||||
default: {
|
||||
title: 'Event nicht erreichbar',
|
||||
description: 'Wir konnten dein Event nicht laden. Bitte versuche es erneut.',
|
||||
ctaLabel: 'Zur Code-Eingabe',
|
||||
},
|
||||
},
|
||||
},
|
||||
profileSetup: {
|
||||
loading: 'Lade Event...',
|
||||
error: {
|
||||
default: 'Event nicht gefunden.',
|
||||
backToStart: 'Zurueck zur Startseite',
|
||||
},
|
||||
card: {
|
||||
description: 'Fange den schoensten Moment ein!',
|
||||
},
|
||||
form: {
|
||||
label: 'Dein Name (z.B. Anna)',
|
||||
placeholder: 'Dein Name',
|
||||
submit: 'Los gehts!',
|
||||
submitting: 'Speichere...',
|
||||
},
|
||||
},
|
||||
landing: {
|
||||
pageTitle: 'Willkommen bei der Fotobox!',
|
||||
headline: 'Willkommen bei der Fotobox!',
|
||||
subheadline: 'Dein Schluessel zu unvergesslichen Momenten.',
|
||||
join: {
|
||||
title: 'Event beitreten',
|
||||
description: 'Scanne den QR-Code oder gib den Code manuell ein.',
|
||||
button: 'Event beitreten',
|
||||
buttonLoading: 'Pruefe...',
|
||||
},
|
||||
scan: {
|
||||
start: 'QR-Code scannen',
|
||||
stop: 'Scanner stoppen',
|
||||
manualDivider: 'Oder manuell eingeben',
|
||||
},
|
||||
input: {
|
||||
placeholder: 'Event-Code eingeben',
|
||||
},
|
||||
errors: {
|
||||
eventClosed: 'Event nicht gefunden oder geschlossen.',
|
||||
network: 'Netzwerkfehler. Bitte spaeter erneut versuchen.',
|
||||
camera: 'Kamera-Zugriff fehlgeschlagen. Bitte erlaube den Zugriff und versuche es erneut.',
|
||||
},
|
||||
},
|
||||
home: {
|
||||
fallbackGuestName: 'Gast',
|
||||
hero: {
|
||||
subtitle: 'Willkommen zur Party',
|
||||
title: 'Hey {name}!',
|
||||
description: 'Du bist bereit fuer "{eventName}". Fang die Highlights des Events ein und teile sie mit allen Gaesten.',
|
||||
progress: {
|
||||
some: 'Schon {count} Aufgaben erledigt - weiter so!',
|
||||
none: 'Starte mit deiner ersten Aufgabe - wir zaehlen auf dich!',
|
||||
},
|
||||
defaultEventName: 'Dein Event',
|
||||
},
|
||||
stats: {
|
||||
online: 'Gleichzeitig online',
|
||||
tasksSolved: 'Aufgaben geloest',
|
||||
lastUpload: 'Letzter Upload',
|
||||
completedTasks: 'Deine erledigten Aufgaben',
|
||||
},
|
||||
actions: {
|
||||
title: 'Deine Aktionen',
|
||||
subtitle: 'Waehle aus, womit du starten willst',
|
||||
queueButton: 'Uploads in Warteschlange ansehen',
|
||||
items: {
|
||||
tasks: {
|
||||
label: 'Aufgabe ziehen',
|
||||
description: 'Hol dir deine naechste Challenge',
|
||||
},
|
||||
upload: {
|
||||
label: 'Direkt hochladen',
|
||||
description: 'Teile deine neuesten Fotos',
|
||||
},
|
||||
gallery: {
|
||||
label: 'Galerie ansehen',
|
||||
description: 'Lass dich von anderen inspirieren',
|
||||
},
|
||||
},
|
||||
},
|
||||
checklist: {
|
||||
title: 'Dein Fortschritt',
|
||||
description: 'Halte dich an diese drei kurzen Schritte fuer die besten Ergebnisse.',
|
||||
steps: {
|
||||
first: 'Aufgabe auswaehlen oder starten',
|
||||
second: 'Emotion festhalten und Foto schiessen',
|
||||
third: 'Bild hochladen und Credits sammeln',
|
||||
},
|
||||
},
|
||||
latestUpload: {
|
||||
none: 'Noch kein Upload',
|
||||
invalid: 'Noch kein Upload',
|
||||
justNow: 'Gerade eben',
|
||||
minutes: 'vor {count} Min',
|
||||
hours: 'vor {count} Std',
|
||||
days: 'vor {count} Tagen',
|
||||
},
|
||||
},
|
||||
notFound: {
|
||||
title: 'Nicht gefunden',
|
||||
description: 'Die Seite konnte nicht gefunden werden.',
|
||||
},
|
||||
uploadQueue: {
|
||||
title: 'Uploads',
|
||||
description: 'Warteschlange mit Fortschritt und erneuten Versuchen; Hintergrund-Sync umschalten.',
|
||||
},
|
||||
lightbox: {
|
||||
taskLabel: 'Aufgabe',
|
||||
loadingTask: 'Lade Aufgabe...',
|
||||
photoAlt: 'Foto {id}{suffix}',
|
||||
photoAltTaskSuffix: ' - {taskTitle}',
|
||||
fallbackTitle: 'Aufgabe {id}',
|
||||
unknownTitle: 'Unbekannte Aufgabe {id}',
|
||||
errors: {
|
||||
notFound: 'Foto nicht gefunden',
|
||||
loadFailed: 'Fehler beim Laden des Fotos',
|
||||
},
|
||||
},
|
||||
upload: {
|
||||
cameraTitle: 'Kamera',
|
||||
preparing: 'Aufgabe und Kamera werden vorbereitet ...',
|
||||
loadError: {
|
||||
title: 'Aufgabe konnte nicht geladen werden. Du kannst trotzdem ein Foto machen.',
|
||||
retry: 'Nochmal versuchen',
|
||||
},
|
||||
primer: {
|
||||
title: 'Bereit fuer dein Shooting?',
|
||||
body: {
|
||||
part1: 'Lass uns sicherstellen, dass alles sitzt: pruefe das Licht, wisch die Kamera sauber und richte alle Personen im Bild aus.',
|
||||
part2: 'Du kannst zwischen Front- und Rueckkamera wechseln und bei Bedarf ein Raster aktivieren.',
|
||||
},
|
||||
dismiss: 'Verstanden',
|
||||
},
|
||||
cameraUnsupported: {
|
||||
title: 'Kamera nicht verfuegbar',
|
||||
message: 'Dein Geraet unterstuetzt keine Kameravorschau im Browser. Du kannst stattdessen Fotos aus deiner Galerie hochladen.',
|
||||
openGallery: 'Foto aus Galerie waehlen',
|
||||
},
|
||||
cameraDenied: {
|
||||
title: 'Kamera-Zugriff verweigert',
|
||||
explanation: 'Du musst den Zugriff auf die Kamera erlauben, um Fotos aufnehmen zu koennen.',
|
||||
reopenPrompt: 'Systemdialog erneut oeffnen',
|
||||
chooseFile: 'Foto aus Galerie waehlen',
|
||||
prompt: 'Wir brauchen Zugriff auf deine Kamera. Erlaube die Anfrage oder waehle alternativ ein Foto aus deiner Galerie.',
|
||||
},
|
||||
cameraError: {
|
||||
title: 'Kamera konnte nicht gestartet werden',
|
||||
explanation: 'Wir konnten keine Verbindung zur Kamera herstellen. Pruefe die Berechtigungen oder starte dein Geraet neu.',
|
||||
tryAgain: 'Nochmals versuchen',
|
||||
},
|
||||
readyOverlay: {
|
||||
title: 'Kamera bereit',
|
||||
message: 'Wenn alle im Bild sind, kannst du den Countdown starten oder ein vorhandenes Foto waehlen.',
|
||||
start: 'Countdown starten',
|
||||
chooseFile: 'Foto auswaehlen',
|
||||
},
|
||||
taskInfo: {
|
||||
countdown: 'Countdown',
|
||||
emotion: 'Stimmung: {value}',
|
||||
instructionsPrefix: 'Hinweis',
|
||||
difficulty: {
|
||||
easy: 'Leicht',
|
||||
medium: 'Medium',
|
||||
hard: 'Herausfordernd',
|
||||
},
|
||||
timeEstimate: '{count} Min',
|
||||
fallbackTitle: 'Aufgabe {id}',
|
||||
fallbackDescription: 'Halte den Moment fest und teile ihn mit allen Gaesten.',
|
||||
fallbackInstructions: 'Positioniere alle im Bild, starte den Countdown und lass die Emotion wirken.',
|
||||
badge: 'Aufgabe #{id}',
|
||||
},
|
||||
countdown: {
|
||||
ready: 'Bereit machen ...',
|
||||
},
|
||||
review: {
|
||||
retake: 'Nochmal aufnehmen',
|
||||
keep: 'Foto verwenden',
|
||||
readyAnnouncement: 'Foto aufgenommen. Bitte Vorschau pruefen.',
|
||||
},
|
||||
status: {
|
||||
saving: 'Speichere Foto...',
|
||||
processing: 'Verarbeite Foto...',
|
||||
uploading: 'Foto wird hochgeladen...',
|
||||
preparing: 'Foto wird vorbereitet...',
|
||||
completed: 'Upload abgeschlossen.',
|
||||
failed: 'Upload fehlgeschlagen. Bitte versuche es erneut.',
|
||||
},
|
||||
controls: {
|
||||
toggleGrid: 'Raster umschalten',
|
||||
toggleCountdown: 'Countdown umschalten',
|
||||
toggleMirror: 'Spiegelung fuer Frontkamera umschalten',
|
||||
toggleFlash: 'Blitzpraeferenz umschalten',
|
||||
capture: 'Foto aufnehmen',
|
||||
switchCamera: 'Kamera wechseln',
|
||||
chooseFile: 'Foto auswaehlen',
|
||||
},
|
||||
limitReached: 'Upload-Limit erreicht ({used} / {max} Fotos). Bitte kontaktiere die Veranstalter fuer ein Upgrade.',
|
||||
limitUnlimited: 'unbegrenzt',
|
||||
cameraInactive: 'Kamera ist nicht aktiv. {hint}',
|
||||
cameraInactiveHint: 'Tippe auf "{label}", um loszulegen.',
|
||||
captureError: 'Foto konnte nicht erstellt werden.',
|
||||
feedError: 'Kamera liefert kein Bild. Bitte starte die Kamera neu.',
|
||||
canvasError: 'Canvas konnte nicht initialisiert werden.',
|
||||
limitCheckError: 'Fehler beim Pruefen des Upload-Limits. Upload deaktiviert.',
|
||||
galleryPickError: 'Auswahl fehlgeschlagen. Bitte versuche es erneut.',
|
||||
captureButton: 'Foto aufnehmen',
|
||||
galleryButton: 'Foto aus Galerie waehlen',
|
||||
switchCamera: 'Kamera wechseln',
|
||||
countdownLabel: 'Countdown: {seconds}s',
|
||||
countdownReady: 'Bereit machen ...',
|
||||
buttons: {
|
||||
startCamera: 'Kamera starten',
|
||||
tryAgain: 'Erneut versuchen',
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
title: 'Einstellungen',
|
||||
subtitle: 'Verwalte deinen Gastzugang, rechtliche Dokumente und lokale Daten.',
|
||||
language: {
|
||||
title: 'Sprache',
|
||||
description: 'Waehle deine bevorzugte Sprache fuer diese Veranstaltung.',
|
||||
activeBadge: 'aktiv',
|
||||
option: {
|
||||
de: 'Deutsch',
|
||||
en: 'English',
|
||||
},
|
||||
},
|
||||
name: {
|
||||
title: 'Dein Name',
|
||||
description: 'Passe an, wie wir dich im Event begruessen. Der Name wird nur lokal gespeichert.',
|
||||
label: 'Anzeigename',
|
||||
placeholder: 'z.B. Anna',
|
||||
save: 'Name speichern',
|
||||
saving: 'Speichere...',
|
||||
reset: 'Zuruecksetzen',
|
||||
saved: 'Gespeichert (ok)',
|
||||
loading: 'Lade gespeicherten Namen...',
|
||||
},
|
||||
legal: {
|
||||
title: 'Rechtliches',
|
||||
description: 'Die rechtlich verbindlichen Texte sind jederzeit hier abrufbar.',
|
||||
loading: 'Dokument wird geladen...',
|
||||
error: 'Das Dokument konnte nicht geladen werden. Bitte versuche es spaeter erneut.',
|
||||
fallbackTitle: 'Rechtlicher Hinweis',
|
||||
section: {
|
||||
impressum: 'Impressum',
|
||||
privacy: 'Datenschutz',
|
||||
terms: 'AGB',
|
||||
},
|
||||
},
|
||||
cache: {
|
||||
title: 'Offline Cache',
|
||||
description: 'Loesche lokale Daten, falls Inhalte veraltet erscheinen oder Uploads haengen bleiben.',
|
||||
clear: 'Cache leeren',
|
||||
clearing: 'Leere Cache...',
|
||||
cleared: 'Cache geloescht.',
|
||||
note: 'Dies betrifft nur diesen Browser und muss pro Geraet erneut ausgefuehrt werden.',
|
||||
},
|
||||
footer: {
|
||||
notice: 'Gastbereich - Daten werden lokal im Browser gespeichert.',
|
||||
},
|
||||
sheet: {
|
||||
openLabel: 'Einstellungen oeffnen',
|
||||
backLabel: 'Zurueck',
|
||||
legalDescription: 'Rechtlicher Hinweis',
|
||||
},
|
||||
},
|
||||
},
|
||||
en: {
|
||||
common: {
|
||||
hi: 'Hi',
|
||||
actions: {
|
||||
close: 'Close',
|
||||
loading: 'Loading...',
|
||||
},
|
||||
},
|
||||
navigation: {
|
||||
home: 'Home',
|
||||
tasks: 'Tasks',
|
||||
achievements: 'Achievements',
|
||||
gallery: 'Gallery',
|
||||
},
|
||||
header: {
|
||||
loading: 'Loading event...',
|
||||
stats: {
|
||||
online: 'online',
|
||||
tasksSolved: 'tasks solved',
|
||||
},
|
||||
},
|
||||
eventAccess: {
|
||||
loading: {
|
||||
title: 'Checking your access...',
|
||||
subtitle: 'Hang tight for a moment.',
|
||||
},
|
||||
error: {
|
||||
invalid_token: {
|
||||
title: 'Access code invalid',
|
||||
description: 'We could not verify the code you entered.',
|
||||
ctaLabel: 'Request new code',
|
||||
},
|
||||
token_revoked: {
|
||||
title: 'Access code revoked',
|
||||
description: 'This code was revoked. Please request a new one.',
|
||||
ctaLabel: 'Request new code',
|
||||
},
|
||||
token_expired: {
|
||||
title: 'Access code expired',
|
||||
description: 'The code is no longer valid. Refresh your code to continue.',
|
||||
ctaLabel: 'Refresh code',
|
||||
},
|
||||
token_rate_limited: {
|
||||
title: 'Too many attempts',
|
||||
description: 'There were too many attempts in a short time. Wait a bit and try again.',
|
||||
hint: 'Tip: You can retry in a few minutes.',
|
||||
},
|
||||
event_not_public: {
|
||||
title: 'Event not public',
|
||||
description: 'This event is not publicly accessible right now.',
|
||||
hint: 'Contact the organizers to get access.',
|
||||
},
|
||||
network_error: {
|
||||
title: 'Connection issue',
|
||||
description: 'We could not reach the server. Check your connection and try again.',
|
||||
},
|
||||
server_error: {
|
||||
title: 'Server unavailable',
|
||||
description: 'The server is currently unavailable. Please try again later.',
|
||||
},
|
||||
default: {
|
||||
title: 'Event unavailable',
|
||||
description: 'We could not load your event. Please try again.',
|
||||
ctaLabel: 'Back to code entry',
|
||||
},
|
||||
},
|
||||
},
|
||||
profileSetup: {
|
||||
loading: 'Loading event...',
|
||||
error: {
|
||||
default: 'Event not found.',
|
||||
backToStart: 'Back to start',
|
||||
},
|
||||
card: {
|
||||
description: 'Capture the best moment!',
|
||||
},
|
||||
form: {
|
||||
label: 'Your name (e.g. Anna)',
|
||||
placeholder: 'Your name',
|
||||
submit: "Let's go!",
|
||||
submitting: 'Saving...',
|
||||
},
|
||||
},
|
||||
landing: {
|
||||
pageTitle: 'Welcome to the photo booth!',
|
||||
headline: 'Welcome to the photo booth!',
|
||||
subheadline: 'Your key to unforgettable moments.',
|
||||
join: {
|
||||
title: 'Join the event',
|
||||
description: 'Scan the QR code or enter the code manually.',
|
||||
button: 'Join event',
|
||||
buttonLoading: 'Checking...',
|
||||
},
|
||||
scan: {
|
||||
start: 'Scan QR code',
|
||||
stop: 'Stop scanner',
|
||||
manualDivider: 'Or enter it manually',
|
||||
},
|
||||
input: {
|
||||
placeholder: 'Enter event code',
|
||||
},
|
||||
errors: {
|
||||
eventClosed: 'Event not found or closed.',
|
||||
network: 'Network error. Please try again later.',
|
||||
camera: 'Camera access failed. Allow access and try again.',
|
||||
},
|
||||
},
|
||||
home: {
|
||||
fallbackGuestName: 'Guest',
|
||||
hero: {
|
||||
subtitle: 'Welcome to the party',
|
||||
title: 'Hey {name}!',
|
||||
description: 'You are ready for "{eventName}". Capture the highlights and share them with everyone.',
|
||||
progress: {
|
||||
some: 'Already {count} tasks done - keep going!',
|
||||
none: 'Start with your first task - we are counting on you!',
|
||||
},
|
||||
defaultEventName: 'Your event',
|
||||
},
|
||||
stats: {
|
||||
online: 'Guests online',
|
||||
tasksSolved: 'Tasks completed',
|
||||
lastUpload: 'Latest upload',
|
||||
completedTasks: 'Your completed tasks',
|
||||
},
|
||||
actions: {
|
||||
title: 'Your actions',
|
||||
subtitle: 'Choose how you want to start',
|
||||
queueButton: 'View uploads in queue',
|
||||
items: {
|
||||
tasks: {
|
||||
label: 'Draw a task',
|
||||
description: 'Grab your next challenge',
|
||||
},
|
||||
upload: {
|
||||
label: 'Upload directly',
|
||||
description: 'Share your latest photos',
|
||||
},
|
||||
gallery: {
|
||||
label: 'Browse gallery',
|
||||
description: 'Get inspired by others',
|
||||
},
|
||||
},
|
||||
},
|
||||
checklist: {
|
||||
title: 'Your progress',
|
||||
description: 'Follow these three quick steps for the best results.',
|
||||
steps: {
|
||||
first: 'Pick or start a task',
|
||||
second: 'Capture the emotion and take the photo',
|
||||
third: 'Upload the picture and earn credits',
|
||||
},
|
||||
},
|
||||
latestUpload: {
|
||||
none: 'No upload yet',
|
||||
invalid: 'No upload yet',
|
||||
justNow: 'Just now',
|
||||
minutes: '{count} min ago',
|
||||
hours: '{count} h ago',
|
||||
days: '{count} days ago',
|
||||
},
|
||||
},
|
||||
notFound: {
|
||||
title: 'Not found',
|
||||
description: 'We could not find the page you requested.',
|
||||
},
|
||||
uploadQueue: {
|
||||
title: 'Uploads',
|
||||
description: 'Queue with progress/retry and background sync toggle.',
|
||||
},
|
||||
lightbox: {
|
||||
taskLabel: 'Task',
|
||||
loadingTask: 'Loading task...',
|
||||
photoAlt: 'Photo {id}{suffix}',
|
||||
photoAltTaskSuffix: ' - {taskTitle}',
|
||||
fallbackTitle: 'Task {id}',
|
||||
unknownTitle: 'Unknown task {id}',
|
||||
errors: {
|
||||
notFound: 'Photo not found',
|
||||
loadFailed: 'Failed to load photo',
|
||||
},
|
||||
},
|
||||
upload: {
|
||||
cameraTitle: 'Camera',
|
||||
preparing: 'Preparing task and camera ...',
|
||||
loadError: {
|
||||
title: 'Task could not be loaded. You can still take a photo.',
|
||||
retry: 'Try again',
|
||||
},
|
||||
primer: {
|
||||
title: 'Ready for your shoot?',
|
||||
body: {
|
||||
part1: 'Make sure everything is set: check the lighting, clean the lens, and line everyone up in the frame.',
|
||||
part2: 'You can switch between front and back camera and enable the grid if needed.',
|
||||
},
|
||||
dismiss: 'Got it',
|
||||
},
|
||||
cameraUnsupported: {
|
||||
title: 'Camera not available',
|
||||
message: 'Your device does not support live camera preview in this browser. You can upload photos from your gallery instead.',
|
||||
openGallery: 'Choose photo from gallery',
|
||||
},
|
||||
cameraDenied: {
|
||||
title: 'Camera access denied',
|
||||
explanation: 'Allow camera access to capture photos.',
|
||||
reopenPrompt: 'Open system dialog again',
|
||||
chooseFile: 'Choose photo from gallery',
|
||||
prompt: 'We need access to your camera. Allow the request or pick a photo from your gallery.',
|
||||
},
|
||||
cameraError: {
|
||||
title: 'Camera could not be started',
|
||||
explanation: 'We could not connect to the camera. Check permissions or restart your device.',
|
||||
tryAgain: 'Try again',
|
||||
},
|
||||
readyOverlay: {
|
||||
title: 'Camera ready',
|
||||
message: 'Once everyone is in frame, start the countdown or pick an existing photo.',
|
||||
start: 'Start countdown',
|
||||
chooseFile: 'Choose photo',
|
||||
},
|
||||
taskInfo: {
|
||||
countdown: 'Countdown',
|
||||
emotion: 'Emotion: {value}',
|
||||
instructionsPrefix: 'Hint',
|
||||
difficulty: {
|
||||
easy: 'Easy',
|
||||
medium: 'Medium',
|
||||
hard: 'Challenging',
|
||||
},
|
||||
timeEstimate: '{count} min',
|
||||
fallbackTitle: 'Task {id}',
|
||||
fallbackDescription: 'Capture the moment and share it with everyone.',
|
||||
fallbackInstructions: 'Line everyone up, start the countdown, and let the emotion shine.',
|
||||
badge: 'Task #{id}',
|
||||
},
|
||||
countdown: {
|
||||
ready: 'Get ready ...',
|
||||
},
|
||||
review: {
|
||||
retake: 'Retake photo',
|
||||
keep: 'Use this photo',
|
||||
readyAnnouncement: 'Photo captured. Please review the preview.',
|
||||
},
|
||||
status: {
|
||||
saving: 'Saving photo...',
|
||||
processing: 'Processing photo...',
|
||||
uploading: 'Uploading photo...',
|
||||
preparing: 'Preparing photo...',
|
||||
completed: 'Upload complete.',
|
||||
failed: 'Upload failed. Please try again.',
|
||||
},
|
||||
controls: {
|
||||
toggleGrid: 'Toggle grid',
|
||||
toggleCountdown: 'Toggle countdown',
|
||||
toggleMirror: 'Toggle mirroring for front camera',
|
||||
toggleFlash: 'Toggle flash',
|
||||
capture: 'Capture photo',
|
||||
switchCamera: 'Switch camera',
|
||||
chooseFile: 'Choose photo',
|
||||
},
|
||||
limitReached: 'Upload limit reached ({used} / {max} photos). Contact the organizers for an upgrade.',
|
||||
limitUnlimited: 'unlimited',
|
||||
cameraInactive: 'Camera is not active. {hint}',
|
||||
cameraInactiveHint: 'Tap "{label}" to get started.',
|
||||
captureError: 'Photo could not be created.',
|
||||
feedError: 'Camera feed not available. Please restart the camera.',
|
||||
canvasError: 'Canvas could not be initialised.',
|
||||
limitCheckError: 'Failed to check upload limits. Upload disabled.',
|
||||
galleryPickError: 'Selection failed. Please try again.',
|
||||
captureButton: 'Capture photo',
|
||||
galleryButton: 'Choose from gallery',
|
||||
switchCamera: 'Switch camera',
|
||||
countdownLabel: 'Countdown: {seconds}s',
|
||||
countdownReady: 'Get ready ...',
|
||||
buttons: {
|
||||
startCamera: 'Start camera',
|
||||
tryAgain: 'Try again',
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
title: 'Settings',
|
||||
subtitle: 'Manage your guest access, legal documents, and local data.',
|
||||
language: {
|
||||
title: 'Language',
|
||||
description: 'Choose your preferred language for this event.',
|
||||
activeBadge: 'active',
|
||||
option: {
|
||||
de: 'German',
|
||||
en: 'English',
|
||||
},
|
||||
},
|
||||
name: {
|
||||
title: 'Your name',
|
||||
description: 'Update how we greet you inside the event. The name is stored locally only.',
|
||||
label: 'Display name',
|
||||
placeholder: 'e.g. Anna',
|
||||
save: 'Save name',
|
||||
saving: 'Saving...',
|
||||
reset: 'Reset',
|
||||
saved: 'Saved',
|
||||
loading: 'Loading saved name...',
|
||||
},
|
||||
legal: {
|
||||
title: 'Legal',
|
||||
description: 'The legally binding documents are always available here.',
|
||||
loading: 'Loading document...',
|
||||
error: 'The document could not be loaded. Please try again later.',
|
||||
fallbackTitle: 'Legal notice',
|
||||
section: {
|
||||
impressum: 'Imprint',
|
||||
privacy: 'Privacy',
|
||||
terms: 'Terms',
|
||||
},
|
||||
},
|
||||
cache: {
|
||||
title: 'Offline cache',
|
||||
description: 'Clear local data if content looks outdated or uploads get stuck.',
|
||||
clear: 'Clear cache',
|
||||
clearing: 'Clearing cache...',
|
||||
cleared: 'Cache cleared.',
|
||||
note: 'This only affects this browser and must be repeated per device.',
|
||||
},
|
||||
footer: {
|
||||
notice: 'Guest area - data is stored locally in the browser.',
|
||||
},
|
||||
sheet: {
|
||||
openLabel: 'Open settings',
|
||||
backLabel: 'Back',
|
||||
legalDescription: 'Legal notice',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export function isLocaleCode(value: string | null | undefined): value is LocaleCode {
|
||||
return SUPPORTED_LOCALES.some((item) => item.code === value);
|
||||
}
|
||||
|
||||
export function translate(locale: LocaleCode, key: string): string | undefined {
|
||||
const segments = key.split('.');
|
||||
const tryLocale = (loc: LocaleCode): string | undefined => {
|
||||
let current: unknown = messages[loc];
|
||||
for (const segment of segments) {
|
||||
if (!current || typeof current !== 'object' || !(segment in current)) {
|
||||
return undefined;
|
||||
}
|
||||
current = (current as NestedMessages)[segment];
|
||||
}
|
||||
return typeof current === 'string' ? current : undefined;
|
||||
};
|
||||
|
||||
return tryLocale(locale) ?? tryLocale(DEFAULT_LOCALE);
|
||||
}
|
||||
21
resources/js/guest/i18n/useTranslation.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
import { translate, DEFAULT_LOCALE, type LocaleCode } from './messages';
|
||||
import { useLocale } from './LocaleContext';
|
||||
|
||||
export type TranslateFn = (key: string, fallback?: string) => string;
|
||||
|
||||
function resolveTranslation(locale: LocaleCode, key: string, fallback?: string): string {
|
||||
return translate(locale, key) ?? translate(DEFAULT_LOCALE, key) ?? fallback ?? key;
|
||||
}
|
||||
|
||||
export function useTranslation() {
|
||||
const { locale } = useLocale();
|
||||
|
||||
const t = React.useCallback<TranslateFn>(
|
||||
(key, fallback) => resolveTranslation(locale, key, fallback),
|
||||
[locale],
|
||||
);
|
||||
|
||||
return React.useMemo(() => ({ t, locale }), [t, locale]);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { router } from './router';
|
||||
import '../../css/app.css';
|
||||
import { initializeTheme } from '@/hooks/use-appearance';
|
||||
import { ToastProvider } from './components/ToastHost';
|
||||
import { LocaleProvider } from './i18n/LocaleContext';
|
||||
|
||||
initializeTheme();
|
||||
const rootEl = document.getElementById('root')!;
|
||||
@@ -25,9 +26,10 @@ if ('serviceWorker' in navigator) {
|
||||
}
|
||||
createRoot(rootEl).render(
|
||||
<React.StrictMode>
|
||||
<LocaleProvider>
|
||||
<ToastProvider>
|
||||
<RouterProvider router={router} />
|
||||
</ToastProvider>
|
||||
</LocaleProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useEventStats } from '../context/EventStatsContext';
|
||||
import { useEventData } from '../hooks/useEventData';
|
||||
import { useGuestTaskProgress } from '../hooks/useGuestTaskProgress';
|
||||
import { Sparkles, UploadCloud, Images, CheckCircle2, Users, TimerReset } from 'lucide-react';
|
||||
import { useTranslation, type TranslateFn } from '../i18n/useTranslation';
|
||||
|
||||
export default function HomePage() {
|
||||
const { token } = useParams<{ token: string }>();
|
||||
@@ -17,63 +18,76 @@ export default function HomePage() {
|
||||
const stats = useEventStats();
|
||||
const { event } = useEventData();
|
||||
const { completedCount } = useGuestTaskProgress(token);
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!token) return null;
|
||||
|
||||
const displayName = hydrated && name ? name : 'Gast';
|
||||
const latestUploadText = formatLatestUpload(stats.latestPhotoAt);
|
||||
const displayName = hydrated && name ? name : t('home.fallbackGuestName');
|
||||
const eventNameDisplay = event?.name ?? t('home.hero.defaultEventName');
|
||||
const latestUploadText = formatLatestUpload(stats.latestPhotoAt, t);
|
||||
|
||||
const primaryActions: Array<{ to: string; label: string; description: string; icon: React.ReactNode }> = [
|
||||
const primaryActions = React.useMemo(
|
||||
() => [
|
||||
{
|
||||
to: 'tasks',
|
||||
label: 'Aufgabe ziehen',
|
||||
description: 'Hol dir deine naechste Challenge',
|
||||
label: t('home.actions.items.tasks.label'),
|
||||
description: t('home.actions.items.tasks.description'),
|
||||
icon: <Sparkles className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
to: 'upload',
|
||||
label: 'Direkt hochladen',
|
||||
description: 'Teile deine neuesten Fotos',
|
||||
label: t('home.actions.items.upload.label'),
|
||||
description: t('home.actions.items.upload.description'),
|
||||
icon: <UploadCloud className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
to: 'gallery',
|
||||
label: 'Galerie ansehen',
|
||||
description: 'Lass dich von anderen inspirieren',
|
||||
label: t('home.actions.items.gallery.label'),
|
||||
description: t('home.actions.items.gallery.description'),
|
||||
icon: <Images className="h-5 w-5" />,
|
||||
},
|
||||
];
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
const checklistItems = [
|
||||
'Aufgabe auswaehlen oder starten',
|
||||
'Emotion festhalten und Foto schiessen',
|
||||
'Bild hochladen und Credits sammeln',
|
||||
];
|
||||
const checklistItems = React.useMemo(
|
||||
() => [
|
||||
t('home.checklist.steps.first'),
|
||||
t('home.checklist.steps.second'),
|
||||
t('home.checklist.steps.third'),
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-24">
|
||||
<HeroCard name={displayName} eventName={event?.name ?? 'Dein Event'} tasksCompleted={completedCount} />
|
||||
<HeroCard
|
||||
name={displayName}
|
||||
eventName={eventNameDisplay}
|
||||
tasksCompleted={completedCount}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardContent className="grid grid-cols-1 gap-4 py-4 sm:grid-cols-4">
|
||||
<StatTile
|
||||
icon={<Users className="h-4 w-4" />}
|
||||
label="Gleichzeitig online"
|
||||
label={t('home.stats.online')}
|
||||
value={`${stats.onlineGuests}`}
|
||||
/>
|
||||
<StatTile
|
||||
icon={<Sparkles className="h-4 w-4" />}
|
||||
label="Aufgaben gelöst"
|
||||
label={t('home.stats.tasksSolved')}
|
||||
value={`${stats.tasksSolved}`}
|
||||
/>
|
||||
<StatTile
|
||||
icon={<TimerReset className="h-4 w-4" />}
|
||||
label="Letzter Upload"
|
||||
label={t('home.stats.lastUpload')}
|
||||
value={latestUploadText}
|
||||
/>
|
||||
<StatTile
|
||||
icon={<CheckCircle2 className="h-4 w-4" />}
|
||||
label="Deine erledigten Aufgaben"
|
||||
label={t('home.stats.completedTasks')}
|
||||
value={`${completedCount}`}
|
||||
/>
|
||||
</CardContent>
|
||||
@@ -81,8 +95,10 @@ export default function HomePage() {
|
||||
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">Deine Aktionen</h2>
|
||||
<span className="text-xs text-muted-foreground">Waehle aus, womit du starten willst</span>
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{t('home.actions.title')}
|
||||
</h2>
|
||||
<span className="text-xs text-muted-foreground">{t('home.actions.subtitle')}</span>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{primaryActions.map((action) => (
|
||||
@@ -102,14 +118,14 @@ export default function HomePage() {
|
||||
))}
|
||||
</div>
|
||||
<Button variant="outline" asChild className="w-full">
|
||||
<Link to="queue">Uploads in Warteschlange ansehen</Link>
|
||||
<Link to="queue">{t('home.actions.queueButton')}</Link>
|
||||
</Button>
|
||||
</section>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Dein Fortschritt</CardTitle>
|
||||
<CardDescription>Halte dich an diese drei kurzen Schritte fuer die besten Ergebnisse.</CardDescription>
|
||||
<CardTitle>{t('home.checklist.title')}</CardTitle>
|
||||
<CardDescription>{t('home.checklist.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{checklistItems.map((item) => (
|
||||
@@ -130,17 +146,29 @@ export default function HomePage() {
|
||||
);
|
||||
}
|
||||
|
||||
function HeroCard({ name, eventName, tasksCompleted }: { name: string; eventName: string; tasksCompleted: number }) {
|
||||
function HeroCard({
|
||||
name,
|
||||
eventName,
|
||||
tasksCompleted,
|
||||
t,
|
||||
}: {
|
||||
name: string;
|
||||
eventName: string;
|
||||
tasksCompleted: number;
|
||||
t: TranslateFn;
|
||||
}) {
|
||||
const heroTitle = t('home.hero.title').replace('{name}', name);
|
||||
const heroDescription = t('home.hero.description').replace('{eventName}', eventName);
|
||||
const progressMessage = tasksCompleted > 0
|
||||
? `Schon ${tasksCompleted} Aufgaben erledigt - weiter so!`
|
||||
: 'Starte mit deiner ersten Aufgabe - wir zählen auf dich!';
|
||||
? t('home.hero.progress.some').replace('{count}', `${tasksCompleted}`)
|
||||
: t('home.hero.progress.none');
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden border-0 bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 text-white shadow-md">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardDescription className="text-sm text-white/80">Willkommen zur Party</CardDescription>
|
||||
<CardTitle className="text-2xl font-bold">Hey {name}!</CardTitle>
|
||||
<p className="text-sm text-white/80">Du bist bereit für "{eventName}". Fang die Highlights des Events ein und teile sie mit allen Gästen.</p>
|
||||
<CardDescription className="text-sm text-white/80">{t('home.hero.subtitle')}</CardDescription>
|
||||
<CardTitle className="text-2xl font-bold">{heroTitle}</CardTitle>
|
||||
<p className="text-sm text-white/80">{heroDescription}</p>
|
||||
<p className="text-sm font-medium text-white/90">{progressMessage}</p>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
@@ -161,26 +189,26 @@ function StatTile({ icon, label, value }: { icon: React.ReactNode; label: string
|
||||
);
|
||||
}
|
||||
|
||||
function formatLatestUpload(isoDate: string | null) {
|
||||
function formatLatestUpload(isoDate: string | null, t: TranslateFn) {
|
||||
if (!isoDate) {
|
||||
return 'Noch kein Upload';
|
||||
return t('home.latestUpload.none');
|
||||
}
|
||||
const date = new Date(isoDate);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return 'Noch kein Upload';
|
||||
return t('home.latestUpload.invalid');
|
||||
}
|
||||
const diffMs = Date.now() - date.getTime();
|
||||
const diffMinutes = Math.round(diffMs / 60000);
|
||||
if (diffMinutes < 1) {
|
||||
return 'Gerade eben';
|
||||
return t('home.latestUpload.justNow');
|
||||
}
|
||||
if (diffMinutes < 60) {
|
||||
return `vor ${diffMinutes} Min`;
|
||||
return t('home.latestUpload.minutes').replace('{count}', `${diffMinutes}`);
|
||||
}
|
||||
const diffHours = Math.round(diffMinutes / 60);
|
||||
if (diffHours < 24) {
|
||||
return `vor ${diffHours} Std`;
|
||||
return t('home.latestUpload.hours').replace('{count}', `${diffHours}`);
|
||||
}
|
||||
const diffDays = Math.round(diffHours / 24);
|
||||
return `vor ${diffDays} Tagen`;
|
||||
return t('home.latestUpload.days').replace('{count}', `${diffDays}`);
|
||||
}
|
||||
|
||||
@@ -7,14 +7,19 @@ import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Html5Qrcode } from 'html5-qrcode';
|
||||
import { readGuestName } from '../context/GuestIdentityContext';
|
||||
import { useTranslation } from '../i18n/useTranslation';
|
||||
|
||||
type LandingErrorKey = 'eventClosed' | 'network' | 'camera';
|
||||
|
||||
export default function LandingPage() {
|
||||
const nav = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const [eventCode, setEventCode] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [errorKey, setErrorKey] = useState<LandingErrorKey | null>(null);
|
||||
const [isScanning, setIsScanning] = useState(false);
|
||||
const [scanner, setScanner] = useState<Html5Qrcode | null>(null);
|
||||
const errorMessage = errorKey ? t(`landing.errors.${errorKey}`) : null;
|
||||
|
||||
function extractEventKey(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
@@ -48,11 +53,11 @@ export default function LandingPage() {
|
||||
const normalized = extractEventKey(provided);
|
||||
if (!normalized) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setErrorKey(null);
|
||||
try {
|
||||
const res = await fetch(`/api/v1/events/${encodeURIComponent(normalized)}`);
|
||||
if (!res.ok) {
|
||||
setError('Event nicht gefunden oder geschlossen.');
|
||||
setErrorKey('eventClosed');
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
@@ -65,7 +70,7 @@ export default function LandingPage() {
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Join request failed', e);
|
||||
setError('Netzwerkfehler. Bitte spaeter erneut versuchen.');
|
||||
setErrorKey('network');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -80,7 +85,7 @@ export default function LandingPage() {
|
||||
setIsScanning(true);
|
||||
} catch (err) {
|
||||
console.error('Scanner start failed', err);
|
||||
setError('Kamera-Zugriff fehlgeschlagen. Bitte erlaube den Zugriff und versuche es erneut.');
|
||||
setErrorKey('camera');
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -92,7 +97,7 @@ export default function LandingPage() {
|
||||
setIsScanning(true);
|
||||
} catch (err) {
|
||||
console.error('Scanner initialisation failed', err);
|
||||
setError('Kamera-Zugriff fehlgeschlagen. Bitte erlaube den Zugriff und versuche es erneut.');
|
||||
setErrorKey('camera');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,22 +128,22 @@ export default function LandingPage() {
|
||||
}, [scanner]);
|
||||
|
||||
return (
|
||||
<Page title="Willkommen bei der Fotobox!">
|
||||
{error && (
|
||||
<Page title={t('landing.pageTitle')}>
|
||||
{errorMessage && (
|
||||
<Alert className="mb-3" variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
<AlertDescription>{errorMessage}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<div className="space-y-6 pb-20">
|
||||
<div className="text-center space-y-2">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Willkommen bei der Fotobox!</h1>
|
||||
<p className="text-lg text-gray-600">Dein Schluessel zu unvergesslichen Momenten.</p>
|
||||
<h1 className="text-2xl font-bold text-gray-900">{t('landing.headline')}</h1>
|
||||
<p className="text-lg text-gray-600">{t('landing.subheadline')}</p>
|
||||
</div>
|
||||
|
||||
<Card className="mx-auto w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-xl font-semibold">Event beitreten</CardTitle>
|
||||
<CardDescription>Scanne den QR-Code oder gib den Code manuell ein.</CardDescription>
|
||||
<CardTitle className="text-xl font-semibold">{t('landing.join.title')}</CardTitle>
|
||||
<CardDescription>{t('landing.join.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 p-6">
|
||||
<div className="flex flex-col items-center space-y-3">
|
||||
@@ -155,20 +160,20 @@ export default function LandingPage() {
|
||||
onClick={isScanning ? stopScanner : startScanner}
|
||||
disabled={loading}
|
||||
>
|
||||
{isScanning ? 'Scanner stoppen' : 'QR-Code scannen'}
|
||||
{isScanning ? t('landing.scan.stop') : t('landing.scan.start')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 py-2 text-center text-sm text-gray-500">
|
||||
Oder manuell eingeben
|
||||
{t('landing.scan.manualDivider')}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
value={eventCode}
|
||||
onChange={(event) => setEventCode(event.target.value)}
|
||||
placeholder="Event-Code eingeben"
|
||||
placeholder={t('landing.input.placeholder')}
|
||||
disabled={loading}
|
||||
/>
|
||||
<Button
|
||||
@@ -176,7 +181,7 @@ export default function LandingPage() {
|
||||
disabled={loading || !eventCode.trim()}
|
||||
onClick={() => join()}
|
||||
>
|
||||
{loading ? 'Pruefe...' : 'Event beitreten'}
|
||||
{loading ? t('landing.join.buttonLoading') : t('landing.join.button')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import React from 'react';
|
||||
import { Page } from './_util';
|
||||
import { useTranslation } from '../i18n/useTranslation';
|
||||
|
||||
export default function NotFoundPage() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Page title="Nicht gefunden">
|
||||
<p>Die Seite konnte nicht gefunden werden.</p>
|
||||
<Page title={t('notFound.title')}>
|
||||
<p>{t('notFound.description')}</p>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Dialog, DialogContent } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Heart, ChevronLeft, ChevronRight, X } from 'lucide-react';
|
||||
import { likePhoto } from '../services/photosApi';
|
||||
import { useTranslation } from '../i18n/useTranslation';
|
||||
|
||||
type Photo = {
|
||||
id: number;
|
||||
@@ -31,6 +32,7 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh
|
||||
const navigate = useNavigate();
|
||||
const photoId = params.photoId;
|
||||
const eventSlug = params.token || slug;
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [standalonePhoto, setStandalonePhoto] = useState<Photo | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -65,10 +67,10 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh
|
||||
setStandalonePhoto(location.state.photo);
|
||||
}
|
||||
} else {
|
||||
setError('Foto nicht gefunden');
|
||||
setError(t('lightbox.errors.notFound'));
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Fehler beim Laden des Fotos');
|
||||
setError(t('lightbox.errors.loadFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -78,7 +80,7 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh
|
||||
} else if (!isStandalone) {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [isStandalone, photoId, eventSlug, standalonePhoto, location.state]);
|
||||
}, [isStandalone, photoId, eventSlug, standalonePhoto, location.state, t]);
|
||||
|
||||
// Update likes when photo changes
|
||||
React.useEffect(() => {
|
||||
@@ -149,31 +151,31 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh
|
||||
if (foundTask) {
|
||||
setTask({
|
||||
id: foundTask.id,
|
||||
title: foundTask.title || `Aufgabe ${taskId}`
|
||||
title: foundTask.title || t('lightbox.fallbackTitle').replace('{id}', `${taskId}`)
|
||||
});
|
||||
} else {
|
||||
setTask({
|
||||
id: taskId,
|
||||
title: `Unbekannte Aufgabe ${taskId}`
|
||||
title: t('lightbox.unknownTitle').replace('{id}', `${taskId}`)
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setTask({
|
||||
id: taskId,
|
||||
title: `Unbekannte Aufgabe ${taskId}`
|
||||
title: t('lightbox.unknownTitle').replace('{id}', `${taskId}`)
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load task:', error);
|
||||
setTask({
|
||||
id: taskId,
|
||||
title: `Unbekannte Aufgabe ${taskId}`
|
||||
title: t('lightbox.unknownTitle').replace('{id}', `${taskId}`)
|
||||
});
|
||||
} finally {
|
||||
setTaskLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [photo?.task_id, eventSlug]);
|
||||
}, [photo?.task_id, eventSlug, t]);
|
||||
|
||||
async function onLike() {
|
||||
if (liked || !photo) return;
|
||||
@@ -251,9 +253,9 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh
|
||||
{task && (
|
||||
<div className="absolute bottom-4 left-4 right-4 z-20 bg-black/60 backdrop-blur-sm rounded-xl p-3 border border-white/20 max-w-md">
|
||||
<div className="text-sm">
|
||||
<div className="font-semibold mb-1 text-white">Task: {task.title}</div>
|
||||
<div className="font-semibold mb-1 text-white">{t('lightbox.taskLabel')}: {task.title}</div>
|
||||
{taskLoading && (
|
||||
<div className="text-xs opacity-70 text-gray-300">Lade Aufgabe...</div>
|
||||
<div className="text-xs opacity-70 text-gray-300">{t('lightbox.loadingTask')}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -269,7 +271,14 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh
|
||||
>
|
||||
<img
|
||||
src={photo?.file_path || photo?.thumbnail_path}
|
||||
alt={`Foto ${photo?.id || ''}${photo?.task_title ? ` - ${photo.task_title}` : ''}`}
|
||||
alt={t('lightbox.photoAlt')
|
||||
.replace('{id}', `${photo?.id ?? ''}`)
|
||||
.replace(
|
||||
'{suffix}',
|
||||
photo?.task_title
|
||||
? t('lightbox.photoAltTaskSuffix').replace('{taskTitle}', photo.task_title)
|
||||
: ''
|
||||
)}
|
||||
className="max-h-[80vh] max-w-full object-contain transition-transform duration-200"
|
||||
onError={(e) => {
|
||||
console.error('Image load error:', e);
|
||||
@@ -283,7 +292,7 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh
|
||||
<div className="absolute top-4 left-4 right-4 z-20 bg-black/60 backdrop-blur-sm rounded-xl p-3 border border-white/20 max-w-md">
|
||||
<div className="text-sm text-center">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mx-auto mb-1"></div>
|
||||
<div className="text-xs opacity-70">Lade Aufgabe...</div>
|
||||
<div className="text-xs opacity-70">{t('lightbox.loadingTask')}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import Header from '../components/Header';
|
||||
import { useTranslation } from '../i18n/useTranslation';
|
||||
|
||||
export default function ProfileSetupPage() {
|
||||
const { token } = useParams<{ token: string }>();
|
||||
@@ -15,6 +15,7 @@ export default function ProfileSetupPage() {
|
||||
const { name: storedName, setName: persistName, hydrated } = useGuestIdentity();
|
||||
const [name, setName] = useState(storedName);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
@@ -51,7 +52,7 @@ export default function ProfileSetupPage() {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-32">
|
||||
<div className="text-lg">Lade Event...</div>
|
||||
<div className="text-lg">{t('profileSetup.loading')}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -59,31 +60,30 @@ export default function ProfileSetupPage() {
|
||||
if (error || !event) {
|
||||
return (
|
||||
<div className="text-center p-4">
|
||||
<p className="text-red-600 mb-4">{error || 'Event nicht gefunden.'}</p>
|
||||
<Button onClick={() => nav('/')}>Zurück zur Startseite</Button>
|
||||
<p className="text-red-600 mb-4">{error || t('profileSetup.error.default')}</p>
|
||||
<Button onClick={() => nav('/')}>{t('profileSetup.error.backToStart')}</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-pink-50 to-purple-50 flex flex-col">
|
||||
<Header slug={token!} />
|
||||
<div className="flex-1 flex flex-col justify-center items-center px-4 py-8">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center space-y-2">
|
||||
<CardTitle className="text-2xl font-bold text-gray-900">{event.name}</CardTitle>
|
||||
<CardDescription className="text-lg text-gray-600">
|
||||
Fange den schoensten Moment ein!
|
||||
{t('profileSetup.card.description')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 p-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name" className="text-sm font-medium">Dein Name (z.B. Anna)</Label>
|
||||
<Label htmlFor="name" className="text-sm font-medium">{t('profileSetup.form.label')}</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
placeholder="Dein Name"
|
||||
placeholder={t('profileSetup.form.placeholder')}
|
||||
className="text-lg"
|
||||
disabled={submitting || !hydrated}
|
||||
autoComplete="name"
|
||||
@@ -94,7 +94,7 @@ export default function ProfileSetupPage() {
|
||||
onClick={submitName}
|
||||
disabled={submitting || !name.trim() || !hydrated}
|
||||
>
|
||||
{submitting ? 'Speichere...' : "Let's go!"}
|
||||
{submitting ? t('profileSetup.form.submitting') : t('profileSetup.form.submit')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
import React from 'react';
|
||||
import { Page } from './_util';
|
||||
import { useTranslation } from '../i18n/useTranslation';
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Page title="Einstellungen">
|
||||
<ul>
|
||||
<li>Sprache</li>
|
||||
<li>Theme</li>
|
||||
<li>Cache leeren</li>
|
||||
<li>Rechtliches</li>
|
||||
</ul>
|
||||
<Page title={t('settings.title')}>
|
||||
<p style={{ fontSize: 14 }}>{t('settings.subtitle')}</p>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
ZapOff,
|
||||
} from 'lucide-react';
|
||||
import { getEventPackage, type EventPackage } from '../services/eventApi';
|
||||
import { useTranslation } from '../i18n/useTranslation';
|
||||
|
||||
interface Task {
|
||||
id: number;
|
||||
@@ -62,6 +63,7 @@ export default function UploadPage() {
|
||||
const { appearance } = useAppearance();
|
||||
const isDarkMode = appearance === 'dark';
|
||||
const { markCompleted } = useGuestTaskProgress(slug);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const taskIdParam = searchParams.get('task');
|
||||
const emotionSlug = searchParams.get('emotion') || '';
|
||||
@@ -137,7 +139,7 @@ export default function UploadPage() {
|
||||
// Load task metadata
|
||||
useEffect(() => {
|
||||
if (!slug || !taskId) {
|
||||
setTaskError('Keine Aufgabeninformationen gefunden.');
|
||||
setTaskError(t('upload.loadError.title'));
|
||||
setLoadingTask(false);
|
||||
return;
|
||||
}
|
||||
@@ -145,6 +147,10 @@ export default function UploadPage() {
|
||||
let active = true;
|
||||
|
||||
async function loadTask() {
|
||||
const fallbackTitle = t('upload.taskInfo.fallbackTitle').replace('{id}', `${taskId!}`);
|
||||
const fallbackDescription = t('upload.taskInfo.fallbackDescription');
|
||||
const fallbackInstructions = t('upload.taskInfo.fallbackInstructions');
|
||||
|
||||
try {
|
||||
setLoadingTask(true);
|
||||
setTaskError(null);
|
||||
@@ -159,9 +165,9 @@ export default function UploadPage() {
|
||||
if (found) {
|
||||
setTask({
|
||||
id: found.id,
|
||||
title: found.title || `Aufgabe ${taskId!}`,
|
||||
description: found.description || 'Halte den Moment fest und teile ihn mit allen Gästen.',
|
||||
instructions: found.instructions,
|
||||
title: found.title || fallbackTitle,
|
||||
description: found.description || fallbackDescription,
|
||||
instructions: found.instructions ?? fallbackInstructions,
|
||||
duration: found.duration || 2,
|
||||
emotion: found.emotion,
|
||||
difficulty: found.difficulty ?? 'medium',
|
||||
@@ -169,9 +175,9 @@ export default function UploadPage() {
|
||||
} else {
|
||||
setTask({
|
||||
id: taskId!,
|
||||
title: `Aufgabe ${taskId!}`,
|
||||
description: 'Halte den Moment fest und teile ihn mit allen Gästen.',
|
||||
instructions: 'Positioniere alle im Bild, starte den Countdown und lass die Emotion wirken.',
|
||||
title: fallbackTitle,
|
||||
description: fallbackDescription,
|
||||
instructions: fallbackInstructions,
|
||||
duration: 2,
|
||||
emotion: emotionSlug
|
||||
? { slug: emotionSlug, name: emotionSlug.replace('-', ' ').replace(/\b\w/g, (l) => l.toUpperCase()) }
|
||||
@@ -182,12 +188,12 @@ export default function UploadPage() {
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch task', error);
|
||||
if (active) {
|
||||
setTaskError('Aufgabe konnte nicht geladen werden. Du kannst trotzdem ein Foto machen.');
|
||||
setTaskError(t('upload.loadError.title'));
|
||||
setTask({
|
||||
id: taskId!,
|
||||
title: `Aufgabe ${taskId!}`,
|
||||
description: 'Halte den Moment fest und teile ihn mit allen Gästen.',
|
||||
instructions: 'Positioniere alle im Bild, starte den Countdown und lass die Emotion wirken.',
|
||||
title: fallbackTitle,
|
||||
description: fallbackDescription,
|
||||
instructions: fallbackInstructions,
|
||||
duration: 2,
|
||||
emotion: emotionSlug
|
||||
? { slug: emotionSlug, name: emotionSlug.replace('-', ' ').replace(/\b\w/g, (l) => l.toUpperCase()) }
|
||||
@@ -204,7 +210,7 @@ export default function UploadPage() {
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [eventKey, taskId, emotionSlug]);
|
||||
}, [eventKey, taskId, emotionSlug, t]);
|
||||
|
||||
// Check upload limits
|
||||
useEffect(() => {
|
||||
@@ -216,19 +222,27 @@ export default function UploadPage() {
|
||||
setEventPackage(pkg);
|
||||
if (pkg && pkg.used_photos >= pkg.package.max_photos) {
|
||||
setCanUpload(false);
|
||||
setUploadError('Upload-Limit erreicht. Kontaktieren Sie den Organisator für ein Upgrade.');
|
||||
const maxLabel = pkg.package.max_photos == null
|
||||
? t('upload.limitUnlimited')
|
||||
: `${pkg.package.max_photos}`;
|
||||
setUploadError(
|
||||
t('upload.limitReached')
|
||||
.replace('{used}', `${pkg.used_photos}`)
|
||||
.replace('{max}', maxLabel)
|
||||
);
|
||||
} else {
|
||||
setCanUpload(true);
|
||||
setUploadError(null);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to check package limits', err);
|
||||
setCanUpload(false);
|
||||
setUploadError('Fehler beim Prüfen des Limits. Upload deaktiviert.');
|
||||
setUploadError(t('upload.limitCheckError'));
|
||||
}
|
||||
};
|
||||
|
||||
checkLimits();
|
||||
}, [eventKey, task]);
|
||||
}, [eventKey, task, t]);
|
||||
|
||||
const stopStream = useCallback(() => {
|
||||
if (streamRef.current) {
|
||||
@@ -265,7 +279,7 @@ export default function UploadPage() {
|
||||
const startCamera = useCallback(async () => {
|
||||
if (!supportsCamera) {
|
||||
setPermissionState('unsupported');
|
||||
setPermissionMessage('Dieses Gerät oder der Browser unterstützt keine Kamera-Zugriffe.');
|
||||
setPermissionMessage(t('upload.cameraUnsupported.message'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -286,18 +300,16 @@ export default function UploadPage() {
|
||||
|
||||
if (error?.name === 'NotAllowedError') {
|
||||
setPermissionState('denied');
|
||||
setPermissionMessage(
|
||||
'Kamera-Zugriff wurde blockiert. Prüfe die Berechtigungen deines Browsers und versuche es erneut.'
|
||||
);
|
||||
setPermissionMessage(t('upload.cameraDenied.explanation'));
|
||||
} else if (error?.name === 'NotFoundError') {
|
||||
setPermissionState('error');
|
||||
setPermissionMessage('Keine Kamera gefunden. Du kannst stattdessen ein Foto aus deiner Galerie wählen.');
|
||||
setPermissionMessage(t('upload.cameraUnsupported.message'));
|
||||
} else {
|
||||
setPermissionState('error');
|
||||
setPermissionMessage(`Kamera konnte nicht gestartet werden: ${error?.message || 'Unbekannter Fehler'}`);
|
||||
setPermissionMessage(t('upload.cameraError.explanation'));
|
||||
}
|
||||
}
|
||||
}, [attachStreamToVideo, createConstraint, mode, preferences.facingMode, stopStream, supportsCamera, task]);
|
||||
}, [attachStreamToVideo, createConstraint, mode, preferences.facingMode, stopStream, supportsCamera, task, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!task || loadingTask) return;
|
||||
@@ -311,15 +323,15 @@ export default function UploadPage() {
|
||||
useEffect(() => {
|
||||
if (!liveRegionRef.current) return;
|
||||
if (mode === 'countdown') {
|
||||
liveRegionRef.current.textContent = `Foto wird in ${countdownValue} Sekunden aufgenommen.`;
|
||||
liveRegionRef.current.textContent = t('upload.countdown.ready').replace('{count}', `${countdownValue}`);
|
||||
} else if (mode === 'review') {
|
||||
liveRegionRef.current.textContent = 'Foto aufgenommen. <20>berpr<70>fe die Vorschau.';
|
||||
liveRegionRef.current.textContent = t('upload.review.readyAnnouncement');
|
||||
} else if (mode === 'uploading') {
|
||||
liveRegionRef.current.textContent = 'Foto wird hochgeladen.';
|
||||
liveRegionRef.current.textContent = t('upload.status.uploading');
|
||||
} else {
|
||||
liveRegionRef.current.textContent = '';
|
||||
}
|
||||
}, [mode, countdownValue]);
|
||||
}, [mode, countdownValue, t]);
|
||||
|
||||
const dismissPrimer = useCallback(() => {
|
||||
setShowPrimer(false);
|
||||
@@ -360,7 +372,7 @@ export default function UploadPage() {
|
||||
|
||||
const performCapture = useCallback(() => {
|
||||
if (!videoRef.current || !canvasRef.current) {
|
||||
setUploadError('Kamera nicht bereit. Bitte versuche es erneut.');
|
||||
setUploadError(t('upload.captureError'));
|
||||
setMode('preview');
|
||||
return;
|
||||
}
|
||||
@@ -371,7 +383,7 @@ export default function UploadPage() {
|
||||
const height = video.videoHeight;
|
||||
|
||||
if (!width || !height) {
|
||||
setUploadError('Kamera liefert kein Bild. Bitte starte die Kamera neu.');
|
||||
setUploadError(t('upload.feedError'));
|
||||
setMode('preview');
|
||||
startCamera();
|
||||
return;
|
||||
@@ -381,7 +393,7 @@ export default function UploadPage() {
|
||||
canvas.height = height;
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
setUploadError('Canvas konnte nicht initialisiert werden.');
|
||||
setUploadError(t('upload.canvasError'));
|
||||
setMode('preview');
|
||||
return;
|
||||
}
|
||||
@@ -399,7 +411,7 @@ export default function UploadPage() {
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (!blob) {
|
||||
setUploadError('Foto konnte nicht erstellt werden.');
|
||||
setUploadError(t('upload.captureError'));
|
||||
setMode('preview');
|
||||
return;
|
||||
}
|
||||
@@ -413,7 +425,7 @@ export default function UploadPage() {
|
||||
'image/jpeg',
|
||||
0.92
|
||||
);
|
||||
}, [preferences.facingMode, preferences.mirrorFrontPreview, startCamera]);
|
||||
}, [preferences.facingMode, preferences.mirrorFrontPreview, startCamera, t]);
|
||||
|
||||
const beginCapture = useCallback(() => {
|
||||
setUploadError(null);
|
||||
@@ -461,7 +473,7 @@ export default function UploadPage() {
|
||||
setMode('uploading');
|
||||
setUploadProgress(5);
|
||||
setUploadError(null);
|
||||
setStatusMessage('Foto wird vorbereitet...');
|
||||
setStatusMessage(t('upload.status.preparing'));
|
||||
|
||||
if (uploadProgressTimerRef.current) {
|
||||
window.clearInterval(uploadProgressTimerRef.current);
|
||||
@@ -473,13 +485,13 @@ export default function UploadPage() {
|
||||
try {
|
||||
const photoId = await uploadPhoto(eventKey, reviewPhoto.file, task.id, emotionSlug || undefined);
|
||||
setUploadProgress(100);
|
||||
setStatusMessage('Upload abgeschlossen.');
|
||||
setStatusMessage(t('upload.status.completed'));
|
||||
markCompleted(task.id);
|
||||
stopStream();
|
||||
navigateAfterUpload(photoId);
|
||||
} catch (error: any) {
|
||||
console.error('Upload failed', error);
|
||||
setUploadError(error?.message || 'Upload fehlgeschlagen. Bitte versuche es erneut.');
|
||||
setUploadError(error?.message || t('upload.status.failed'));
|
||||
setMode('review');
|
||||
} finally {
|
||||
if (uploadProgressTimerRef.current) {
|
||||
@@ -488,7 +500,7 @@ export default function UploadPage() {
|
||||
}
|
||||
setStatusMessage('');
|
||||
}
|
||||
}, [emotionSlug, markCompleted, navigateAfterUpload, reviewPhoto, eventKey, stopStream, task, canUpload]);
|
||||
}, [emotionSlug, markCompleted, navigateAfterUpload, reviewPhoto, eventKey, stopStream, task, canUpload, t]);
|
||||
|
||||
const handleGalleryPick = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!canUpload) return;
|
||||
@@ -501,10 +513,10 @@ export default function UploadPage() {
|
||||
setMode('review');
|
||||
};
|
||||
reader.onerror = () => {
|
||||
setUploadError('Auswahl fehlgeschlagen. Bitte versuche es erneut.');
|
||||
setUploadError(t('upload.galleryPickError'));
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}, [canUpload]);
|
||||
}, [canUpload, t]);
|
||||
|
||||
const difficultyBadgeClass = useMemo(() => {
|
||||
if (!task) return 'text-white';
|
||||
@@ -533,12 +545,10 @@ export default function UploadPage() {
|
||||
if (!supportsCamera && !task) {
|
||||
return (
|
||||
<div className="pb-16">
|
||||
<Header slug={eventKey} title="Kamera" />
|
||||
<Header slug={eventKey} title={t('upload.cameraTitle')} />
|
||||
<main className="px-4 py-6">
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
Dieses Gerät unterstützt keine Kamera-Zugriffe. Du kannst stattdessen Fotos aus deiner Galerie hochladen.
|
||||
</AlertDescription>
|
||||
<AlertDescription>{t('upload.cameraUnsupported.message')}</AlertDescription>
|
||||
</Alert>
|
||||
</main>
|
||||
<BottomNav />
|
||||
@@ -549,10 +559,10 @@ export default function UploadPage() {
|
||||
if (loadingTask) {
|
||||
return (
|
||||
<div className="pb-16">
|
||||
<Header slug={eventKey} title="Kamera" />
|
||||
<Header slug={eventKey} title={t('upload.cameraTitle')} />
|
||||
<main className="px-4 py-6 flex flex-col items-center justify-center text-center">
|
||||
<Loader2 className="h-10 w-10 animate-spin text-pink-500 mb-4" />
|
||||
<p className="text-sm text-muted-foreground">Aufgabe und Kamera werden vorbereitet ...</p>
|
||||
<p className="text-sm text-muted-foreground">{t('upload.preparing')}</p>
|
||||
</main>
|
||||
<BottomNav />
|
||||
</div>
|
||||
@@ -562,13 +572,14 @@ export default function UploadPage() {
|
||||
if (!canUpload) {
|
||||
return (
|
||||
<div className="pb-16">
|
||||
<Header slug={eventKey} title="Kamera" />
|
||||
<Header slug={eventKey} title={t('upload.cameraTitle')} />
|
||||
<main className="px-4 py-6">
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Upload-Limit erreicht ({eventPackage?.used_photos || 0} / {eventPackage?.package.max_photos || 0} Fotos).
|
||||
Kontaktieren Sie den Organisator für ein Package-Upgrade.
|
||||
{t('upload.limitReached')
|
||||
.replace('{used}', `${eventPackage?.used_photos || 0}`)
|
||||
.replace('{max}', `${eventPackage?.package.max_photos || 0}`)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</main>
|
||||
@@ -583,13 +594,14 @@ export default function UploadPage() {
|
||||
<div className="flex items-start gap-3">
|
||||
<Info className="mt-0.5 h-5 w-5 flex-shrink-0" />
|
||||
<div className="text-left">
|
||||
<p className="font-semibold">Bereit für dein Shooting?</p>
|
||||
<p className="font-semibold">{t('upload.primer.title')}</p>
|
||||
<p className="mt-1">
|
||||
Suche dir gutes Licht, halte die Stimmung der Aufgabe fest und nutze die Kontrollleiste für Countdown, Grid und Kamerawechsel.
|
||||
{t('upload.primer.body.part1')}{' '}
|
||||
{t('upload.primer.body.part2')}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={dismissPrimer}>
|
||||
Alles klar
|
||||
{t('upload.primer.dismiss')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -601,9 +613,7 @@ export default function UploadPage() {
|
||||
if (permissionState === 'unsupported') {
|
||||
return (
|
||||
<Alert className="mx-4">
|
||||
<AlertDescription>
|
||||
Dieses Gerät unterstützt keine Kamera. Nutze den Button `Foto aus Galerie wählen`, um dennoch teilzunehmen.
|
||||
</AlertDescription>
|
||||
<AlertDescription>{t('upload.cameraUnsupported.message')}</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -613,7 +623,7 @@ export default function UploadPage() {
|
||||
<AlertDescription className="space-y-3">
|
||||
<div>{permissionMessage}</div>
|
||||
<Button size="sm" variant="outline" onClick={startCamera}>
|
||||
Erneut versuchen
|
||||
{t('upload.buttons.tryAgain')}
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
@@ -621,16 +631,14 @@ export default function UploadPage() {
|
||||
}
|
||||
return (
|
||||
<Alert className="mx-4">
|
||||
<AlertDescription>
|
||||
Wir benötigen Zugriff auf deine Kamera. Bestätige die Browser-Abfrage oder nutze alternativ ein Foto aus deiner Galerie.
|
||||
</AlertDescription>
|
||||
<AlertDescription>{t('upload.cameraDenied.prompt')}</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pb-16">
|
||||
<Header slug={eventKey} title="Kamera" />
|
||||
<Header slug={eventKey} title={t('upload.cameraTitle')} />
|
||||
<main className="relative flex flex-col gap-4 pb-4">
|
||||
<div className="absolute left-0 right-0 top-0" aria-hidden="true">
|
||||
{renderPrimer()}
|
||||
@@ -666,14 +674,17 @@ export default function UploadPage() {
|
||||
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center bg-black/70 text-center text-sm">
|
||||
<Camera className="mb-3 h-8 w-8 text-pink-400" />
|
||||
<p className="max-w-xs text-white/90">
|
||||
Kamera ist nicht aktiv. {permissionMessage || 'Tippe auf `Kamera starten`, um loszulegen.'}
|
||||
{t('upload.cameraInactive').replace(
|
||||
'{hint}',
|
||||
(permissionMessage ?? t('upload.cameraInactiveHint').replace('{label}', t('upload.buttons.startCamera')))
|
||||
)}
|
||||
</p>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<Button size="sm" onClick={startCamera}>
|
||||
Kamera starten
|
||||
{t('upload.buttons.startCamera')}
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => fileInputRef.current?.click()}>
|
||||
Foto aus Galerie wählen
|
||||
{t('upload.galleryButton')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -684,14 +695,10 @@ export default function UploadPage() {
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Badge variant="secondary" className="flex items-center gap-2 text-xs">
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
Aufgabe #{task.id}
|
||||
{t('upload.taskInfo.badge').replace('{id}', `${task.id}`)}
|
||||
</Badge>
|
||||
<span className={cn('text-xs font-medium uppercase tracking-wide', difficultyBadgeClass)}>
|
||||
{task.difficulty === 'easy'
|
||||
? 'Leicht'
|
||||
: task.difficulty === 'hard'
|
||||
? 'Herausfordernd'
|
||||
: 'Medium'}
|
||||
{t(`upload.taskInfo.difficulty.${task.difficulty ?? 'medium'}`)}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
@@ -699,13 +706,19 @@ export default function UploadPage() {
|
||||
<p className="mt-1 text-xs leading-relaxed text-white/80">{task.description}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-[11px] text-white/70">
|
||||
{task.instructions && <span>Hinweis: {task.instructions}</span>}
|
||||
{task.instructions && (
|
||||
<span>
|
||||
{t('upload.taskInfo.instructionsPrefix')}: {task.instructions}
|
||||
</span>
|
||||
)}
|
||||
{emotionSlug && (
|
||||
<span className="rounded-full border border-white/20 px-2 py-0.5">Stimmung: {task.emotion?.name || emotionSlug}</span>
|
||||
<span className="rounded-full border border-white/20 px-2 py-0.5">
|
||||
{t('upload.taskInfo.emotion').replace('{value}', `${task.emotion?.name || emotionSlug}`)}
|
||||
</span>
|
||||
)}
|
||||
{preferences.countdownEnabled && (
|
||||
<span className="rounded-full border border-white/20 px-2 py-0.5">
|
||||
Countdown: {preferences.countdownSeconds}s
|
||||
{t('upload.countdownLabel').replace('{seconds}', `${preferences.countdownSeconds}`)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -715,7 +728,7 @@ export default function UploadPage() {
|
||||
{mode === 'countdown' && (
|
||||
<div className="absolute inset-0 z-40 flex flex-col items-center justify-center bg-black/60 text-white">
|
||||
<div className="text-6xl font-bold">{countdownValue}</div>
|
||||
<p className="mt-2 text-sm text-white/70">Bereit machen ...</p>
|
||||
<p className="mt-2 text-sm text-white/70">{t('upload.countdownReady')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -760,7 +773,7 @@ export default function UploadPage() {
|
||||
onClick={handleToggleGrid}
|
||||
>
|
||||
<Grid3X3 className="h-5 w-5" />
|
||||
<span className="sr-only">Raster umschalten</span>
|
||||
<span className="sr-only">{t('upload.controls.toggleGrid')}</span>
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
@@ -769,7 +782,7 @@ export default function UploadPage() {
|
||||
onClick={handleToggleCountdown}
|
||||
>
|
||||
<span className="text-sm font-semibold">{preferences.countdownSeconds}s</span>
|
||||
<span className="sr-only">Countdown umschalten</span>
|
||||
<span className="sr-only">{t('upload.controls.toggleCountdown')}</span>
|
||||
</Button>
|
||||
{preferences.facingMode === 'user' && (
|
||||
<Button
|
||||
@@ -779,7 +792,7 @@ export default function UploadPage() {
|
||||
onClick={handleToggleMirror}
|
||||
>
|
||||
<span className="text-sm font-semibold">?</span>
|
||||
<span className="sr-only">Spiegelung für Frontkamera umschalten</span>
|
||||
<span className="sr-only">{t('upload.controls.toggleMirror')}</span>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
@@ -790,7 +803,7 @@ export default function UploadPage() {
|
||||
disabled={preferences.facingMode !== 'environment'}
|
||||
>
|
||||
{preferences.flashPreferred ? <Zap className="h-5 w-5 text-yellow-300" /> : <ZapOff className="h-5 w-5" />}
|
||||
<span className="sr-only">Blitzpräferenz umschalten</span>
|
||||
<span className="sr-only">{t('upload.controls.toggleFlash')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -802,7 +815,7 @@ export default function UploadPage() {
|
||||
onClick={handleSwitchCamera}
|
||||
>
|
||||
<RotateCcw className="mr-1 h-4 w-4" />
|
||||
Kamera wechseln
|
||||
{t('upload.switchCamera')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
@@ -811,7 +824,7 @@ export default function UploadPage() {
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<ImagePlus className="mr-1 h-4 w-4" />
|
||||
Foto aus Galerie
|
||||
{t('upload.galleryButton')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -820,10 +833,10 @@ export default function UploadPage() {
|
||||
{mode === 'review' && reviewPhoto ? (
|
||||
<div className="flex w-full max-w-md flex-col gap-3 sm:flex-row">
|
||||
<Button variant="secondary" className="flex-1" onClick={handleRetake}>
|
||||
Noch einmal
|
||||
{t('upload.review.retake')}
|
||||
</Button>
|
||||
<Button className="flex-1" onClick={handleUsePhoto}>
|
||||
Foto verwenden
|
||||
{t('upload.review.keep')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
@@ -834,7 +847,7 @@ export default function UploadPage() {
|
||||
disabled={!isCameraActive || mode === 'countdown'}
|
||||
>
|
||||
<Camera className="h-7 w-7" />
|
||||
<span className="sr-only">Foto aufnehmen</span>
|
||||
<span className="sr-only">{t('upload.captureButton')}</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import React from 'react';
|
||||
import { Page } from './_util';
|
||||
import { useTranslation } from '../i18n/useTranslation';
|
||||
|
||||
export default function UploadQueuePage() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Page title="Uploads">
|
||||
<p>Queue with progress/retry; background sync toggle.</p>
|
||||
<Page title={t('uploadQueue.title')}>
|
||||
<p>{t('uploadQueue.description')}</p>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,9 @@ import SlideshowPage from './pages/SlideshowPage';
|
||||
import SettingsPage from './pages/SettingsPage';
|
||||
import LegalPage from './pages/LegalPage';
|
||||
import NotFoundPage from './pages/NotFoundPage';
|
||||
import { LocaleProvider } from './i18n/LocaleContext';
|
||||
import { DEFAULT_LOCALE, isLocaleCode } from './i18n/messages';
|
||||
import { useTranslation, type TranslateFn } from './i18n/useTranslation';
|
||||
|
||||
function HomeLayout() {
|
||||
const { token } = useParams();
|
||||
@@ -85,7 +88,11 @@ function EventBoundary({ token }: { token: string }) {
|
||||
return <EventErrorView code={errorCode} message={error} />;
|
||||
}
|
||||
|
||||
const eventLocale = isLocaleCode(event.default_locale) ? event.default_locale : DEFAULT_LOCALE;
|
||||
const localeStorageKey = `guestLocale_event_${event.id ?? token}`;
|
||||
|
||||
return (
|
||||
<LocaleProvider defaultLocale={eventLocale} storageKey={localeStorageKey}>
|
||||
<EventStatsProvider eventKey={token}>
|
||||
<div className="pb-16">
|
||||
<Header slug={token} />
|
||||
@@ -95,31 +102,38 @@ function EventBoundary({ token }: { token: string }) {
|
||||
<BottomNav />
|
||||
</div>
|
||||
</EventStatsProvider>
|
||||
</LocaleProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function SetupLayout() {
|
||||
const { token } = useParams<{ token: string }>();
|
||||
if (!token) return null;
|
||||
const { event } = useEventData();
|
||||
const eventLocale = event && isLocaleCode(event.default_locale) ? event.default_locale : DEFAULT_LOCALE;
|
||||
const localeStorageKey = event ? `guestLocale_event_${event.id}` : `guestLocale_event_${token}`;
|
||||
return (
|
||||
<GuestIdentityProvider eventKey={token}>
|
||||
<LocaleProvider defaultLocale={eventLocale} storageKey={localeStorageKey}>
|
||||
<EventStatsProvider eventKey={token}>
|
||||
<div className="pb-0">
|
||||
<Header slug={token} />
|
||||
<Outlet />
|
||||
</div>
|
||||
</EventStatsProvider>
|
||||
</LocaleProvider>
|
||||
</GuestIdentityProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function EventLoadingView() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center gap-4 px-6 text-center">
|
||||
<Loader2 className="h-10 w-10 animate-spin text-muted-foreground" aria-hidden />
|
||||
<div className="space-y-1">
|
||||
<p className="text-lg font-semibold text-foreground">Wir prüfen deinen Zugang...</p>
|
||||
<p className="text-sm text-muted-foreground">Einen Moment bitte.</p>
|
||||
<p className="text-lg font-semibold text-foreground">{t('eventAccess.loading.title')}</p>
|
||||
<p className="text-sm text-muted-foreground">{t('eventAccess.loading.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -131,7 +145,8 @@ interface EventErrorViewProps {
|
||||
}
|
||||
|
||||
function EventErrorView({ code, message }: EventErrorViewProps) {
|
||||
const content = getErrorContent(code, message);
|
||||
const { t } = useTranslation();
|
||||
const content = getErrorContent(t, code, message);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center gap-6 px-6 text-center">
|
||||
@@ -155,50 +170,39 @@ function EventErrorView({ code, message }: EventErrorViewProps) {
|
||||
}
|
||||
|
||||
function getErrorContent(
|
||||
t: TranslateFn,
|
||||
code: FetchEventErrorCode | null,
|
||||
message: string | null,
|
||||
) {
|
||||
const base = (fallbackTitle: string, fallbackDescription: string, options?: { ctaLabel?: string; ctaHref?: string; hint?: string }) => ({
|
||||
title: fallbackTitle,
|
||||
description: message ?? fallbackDescription,
|
||||
ctaLabel: options?.ctaLabel,
|
||||
const build = (key: string, options?: { ctaHref?: string }) => {
|
||||
const ctaLabel = t(`eventAccess.error.${key}.ctaLabel`, '');
|
||||
const hint = t(`eventAccess.error.${key}.hint`, '');
|
||||
return {
|
||||
title: t(`eventAccess.error.${key}.title`),
|
||||
description: message ?? t(`eventAccess.error.${key}.description`),
|
||||
ctaLabel: ctaLabel.trim().length > 0 ? ctaLabel : undefined,
|
||||
ctaHref: options?.ctaHref,
|
||||
hint: options?.hint ?? null,
|
||||
});
|
||||
hint: hint.trim().length > 0 ? hint : null,
|
||||
};
|
||||
};
|
||||
|
||||
switch (code) {
|
||||
case 'invalid_token':
|
||||
return base('Zugriffscode ungültig', 'Der eingegebene Code konnte nicht verifiziert werden.', {
|
||||
ctaLabel: 'Neuen Code anfordern',
|
||||
ctaHref: '/event',
|
||||
});
|
||||
return build('invalid_token', { ctaHref: '/event' });
|
||||
case 'token_revoked':
|
||||
return base('Zugriffscode deaktiviert', 'Dieser Code wurde zurückgezogen. Bitte fordere einen neuen Code an.', {
|
||||
ctaLabel: 'Neuen Code anfordern',
|
||||
ctaHref: '/event',
|
||||
});
|
||||
return build('token_revoked', { ctaHref: '/event' });
|
||||
case 'token_expired':
|
||||
return base('Zugriffscode abgelaufen', 'Der Code ist nicht mehr gültig. Aktualisiere deinen Code, um fortzufahren.', {
|
||||
ctaLabel: 'Code aktualisieren',
|
||||
ctaHref: '/event',
|
||||
});
|
||||
return build('token_expired', { ctaHref: '/event' });
|
||||
case 'token_rate_limited':
|
||||
return base('Zu viele Versuche', 'Es gab zu viele Eingaben in kurzer Zeit. Warte kurz und versuche es erneut.', {
|
||||
hint: 'Tipp: Eine erneute Eingabe ist in wenigen Minuten möglich.',
|
||||
});
|
||||
return build('token_rate_limited');
|
||||
case 'event_not_public':
|
||||
return base('Event nicht öffentlich', 'Dieses Event ist aktuell nicht öffentlich zugänglich.', {
|
||||
hint: 'Nimm Kontakt mit den Veranstalter:innen auf, um Zugang zu erhalten.',
|
||||
});
|
||||
return build('event_not_public');
|
||||
case 'network_error':
|
||||
return base('Verbindungsproblem', 'Wir konnten keine Verbindung zum Server herstellen. Prüfe deine Internetverbindung und versuche es erneut.');
|
||||
return build('network_error');
|
||||
case 'server_error':
|
||||
return base('Server nicht erreichbar', 'Der Server reagiert derzeit nicht. Versuche es später erneut.');
|
||||
return build('server_error');
|
||||
default:
|
||||
return base('Event nicht erreichbar', 'Wir konnten dein Event nicht laden. Bitte versuche es erneut.', {
|
||||
ctaLabel: 'Zur Code-Eingabe',
|
||||
ctaHref: '/event',
|
||||
});
|
||||
return build('default', { ctaHref: '/event' });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,15 +14,20 @@ const Footer: React.FC = () => {
|
||||
return (
|
||||
<footer className="bg-white border-t border-gray-200 mt-auto">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
|
||||
<div className="col-span-1 md:col-span-2">
|
||||
<Link href="/marketing" className="text-2xl font-bold font-display text-pink-500">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<div>
|
||||
<div className="flex items-center gap-4">
|
||||
<img src="logo-transparent-md.png" alt="FotoSpiel.App Logo" className="h-12 w-auto" />
|
||||
<div>
|
||||
<Link href="/" className="text-2xl font-bold font-display text-pink-500">
|
||||
FotoSpiel.App
|
||||
</Link>
|
||||
<p className="text-gray-600 font-sans-marketing mt-2">
|
||||
Deine Plattform für Event-Fotos.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold font-display text-gray-900 mb-4">Rechtliches</h3>
|
||||
|
||||
@@ -118,8 +118,11 @@ const Header: React.FC = () => {
|
||||
<header className="fixed top-0 z-50 w-full bg-white dark:bg-gray-900 shadow-lg border-b-2 border-gray-200 dark:border-gray-700">
|
||||
<div className="container mx-auto px-4 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Link href={localizedPath('/')} className="text-2xl font-bold text-gray-800 dark:text-gray-200">
|
||||
Die Fotospiel.App
|
||||
<Link href={localizedPath('/')} className="flex items-center gap-4">
|
||||
<img src="logo-transparent-md.png" alt="FotoSpiel.App Logo" className="h-12 w-auto" />
|
||||
<span className="text-2xl font-bold font-display text-pink-500">
|
||||
FotoSpiel.App
|
||||
</span>
|
||||
</Link>
|
||||
<NavigationMenu className="hidden lg:flex flex-1 justify-center" viewport={false}>
|
||||
<NavigationMenuList className="gap-2">
|
||||
@@ -246,6 +249,18 @@ const Header: React.FC = () => {
|
||||
<SheetHeader className="text-left">
|
||||
<SheetTitle className="text-xl font-semibold">Menü</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">Sprache</span>
|
||||
<Select value={i18n.language || 'de'} onValueChange={handleLanguageChange}>
|
||||
<SelectTrigger className="h-10 w-full">
|
||||
<SelectValue placeholder={t('common.ui.language_select')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="de">Deutsch</SelectItem>
|
||||
<SelectItem value="en">English</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<nav className="flex flex-col gap-2">
|
||||
{navItems.map((item) => (
|
||||
item.children ? (
|
||||
@@ -303,18 +318,6 @@ const Header: React.FC = () => {
|
||||
<span className="sr-only">Theme Toggle</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">Sprache</span>
|
||||
<Select value={i18n.language || 'de'} onValueChange={handleLanguageChange}>
|
||||
<SelectTrigger className="h-10 w-full">
|
||||
<SelectValue placeholder={t('common.ui.language_select')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="de">Deutsch</SelectItem>
|
||||
<SelectItem value="en">English</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
{auth.user ? (
|
||||
<>
|
||||
|
||||
@@ -34,6 +34,19 @@
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
</head>
|
||||
<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>
|
||||
@yield('content')
|
||||
|
||||