feat: extend event toolkit and polish guest pwa

This commit is contained in:
Codex Agent
2025-10-28 18:28:22 +01:00
parent f29067f570
commit a7bbf230fd
45 changed files with 3809 additions and 351 deletions

View File

@@ -54,7 +54,7 @@ export default function BottomNav() {
const isGalleryActive = currentPath.startsWith(`${base}/gallery`) || currentPath.startsWith(`${base}/photos`);
return (
<div className="fixed inset-x-0 bottom-0 z-30 border-t border-white/20 bg-black/30 px-2 py-2 backdrop-blur-xl shadow-xl dark:bg-black/40 dark:border-gray-800/50">
<div className="fixed inset-x-0 bottom-0 z-30 border-t border-white/30 bg-white/70 px-2 py-2 shadow-xl backdrop-blur-xl dark:border-white/10 dark:bg-gradient-to-t dark:from-gray-950/85 dark:via-gray-900/70 dark:to-gray-900/35 dark:backdrop-blur-2xl dark:shadow-[0_18px_60px_rgba(15,15,30,0.6)]">
<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">

View File

@@ -16,8 +16,8 @@ export const messages: Record<LocaleCode, NestedMessages> = {
common: {
hi: 'Hi',
actions: {
close: 'Schliessen',
loading: 'Laedt...',
close: 'Schließen',
loading: 'Lädt...',
},
},
navigation: {
@@ -30,34 +30,34 @@ export const messages: Record<LocaleCode, NestedMessages> = {
loading: 'Lade Event...',
stats: {
online: 'online',
tasksSolved: 'Aufgaben geloest',
tasksSolved: 'Aufgaben gelöst',
},
},
eventAccess: {
loading: {
title: 'Wir pruefen deinen Zugang...',
title: 'Wir prüfen deinen Zugang...',
subtitle: 'Einen Moment bitte.',
},
error: {
invalid_token: {
title: 'Zugriffscode ungueltig',
title: 'Zugriffscode ungültig',
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.',
description: 'Dieser Code wurde zurückgezogen. 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.',
description: 'Der Code ist nicht mehr gültig. 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.',
hint: 'Tipp: Eine erneute Eingabe ist in wenigen Minuten möglich.',
},
access_rate_limited: {
title: 'Zu viele Aufrufe',
@@ -65,22 +65,22 @@ export const messages: Record<LocaleCode, NestedMessages> = {
hint: 'Tipp: Du kannst es gleich noch einmal versuchen.',
},
gallery_expired: {
title: 'Galerie nicht mehr verfuegbar',
description: 'Die Galerie zu diesem Event ist nicht mehr zugaenglich.',
title: 'Galerie nicht mehr verfügbar',
description: 'Die Galerie zu diesem Event ist nicht mehr zugänglich.',
ctaLabel: 'Neuen Code anfordern',
},
event_not_public: {
title: 'Event nicht oeffentlich',
description: 'Dieses Event ist aktuell nicht oeffentlich zugaenglich.',
title: 'Event nicht öffentlich',
description: 'Dieses Event ist aktuell nicht öffentlich zugänglich.',
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.',
description: 'Wir konnten keine Verbindung zum Server herstellen. Prüfe deine Internetverbindung und versuche es erneut.',
},
server_error: {
title: 'Server nicht erreichbar',
description: 'Der Server reagiert derzeit nicht. Versuche es spaeter erneut.',
description: 'Der Server reagiert derzeit nicht. Versuche es später erneut.',
},
default: {
title: 'Event nicht erreichbar',
@@ -93,10 +93,10 @@ export const messages: Record<LocaleCode, NestedMessages> = {
loading: 'Lade Event...',
error: {
default: 'Event nicht gefunden.',
backToStart: 'Zurueck zur Startseite',
backToStart: 'Zurück zur Startseite',
},
card: {
description: 'Fange den schoensten Moment ein!',
description: 'Fange den schönsten Moment ein!',
},
form: {
label: 'Dein Name (z.B. Anna)',
@@ -108,12 +108,12 @@ export const messages: Record<LocaleCode, NestedMessages> = {
landing: {
pageTitle: 'Willkommen bei der Fotobox!',
headline: 'Willkommen bei der Fotobox!',
subheadline: 'Dein Schluessel zu unvergesslichen Momenten.',
subheadline: 'Dein Schlüssel zu unvergesslichen Momenten.',
join: {
title: 'Event beitreten',
description: 'Scanne den QR-Code oder gib den Code manuell ein.',
button: 'Event beitreten',
buttonLoading: 'Pruefe...',
buttonLoading: 'Prüfe...',
},
scan: {
start: 'QR-Code scannen',
@@ -125,7 +125,7 @@ export const messages: Record<LocaleCode, NestedMessages> = {
},
errors: {
eventClosed: 'Event nicht gefunden oder geschlossen.',
network: 'Netzwerkfehler. Bitte spaeter erneut versuchen.',
network: 'Netzwerkfehler. Bitte später erneut versuchen.',
camera: 'Kamera-Zugriff fehlgeschlagen. Bitte erlaube den Zugriff und versuche es erneut.',
},
},
@@ -134,27 +134,27 @@ export const messages: Record<LocaleCode, NestedMessages> = {
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.',
description: 'Du bist bereit für "{eventName}". Fang die Highlights des Events ein und teile sie mit allen Gästen.',
progress: {
some: 'Schon {count} Aufgaben erledigt - weiter so!',
none: 'Starte mit deiner ersten Aufgabe - wir zaehlen auf dich!',
some: 'Schon {count} Aufgaben erledigt weiter so!',
none: 'Starte mit deiner ersten Aufgabe wir zählen auf dich!',
},
defaultEventName: 'Dein Event',
},
stats: {
online: 'Gleichzeitig online',
tasksSolved: 'Aufgaben geloest',
tasksSolved: 'Aufgaben gelöst',
lastUpload: 'Letzter Upload',
completedTasks: 'Deine erledigten Aufgaben',
},
actions: {
title: 'Deine Aktionen',
subtitle: 'Waehle aus, womit du starten willst',
subtitle: 'Wähle aus, womit du starten willst',
queueButton: 'Uploads in Warteschlange ansehen',
items: {
tasks: {
label: 'Aufgabe ziehen',
description: 'Hol dir deine naechste Challenge',
description: 'Hol dir deine nächste Challenge',
},
upload: {
label: 'Direkt hochladen',
@@ -168,10 +168,10 @@ export const messages: Record<LocaleCode, NestedMessages> = {
},
checklist: {
title: 'Dein Fortschritt',
description: 'Halte dich an diese drei kurzen Schritte fuer die besten Ergebnisse.',
description: 'Halte dich an diese drei kurzen Schritte für die besten Ergebnisse.',
steps: {
first: 'Aufgabe auswaehlen oder starten',
second: 'Emotion festhalten und Foto schiessen',
first: 'Aufgabe auswählen oder starten',
second: 'Emotion festhalten und Foto schießen',
third: 'Bild hochladen und Credits sammeln',
},
},
@@ -225,35 +225,35 @@ export const messages: Record<LocaleCode, NestedMessages> = {
retry: 'Nochmal versuchen',
},
primer: {
title: 'Bereit fuer dein Shooting?',
title: 'Bereit für 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.',
part1: 'Lass uns sicherstellen, dass alles sitzt: prüfe das Licht, wisch die Kamera sauber und richte alle Personen im Bild aus.',
part2: 'Du kannst zwischen Front- und Rückkamera 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',
title: 'Kamera nicht verfügbar',
message: 'Dein Gerät unterstützt keine Kameravorschau im Browser. Du kannst stattdessen Fotos aus deiner Galerie hochladen.',
openGallery: 'Foto aus Galerie wählen',
},
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.',
explanation: 'Du musst den Zugriff auf die Kamera erlauben, um Fotos aufnehmen zu können.',
reopenPrompt: 'Systemdialog erneut öffnen',
chooseFile: 'Foto aus Galerie wählen',
prompt: 'Wir brauchen Zugriff auf deine Kamera. Erlaube die Anfrage oder wähle 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.',
explanation: 'Wir konnten keine Verbindung zur Kamera herstellen. Prüfe die Berechtigungen oder starte dein Gerät neu.',
tryAgain: 'Nochmals versuchen',
},
readyOverlay: {
title: 'Kamera bereit',
message: 'Wenn alle im Bild sind, kannst du den Countdown starten oder ein vorhandenes Foto waehlen.',
message: 'Wenn alle im Bild sind, kannst du den Countdown starten oder ein vorhandenes Foto wählen.',
start: 'Countdown starten',
chooseFile: 'Foto auswaehlen',
chooseFile: 'Foto auswählen',
},
taskInfo: {
countdown: 'Countdown',
@@ -266,7 +266,7 @@ export const messages: Record<LocaleCode, NestedMessages> = {
},
timeEstimate: '{count} Min',
fallbackTitle: 'Aufgabe {id}',
fallbackDescription: 'Halte den Moment fest und teile ihn mit allen Gaesten.',
fallbackDescription: 'Halte den Moment fest und teile ihn mit allen Gästen.',
fallbackInstructions: 'Positioniere alle im Bild, starte den Countdown und lass die Emotion wirken.',
badge: 'Aufgabe #{id}',
},
@@ -276,7 +276,7 @@ export const messages: Record<LocaleCode, NestedMessages> = {
review: {
retake: 'Nochmal aufnehmen',
keep: 'Foto verwenden',
readyAnnouncement: 'Foto aufgenommen. Bitte Vorschau pruefen.',
readyAnnouncement: 'Foto aufgenommen. Bitte Vorschau prüfen.',
},
status: {
saving: 'Speichere Foto...',
@@ -289,23 +289,23 @@ export const messages: Record<LocaleCode, NestedMessages> = {
controls: {
toggleGrid: 'Raster umschalten',
toggleCountdown: 'Countdown umschalten',
toggleMirror: 'Spiegelung fuer Frontkamera umschalten',
toggleFlash: 'Blitzpraeferenz umschalten',
toggleMirror: 'Spiegelung für Frontkamera umschalten',
toggleFlash: 'Blitzpräferenz umschalten',
capture: 'Foto aufnehmen',
switchCamera: 'Kamera wechseln',
chooseFile: 'Foto auswaehlen',
chooseFile: 'Foto auswählen',
},
limitReached: 'Upload-Limit erreicht ({used} / {max} Fotos). Bitte kontaktiere die Veranstalter fuer ein Upgrade.',
limitReached: 'Upload-Limit erreicht ({used} / {max} Fotos). Bitte kontaktiere die Veranstalter für 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.',
limitCheckError: 'Fehler beim Prüfen des Upload-Limits. Upload deaktiviert.',
galleryPickError: 'Auswahl fehlgeschlagen. Bitte versuche es erneut.',
captureButton: 'Foto aufnehmen',
galleryButton: 'Foto aus Galerie waehlen',
galleryButton: 'Foto aus Galerie wählen',
switchCamera: 'Kamera wechseln',
countdownLabel: 'Countdown: {seconds}s',
countdownReady: 'Bereit machen ...',
@@ -319,7 +319,7 @@ export const messages: Record<LocaleCode, NestedMessages> = {
subtitle: 'Verwalte deinen Gastzugang, rechtliche Dokumente und lokale Daten.',
language: {
title: 'Sprache',
description: 'Waehle deine bevorzugte Sprache fuer diese Veranstaltung.',
description: 'Wähle deine bevorzugte Sprache für diese Veranstaltung.',
activeBadge: 'aktiv',
option: {
de: 'Deutsch',
@@ -328,12 +328,12 @@ export const messages: Record<LocaleCode, NestedMessages> = {
},
name: {
title: 'Dein Name',
description: 'Passe an, wie wir dich im Event begruessen. Der Name wird nur lokal gespeichert.',
description: 'Passe an, wie wir dich im Event begrüßen. Der Name wird nur lokal gespeichert.',
label: 'Anzeigename',
placeholder: 'z.B. Anna',
save: 'Name speichern',
saving: 'Speichere...',
reset: 'Zuruecksetzen',
reset: 'Zurücksetzen',
saved: 'Gespeichert (ok)',
loading: 'Lade gespeicherten Namen...',
},
@@ -341,7 +341,7 @@ export const messages: Record<LocaleCode, NestedMessages> = {
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.',
error: 'Das Dokument konnte nicht geladen werden. Bitte versuche es später erneut.',
fallbackTitle: 'Rechtlicher Hinweis',
section: {
impressum: 'Impressum',
@@ -350,19 +350,19 @@ export const messages: Record<LocaleCode, NestedMessages> = {
},
},
cache: {
title: 'Offline Cache',
description: 'Loesche lokale Daten, falls Inhalte veraltet erscheinen oder Uploads haengen bleiben.',
title: 'Offline-Cache',
description: 'Lösche lokale Daten, falls Inhalte veraltet erscheinen oder Uploads hängen bleiben.',
clear: 'Cache leeren',
clearing: 'Leere Cache...',
cleared: 'Cache geloescht.',
note: 'Dies betrifft nur diesen Browser und muss pro Geraet erneut ausgefuehrt werden.',
cleared: 'Cache gelöscht.',
note: 'Dies betrifft nur diesen Browser und muss pro Gerät erneut ausgeführt werden.',
},
footer: {
notice: 'Gastbereich - Daten werden lokal im Browser gespeichert.',
},
sheet: {
openLabel: 'Einstellungen oeffnen',
backLabel: 'Zurueck',
openLabel: 'Einstellungen öffnen',
backLabel: 'Zurück',
legalDescription: 'Rechtlicher Hinweis',
},
},

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import Header from '../components/Header';
import BottomNav from '../components/BottomNav';
@@ -7,7 +7,6 @@ import { Badge } from '@/components/ui/badge';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { uploadPhoto } from '../services/photosApi';
import { useGuestTaskProgress } from '../hooks/useGuestTaskProgress';
import { useAppearance } from '../../hooks/use-appearance';
import { cn } from '@/lib/utils';
import {
AlertTriangle,
@@ -46,6 +45,39 @@ type CameraPreferences = {
flashPreferred: boolean;
};
type TaskPayload = Partial<Task> & { id: number };
function isTaskPayload(value: unknown): value is TaskPayload {
if (typeof value !== 'object' || value === null) {
return false;
}
const candidate = value as { id?: unknown };
return typeof candidate.id === 'number';
}
function getErrorName(error: unknown): string | undefined {
if (typeof error === 'object' && error !== null && 'name' in error) {
const name = (error as { name?: unknown }).name;
return typeof name === 'string' ? name : undefined;
}
return undefined;
}
function getErrorMessage(error: unknown): string | undefined {
if (error instanceof Error && typeof error.message === 'string') {
return error.message;
}
if (typeof error === 'object' && error !== null && 'message' in error) {
const message = (error as { message?: unknown }).message;
return typeof message === 'string' ? message : undefined;
}
return undefined;
}
const DEFAULT_PREFS: CameraPreferences = {
facingMode: 'environment',
countdownSeconds: 3,
@@ -60,8 +92,6 @@ export default function UploadPage() {
const eventKey = token ?? '';
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { appearance } = useAppearance();
const isDarkMode = appearance === 'dark';
const { markCompleted } = useGuestTaskProgress(token);
const { t } = useTranslation();
@@ -75,7 +105,6 @@ export default function UploadPage() {
const [task, setTask] = useState<Task | null>(null);
const [loadingTask, setLoadingTask] = useState(true);
const [taskError, setTaskError] = useState<string | null>(null);
const [permissionState, setPermissionState] = useState<PermissionState>('idle');
const [permissionMessage, setPermissionMessage] = useState<string | null>(null);
@@ -138,8 +167,7 @@ export default function UploadPage() {
// Load task metadata
useEffect(() => {
if (!token || !taskId) {
setTaskError(t('upload.loadError.title'));
if (!token || taskId === null) {
setLoadingTask(false);
return;
}
@@ -147,18 +175,19 @@ export default function UploadPage() {
let active = true;
async function loadTask() {
const fallbackTitle = t('upload.taskInfo.fallbackTitle').replace('{id}', `${taskId!}`);
const currentTaskId = taskId;
const fallbackTitle = t('upload.taskInfo.fallbackTitle').replace('{id}', `${currentTaskId}`);
const fallbackDescription = t('upload.taskInfo.fallbackDescription');
const fallbackInstructions = t('upload.taskInfo.fallbackInstructions');
try {
setLoadingTask(true);
setTaskError(null);
const res = await fetch(`/api/v1/events/${encodeURIComponent(eventKey)}/tasks`);
if (!res.ok) throw new Error('Tasks konnten nicht geladen werden');
const tasks = await res.json();
const found = Array.isArray(tasks) ? tasks.find((entry: any) => entry.id === taskId!) : null;
const payload = (await res.json()) as unknown;
const entries = Array.isArray(payload) ? payload.filter(isTaskPayload) : [];
const found = entries.find((entry) => entry.id === currentTaskId) ?? null;
if (!active) return;
@@ -174,7 +203,7 @@ export default function UploadPage() {
});
} else {
setTask({
id: taskId!,
id: currentTaskId,
title: fallbackTitle,
description: fallbackDescription,
instructions: fallbackInstructions,
@@ -188,9 +217,8 @@ export default function UploadPage() {
} catch (error) {
console.error('Failed to fetch task', error);
if (active) {
setTaskError(t('upload.loadError.title'));
setTask({
id: taskId!,
id: currentTaskId,
title: fallbackTitle,
description: fallbackDescription,
instructions: fallbackInstructions,
@@ -210,7 +238,7 @@ export default function UploadPage() {
return () => {
active = false;
};
}, [eventKey, taskId, emotionSlug, t]);
}, [eventKey, taskId, emotionSlug, t, token]);
// Check upload limits
useEffect(() => {
@@ -294,14 +322,15 @@ export default function UploadPage() {
streamRef.current = stream;
attachStreamToVideo(stream);
setPermissionState('granted');
} catch (error: any) {
} catch (error: unknown) {
console.error('Camera access error', error);
stopStream();
if (error?.name === 'NotAllowedError') {
const errorName = getErrorName(error);
if (errorName === 'NotAllowedError') {
setPermissionState('denied');
setPermissionMessage(t('upload.cameraDenied.explanation'));
} else if (error?.name === 'NotFoundError') {
} else if (errorName === 'NotFoundError') {
setPermissionState('error');
setPermissionMessage(t('upload.cameraUnsupported.message'));
} else {
@@ -489,9 +518,9 @@ export default function UploadPage() {
markCompleted(task.id);
stopStream();
navigateAfterUpload(photoId);
} catch (error: any) {
} catch (error: unknown) {
console.error('Upload failed', error);
setUploadError(error?.message || t('upload.status.failed'));
setUploadError(getErrorMessage(error) || t('upload.status.failed'));
setMode('review');
} finally {
if (uploadProgressTimerRef.current) {
@@ -533,7 +562,6 @@ export default function UploadPage() {
const isCameraActive = permissionState === 'granted' && mode !== 'uploading';
const showTaskOverlay = task && mode !== 'uploading';
const isUploadDisabled = !canUpload || !task;
useEffect(() => () => {
resetCountdownTimer();
@@ -542,49 +570,41 @@ export default function UploadPage() {
}
}, [resetCountdownTimer]);
const renderPage = (content: ReactNode, mainClassName = 'px-4 py-6') => (
<div className="pb-16">
<Header eventToken={eventKey} title={t('upload.cameraTitle')} />
<main className={mainClassName}>{content}</main>
<BottomNav />
</div>
);
if (!supportsCamera && !task) {
return (
<div className="pb-16">
<Header eventToken={eventKey} title={t('upload.cameraTitle')} />
<main className="px-4 py-6">
<Alert>
<AlertDescription>{t('upload.cameraUnsupported.message')}</AlertDescription>
</Alert>
</main>
<BottomNav />
</div>
return renderPage(
<Alert>
<AlertDescription>{t('upload.cameraUnsupported.message')}</AlertDescription>
</Alert>
);
}
if (loadingTask) {
return (
<div className="pb-16">
<Header eventToken={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">{t('upload.preparing')}</p>
</main>
<BottomNav />
return renderPage(
<div className="flex flex-col items-center justify-center gap-4 text-center">
<Loader2 className="h-10 w-10 animate-spin text-pink-500" />
<p className="text-sm text-muted-foreground">{t('upload.preparing')}</p>
</div>
);
}
if (!canUpload) {
return (
<div className="pb-16">
<Header eventToken={eventKey} title={t('upload.cameraTitle')} />
<main className="px-4 py-6">
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
{t('upload.limitReached')
.replace('{used}', `${eventPackage?.used_photos || 0}`)
.replace('{max}', `${eventPackage?.package.max_photos || 0}`)}
</AlertDescription>
</Alert>
</main>
<BottomNav />
</div>
return renderPage(
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
{t('upload.limitReached')
.replace('{used}', `${eventPackage?.used_photos || 0}`)
.replace('{max}', `${eventPackage?.package.max_photos || 0}`)}
</AlertDescription>
</Alert>
);
}
@@ -636,18 +656,16 @@ export default function UploadPage() {
);
};
return (
<div className="pb-16">
<Header eventToken={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()}
</div>
<div className="pt-32" />
{permissionState !== 'granted' && renderPermissionNotice()}
return renderPage(
<>
<div className="absolute left-0 right-0 top-0" aria-hidden="true">
{renderPrimer()}
</div>
<div className="pt-32" />
{permissionState !== 'granted' && renderPermissionNotice()}
<section className="relative mx-4 overflow-hidden rounded-2xl border border-white/10 bg-black text-white shadow-xl">
<div className="relative aspect-[3/4] sm:aspect-video">
<section className="relative mx-4 overflow-hidden rounded-2xl border border-white/10 bg-black text-white shadow-xl">
<div className="relative aspect-[3/4] sm:aspect-video">
<video
ref={videoRef}
className={cn(
@@ -863,9 +881,10 @@ export default function UploadPage() {
/>
<div ref={liveRegionRef} className="sr-only" aria-live="assertive" />
</main>
<BottomNav />
<canvas ref={canvasRef} className="hidden" />
</div>
<canvas ref={canvasRef} className="hidden" />
</>
,
'relative flex flex-col gap-4 pb-4'
);
}