Files
fotospiel-app/resources/js/guest/i18n/messages.ts
Codex Agent ae9b9160ac - Added public gallery API with token-expiry enforcement, branding payload, cursor pagination, and per-photo download stream (app/Http/Controllers/Api/EventPublicController.php:1, routes/api.php:16). 410 is returned when the package gallery duration has lapsed.
- Served the guest PWA at /g/{token} and introduced a mobile-friendly gallery page with lazy-loaded thumbnails, themed colors, lightbox, and download links plus new gallery data client (resources/js/guest/pages/PublicGalleryPage.tsx:1, resources/js/guest/services/galleryApi.ts:1, resources/js/guest/router.tsx:1). Added i18n strings for the public gallery experience (resources/js/guest/i18n/messages.ts:1).
- Ensured checkout step changes snap back to the progress bar on mobile via smooth scroll anchoring (resources/ js/pages/marketing/checkout/CheckoutWizard.tsx:1).
- Enabled tenant admins to export all approved event photos through a new download action that streams a ZIP archive, with translations and routing in place (app/Http/Controllers/Tenant/EventPhotoArchiveController.php:1, app/Filament/Resources/EventResource.php:1, routes/web.php:1, resources/lang/de/admin.php:1, resources/lang/en/admin.php:1).
2025-10-17 23:24:06 +02:00

726 lines
25 KiB
TypeScript

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.',
},
galleryPublic: {
title: 'Galerie',
loading: 'Galerie wird geladen ...',
loadingMore: 'Weitere Fotos werden geladen',
loadError: 'Die Galerie konnte nicht geladen werden.',
loadMore: 'Mehr anzeigen',
download: 'Herunterladen',
expiredTitle: 'Galerie nicht verfügbar',
expiredDescription: 'Die Galerie für dieses Event ist abgelaufen.',
emptyTitle: 'Noch keine Fotos',
emptyDescription: 'Sobald Fotos freigegeben sind, erscheinen sie hier.',
lightboxGuestFallback: 'Gast',
},
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.',
},
galleryPublic: {
title: 'Gallery',
loading: 'Loading gallery ...',
loadingMore: 'Loading more photos',
loadError: 'The gallery could not be loaded.',
loadMore: 'Show more',
download: 'Download',
expiredTitle: 'Gallery unavailable',
expiredDescription: 'The gallery for this event has expired.',
emptyTitle: 'No photos yet',
emptyDescription: 'Once photos are approved they will appear here.',
lightboxGuestFallback: 'Guest',
},
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);
}