// @ts-nocheck import React, { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Alert, AlertDescription } from '@/components/ui/alert'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { uploadPhoto, type UploadError } from '../services/photosApi'; import { useGuestTaskProgress } from '../hooks/useGuestTaskProgress'; import { cn } from '@/lib/utils'; import { AlertTriangle, Camera, ChevronDown, Grid3X3, Menu, ImagePlus, Info, Loader2, RotateCcw, Timer, Sparkles, FlipHorizontal, Zap, ZapOff, } from 'lucide-react'; import { getEventPackage, type EventPackage } from '../services/eventApi'; import { useTranslation, type TranslateFn } from '../i18n/useTranslation'; import { buildLimitSummaries, type LimitSummaryCard } from '../lib/limitSummaries'; import { resolveUploadErrorDialog, type UploadErrorDialog } from '../lib/uploadErrorDialog'; import { useEventStats } from '../context/EventStatsContext'; import { useEventBranding } from '../context/EventBrandingContext'; import { compressPhoto, formatBytes } from '../lib/image'; import { useGuestIdentity } from '../context/GuestIdentityContext'; import { useEventData } from '../hooks/useEventData'; import { isTaskModeEnabled } from '../lib/engagement'; import { getDeviceId } from '../lib/device'; interface Task { id: number; title: string; description: string; instructions?: string; duration: number; emotion?: { slug: string; name: string }; difficulty?: 'easy' | 'medium' | 'hard'; } type PermissionState = 'idle' | 'prompt' | 'granted' | 'denied' | 'error' | 'unsupported' | 'blocked'; type CameraMode = 'preview' | 'countdown' | 'review' | 'uploading'; type CameraPreferences = { facingMode: 'user' | 'environment'; countdownSeconds: number; countdownEnabled: boolean; gridEnabled: boolean; mirrorFrontPreview: boolean; flashPreferred: boolean; }; type TaskPayload = Partial & { 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 isCameraBlockedByPolicy(): boolean { if (typeof document === 'undefined') { return false; } const policy = (document as { permissionsPolicy?: { allowsFeature?: (feature: string) => boolean } }) .permissionsPolicy; if (!policy?.allowsFeature) { return false; } return !policy.allowsFeature('camera'); } const DEFAULT_PREFS: CameraPreferences = { facingMode: 'environment', countdownSeconds: 3, countdownEnabled: true, gridEnabled: true, mirrorFrontPreview: true, flashPreferred: false, }; const LIMIT_CARD_STYLES: Record = { neutral: { card: 'border-slate-200 bg-white/90 text-slate-900 dark:border-white/15 dark:bg-white/10 dark:text-white', badge: 'bg-slate-900/10 text-slate-900 dark:bg-white/20 dark:text-white', bar: 'bg-emerald-500', }, warning: { card: 'border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-400/40 dark:bg-amber-500/15 dark:text-amber-50', badge: 'bg-white/70 text-amber-900 dark:bg-amber-400/25 dark:text-amber-50', bar: 'bg-amber-500', }, danger: { card: 'border-rose-200 bg-rose-50 text-rose-900 dark:border-rose-400/50 dark:bg-rose-500/15 dark:text-rose-50', badge: 'bg-white/70 text-rose-900 dark:bg-rose-400/20 dark:text-rose-50', bar: 'bg-rose-500', }, }; export default function UploadPage() { const { token } = useParams<{ token: string }>(); const eventKey = token ?? ''; const navigate = useNavigate(); const [searchParams] = useSearchParams(); const { markCompleted } = useGuestTaskProgress(token); const identity = useGuestIdentity(); const { event } = useEventData(); const tasksEnabled = isTaskModeEnabled(event); const { t, locale } = useTranslation(); const stats = useEventStats(); const { branding } = useEventBranding(); const radius = branding.buttons?.radius ?? 12; const buttonStyle = branding.buttons?.style ?? 'filled'; const linkColor = branding.buttons?.linkColor ?? branding.secondaryColor; const bodyFont = branding.typography?.body ?? branding.fontFamily ?? undefined; const uploadsRequireApproval = (event?.guest_upload_visibility as 'immediate' | 'review' | undefined) !== 'immediate'; const demoReadOnly = Boolean(event?.demo_read_only); const taskIdParam = searchParams.get('task'); const emotionSlug = searchParams.get('emotion') || ''; const primerStorageKey = eventKey ? `guestCameraPrimerDismissed_${eventKey}` : 'guestCameraPrimerDismissed'; const prefsStorageKey = eventKey ? `guestCameraPrefs_${eventKey}` : 'guestCameraPrefs'; const supportsCamera = typeof navigator !== 'undefined' && !!navigator.mediaDevices?.getUserMedia; const [task, setTask] = useState(null); const [loadingTask, setLoadingTask] = useState(true); const [permissionState, setPermissionState] = useState('idle'); const [permissionMessage, setPermissionMessage] = useState(null); const [preferences, setPreferences] = useState(DEFAULT_PREFS); const [mode, setMode] = useState('preview'); const [countdownValue, setCountdownValue] = useState(DEFAULT_PREFS.countdownSeconds); const [statusMessage, setStatusMessage] = useState(''); const [reviewPhoto, setReviewPhoto] = useState<{ dataUrl: string; file: File } | null>(null); const [uploadProgress, setUploadProgress] = useState(0); const [uploadError, setUploadError] = useState(null); const [uploadWarning, setUploadWarning] = useState(null); const [immersiveMode, setImmersiveMode] = useState(true); const [showCelebration, setShowCelebration] = useState(false); const [showHeroOverlay, setShowHeroOverlay] = useState(true); const kpiChipsRef = useRef(null); const navSentinelRef = useRef(null); const [errorDialog, setErrorDialog] = useState(null); const [taskDetailsExpanded, setTaskDetailsExpanded] = useState(false); const [eventPackage, setEventPackage] = useState(null); const [canUpload, setCanUpload] = useState(true); const limitCards = useMemo( () => buildLimitSummaries(eventPackage?.limits ?? null, t), [eventPackage?.limits, t] ); useEffect(() => { if (typeof document === 'undefined') return undefined; const className = 'guest-immersive'; document.body.classList.add(className); document.body.classList.add('guest-nav-visible'); // show nav by default on upload page return () => { document.body.classList.remove(className); document.body.classList.remove('guest-nav-visible'); }; }, []); const updateNavVisibility = useCallback(() => { if (typeof document === 'undefined') { return; } // nav is always visible on upload page unless user explicitly toggles immersive off via button document.body.classList.add('guest-nav-visible'); }, []); useEffect(() => { if (typeof window === 'undefined') { return; } // ensure nav remains visible; hide only when immersive toggled off via the menu button updateNavVisibility(); return () => { document.body.classList.remove('guest-nav-visible'); }; }, [updateNavVisibility]); const [showPrimer, setShowPrimer] = useState(() => { if (typeof window === 'undefined') return false; return window.localStorage.getItem(primerStorageKey) !== '1'; }); const videoRef = useRef(null); const canvasRef = useRef(null); const fileInputRef = useRef(null); const liveRegionRef = useRef(null); const cameraViewportRef = useRef(null); const cameraShellRef = useRef(null); const streamRef = useRef(null); const countdownTimerRef = useRef(null); const uploadProgressTimerRef = useRef(null); const taskId = useMemo(() => { if (!taskIdParam) return null; const parsed = parseInt(taskIdParam, 10); return Number.isFinite(parsed) ? parsed : null; }, [taskIdParam]); const tasksUrl = useMemo(() => { if (!eventKey) return '/tasks'; return `/e/${encodeURIComponent(eventKey)}/tasks`; }, [eventKey]); // Load preferences from storage useEffect(() => { if (typeof window === 'undefined') return; try { const stored = window.localStorage.getItem(prefsStorageKey); if (stored) { const parsed = JSON.parse(stored) as Partial; setPreferences((prev) => ({ ...prev, ...parsed })); } } catch (error) { console.warn('Failed to parse camera preferences', error); } }, [prefsStorageKey]); // Persist preferences when they change useEffect(() => { if (typeof window === 'undefined') return; try { window.localStorage.setItem(prefsStorageKey, JSON.stringify(preferences)); } catch (error) { console.warn('Failed to persist camera preferences', error); } }, [preferences, prefsStorageKey]); // Load task metadata useEffect(() => { if (!token || taskId === null || !tasksEnabled) { setLoadingTask(false); return; } let active = true; async function loadTask() { if (taskId === null) return; const currentTaskId = Number(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); const res = await fetch( `/api/v1/events/${encodeURIComponent(eventKey)}/tasks?locale=${encodeURIComponent(locale)}`, { headers: { Accept: 'application/json', 'X-Locale': locale, 'X-Device-Id': getDeviceId(), }, } ); if (!res.ok) throw new Error('Tasks konnten nicht geladen werden'); const payload = (await res.json()) as unknown; const entries = Array.isArray(payload) ? payload.filter(isTaskPayload) : Array.isArray((payload as any)?.data) ? (payload as any).data.filter(isTaskPayload) : Array.isArray((payload as any)?.tasks) ? (payload as any).tasks.filter(isTaskPayload) : []; const found = entries.find((entry) => entry.id === currentTaskId) ?? null; if (!active) return; if (found) { setTask({ id: found.id, title: found.title || fallbackTitle, description: found.description || fallbackDescription, instructions: found.instructions ?? fallbackInstructions, duration: found.duration || 2, emotion: found.emotion, difficulty: found.difficulty ?? 'medium', }); } else { setTask({ id: currentTaskId, title: fallbackTitle, description: fallbackDescription, instructions: fallbackInstructions, duration: 2, emotion: emotionSlug ? { slug: emotionSlug, name: emotionSlug.replace('-', ' ').replace(/\b\w/g, (l) => l.toUpperCase()) } : undefined, difficulty: 'medium', }); } } catch (error) { console.error('Failed to fetch task', error); if (active) { setTask({ id: currentTaskId, title: fallbackTitle, description: fallbackDescription, instructions: fallbackInstructions, duration: 2, emotion: emotionSlug ? { slug: emotionSlug, name: emotionSlug.replace('-', ' ').replace(/\b\w/g, (l) => l.toUpperCase()) } : undefined, difficulty: 'medium', }); } } finally { if (active) setLoadingTask(false); } } loadTask(); return () => { active = false; }; }, [eventKey, taskId, emotionSlug, t, token, locale]); // Check upload limits useEffect(() => { if (!eventKey) return; const checkLimits = async () => { if (demoReadOnly) { setCanUpload(false); setUploadError(t('upload.demoReadOnly', 'Uploads sind in der Demo deaktiviert.')); setUploadWarning(null); return; } try { const pkg = await getEventPackage(eventKey); setEventPackage(pkg); if (!pkg) { setCanUpload(true); setUploadError(null); setUploadWarning(null); return; } const photoLimits = pkg.limits?.photos ?? null; const galleryLimits = pkg.limits?.gallery ?? null; let canUploadCurrent = pkg.limits?.can_upload_photos ?? true; let errorMessage: string | null = null; if (photoLimits?.state === 'limit_reached') { canUploadCurrent = false; if (typeof photoLimits.limit === 'number') { errorMessage = t('upload.limitReached') .replace('{used}', `${photoLimits.used}`) .replace('{max}', `${photoLimits.limit}`); } else { errorMessage = t('upload.errors.photoLimit'); } } if (galleryLimits?.state === 'expired') { canUploadCurrent = false; errorMessage = t('upload.errors.galleryExpired'); } setCanUpload(canUploadCurrent); setUploadError(errorMessage); setUploadWarning(null); } catch (err) { console.error('Failed to check package limits', err); setCanUpload(false); setUploadError(t('upload.limitCheckError')); setUploadWarning(null); } }; checkLimits(); }, [demoReadOnly, eventKey, t]); const stopStream = useCallback(() => { if (streamRef.current) { streamRef.current.getTracks().forEach((track) => track.stop()); streamRef.current = null; } }, []); const attachStreamToVideo = useCallback((stream: MediaStream) => { if (!videoRef.current) return; videoRef.current.srcObject = stream; videoRef.current .play() .then(() => { if (videoRef.current) { videoRef.current.muted = true; } }) .catch((error) => console.error('Video play error', error)); }, []); const createConstraint = useCallback( (mode: 'user' | 'environment'): MediaStreamConstraints => ({ video: { width: { ideal: 1920 }, height: { ideal: 1080 }, facingMode: { ideal: mode }, }, audio: false, }), [] ); const startCamera = useCallback(async () => { if (!supportsCamera) { setPermissionState('unsupported'); setPermissionMessage(t('upload.cameraUnsupported.message')); return; } if (mode === 'uploading') return; try { if (isCameraBlockedByPolicy()) { setPermissionState('blocked'); setPermissionMessage(t('upload.cameraBlocked.message')); return; } setPermissionState('prompt'); setPermissionMessage(null); const stream = await navigator.mediaDevices.getUserMedia(createConstraint(preferences.facingMode)); stopStream(); streamRef.current = stream; attachStreamToVideo(stream); setPermissionState('granted'); } catch (error: unknown) { console.error('Camera access error', error); stopStream(); const errorName = getErrorName(error); if (errorName === 'NotAllowedError') { setPermissionState('denied'); setPermissionMessage(t('upload.cameraDenied.explanation')); } else if (errorName === 'NotFoundError') { setPermissionState('error'); setPermissionMessage(t('upload.cameraUnsupported.message')); } else { setPermissionState('error'); setPermissionMessage(t('upload.cameraError.explanation')); } } }, [attachStreamToVideo, createConstraint, mode, preferences.facingMode, stopStream, supportsCamera, t]); const handleRecheckCamera = useCallback(() => { if (isCameraBlockedByPolicy()) { setPermissionState('blocked'); setPermissionMessage(t('upload.cameraBlocked.message')); return; } setPermissionState('idle'); setPermissionMessage(null); void startCamera(); }, [startCamera, t]); useEffect(() => { if (loadingTask) return; startCamera(); return () => { stopStream(); }; }, [loadingTask, startCamera, stopStream, preferences.facingMode]); // Countdown live region updates useEffect(() => { if (!liveRegionRef.current) return; if (mode === 'countdown') { liveRegionRef.current.textContent = t('upload.countdown.ready').replace('{count}', `${countdownValue}`); } else if (mode === 'review') { liveRegionRef.current.textContent = t('upload.review.readyAnnouncement'); } else if (mode === 'uploading') { liveRegionRef.current.textContent = t('upload.status.uploading'); } else { liveRegionRef.current.textContent = ''; } }, [mode, countdownValue, t]); const dismissPrimer = useCallback(() => { setShowPrimer(false); if (typeof window !== 'undefined') { window.localStorage.setItem(primerStorageKey, '1'); } }, [primerStorageKey]); const handleToggleGrid = useCallback(() => { setPreferences((prev) => ({ ...prev, gridEnabled: !prev.gridEnabled })); }, []); const handleToggleMirror = useCallback(() => { setPreferences((prev) => ({ ...prev, mirrorFrontPreview: !prev.mirrorFrontPreview })); }, []); const handleToggleCountdown = useCallback(() => { setPreferences((prev) => ({ ...prev, countdownEnabled: !prev.countdownEnabled })); }, []); const handleSwitchCamera = useCallback(() => { setPreferences((prev) => ({ ...prev, facingMode: prev.facingMode === 'user' ? 'environment' : 'user', })); }, []); const handleToggleFlashPreference = useCallback(() => { setPreferences((prev) => ({ ...prev, flashPreferred: !prev.flashPreferred })); }, []); const handleToggleImmersive = useCallback(async () => { setImmersiveMode((prev) => !prev); const shell = cameraShellRef.current; if (!shell) return; const prefersReducedMotion = typeof window !== 'undefined' ? window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches : false; if (prefersReducedMotion) return; try { if (!document.fullscreenElement) { await shell.requestFullscreen?.(); } else { await document.exitFullscreen?.(); } } catch (error) { console.warn('Fullscreen toggle failed', error); } }, []); const triggerConfetti = useCallback(async () => { if (typeof window === 'undefined') return; const prefersReducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches; if (prefersReducedMotion) return; try { const { default: confetti } = await import('canvas-confetti'); confetti({ particleCount: 70, spread: 65, origin: { x: 0.5, y: 0.35 }, ticks: 160, scalar: 0.9, }); } catch (error) { console.warn('Confetti could not start', error); } }, []); const resetCountdownTimer = useCallback(() => { if (countdownTimerRef.current) { window.clearInterval(countdownTimerRef.current); countdownTimerRef.current = null; } }, []); const performCapture = useCallback(() => { if (!videoRef.current || !canvasRef.current) { setUploadError(t('upload.captureError')); setMode('preview'); return; } const video = videoRef.current; const canvas = canvasRef.current; const width = video.videoWidth; const height = video.videoHeight; if (!width || !height) { setUploadError(t('upload.feedError')); setMode('preview'); startCamera(); return; } canvas.width = width; canvas.height = height; const context = canvas.getContext('2d'); if (!context) { setUploadError(t('upload.canvasError')); setMode('preview'); return; } context.save(); const shouldMirror = preferences.facingMode === 'user' && preferences.mirrorFrontPreview; if (shouldMirror) { context.scale(-1, 1); context.drawImage(video, -width, 0, width, height); } else { context.drawImage(video, 0, 0, width, height); } context.restore(); canvas.toBlob( (blob) => { if (!blob) { setUploadError(t('upload.captureError')); setMode('preview'); return; } const timestamp = Date.now(); const fileName = `photo-${timestamp}.jpg`; const file = new File([blob], fileName, { type: 'image/jpeg', lastModified: timestamp }); const dataUrl = canvas.toDataURL('image/jpeg', 0.92); setReviewPhoto({ dataUrl, file }); setMode('review'); }, 'image/jpeg', 0.92 ); }, [preferences.facingMode, preferences.mirrorFrontPreview, startCamera, t]); const beginCapture = useCallback(() => { setUploadError(null); if (preferences.countdownEnabled && preferences.countdownSeconds > 0) { setMode('countdown'); setCountdownValue(preferences.countdownSeconds); resetCountdownTimer(); countdownTimerRef.current = window.setInterval(() => { setCountdownValue((prev) => { if (prev <= 1) { resetCountdownTimer(); performCapture(); return preferences.countdownSeconds; } return prev - 1; }); }, 1000); } else { performCapture(); } }, [performCapture, preferences.countdownEnabled, preferences.countdownSeconds, resetCountdownTimer]); const handleRetake = useCallback(() => { setReviewPhoto(null); setUploadProgress(0); setUploadError(null); setMode('preview'); }, []); const navigateAfterUpload = useCallback( (photoId: number | undefined) => { if (!eventKey) return; const params = new URLSearchParams(); params.set('uploaded', 'true'); if (task?.id) params.set('task', String(task.id)); if (photoId) params.set('photo', String(photoId)); if (emotionSlug) params.set('emotion', emotionSlug); navigate(`/e/${encodeURIComponent(eventKey)}/gallery?${params.toString()}`); }, [emotionSlug, navigate, eventKey, task?.id] ); const handleUsePhoto = useCallback(async () => { if (!eventKey || !reviewPhoto || !canUpload) return; setMode('uploading'); setUploadProgress(2); setUploadError(null); setUploadWarning(null); setStatusMessage(t('upload.status.preparing')); if (uploadProgressTimerRef.current) { window.clearInterval(uploadProgressTimerRef.current); uploadProgressTimerRef.current = null; } const maxEdge = 2400; const targetBytes = 4_000_000; let fileForUpload = reviewPhoto.file; try { setStatusMessage(t('upload.status.optimizing', 'Foto wird optimiert…')); const optimized = await compressPhoto(reviewPhoto.file, { maxEdge, targetBytes, qualityStart: 0.82, }); fileForUpload = optimized; setUploadProgress(10); if (optimized.size < reviewPhoto.file.size - 50_000) { const saved = formatBytes(reviewPhoto.file.size - optimized.size); setUploadWarning( t('upload.optimizedNotice', 'Wir haben dein Foto verkleinert, damit der Upload schneller klappt. Eingespart: {saved}') .replace('{saved}', saved) ); } } catch (e) { console.warn('Image optimization failed, uploading original', e); setUploadWarning(t('upload.optimizedFallback', 'Optimierung nicht möglich – wir laden das Original hoch.')); } try { const photoId = await uploadPhoto(eventKey, fileForUpload, task?.id, emotionSlug || undefined, { maxRetries: 2, guestName: identity.name || undefined, onProgress: (percent) => { setUploadProgress(Math.max(15, Math.min(98, percent))); setStatusMessage(t('upload.status.uploading')); }, onRetry: (attempt) => { setUploadWarning( t('upload.retrying', 'Verbindung holperig – neuer Versuch ({attempt}).') .replace('{attempt}', `${attempt}`) ); }, }); setUploadProgress(100); setStatusMessage(t('upload.status.completed')); if (task?.id) { markCompleted(task.id); } setShowCelebration(true); if (typeof window !== 'undefined') { window.setTimeout(() => setShowCelebration(false), 1800); } void triggerConfetti(); try { const raw = localStorage.getItem('my-photo-ids'); const arr: number[] = raw ? JSON.parse(raw) : []; if (photoId && !arr.includes(photoId)) { localStorage.setItem('my-photo-ids', JSON.stringify([photoId, ...arr])); } } catch (error) { console.warn('Failed to persist my-photo-ids', error); } await new Promise((resolve) => { window.setTimeout(resolve, 420); }); stopStream(); navigateAfterUpload(photoId); } catch (error: unknown) { console.error('Upload failed', error); const uploadErr = error as UploadError; setUploadWarning(null); const meta = uploadErr.meta as Record | undefined; const dialog = resolveUploadErrorDialog(uploadErr.code, meta, t); setErrorDialog(dialog); setUploadError(dialog.description); if (uploadErr.status === 422 || uploadErr.code === 'validation_error') { setUploadWarning( t('upload.errors.tooLargeHint', 'Das Foto war zu groß. Bitte erneut versuchen – wir verkleinern es automatisch.') ); } if ( uploadErr.code === 'photo_limit_exceeded' || uploadErr.code === 'upload_device_limit' || uploadErr.code === 'event_package_missing' || uploadErr.code === 'event_not_found' || uploadErr.code === 'gallery_expired' ) { setCanUpload(false); } setMode('review'); } finally { setStatusMessage(''); } }, [emotionSlug, markCompleted, navigateAfterUpload, reviewPhoto, eventKey, stopStream, task, canUpload, t, identity.name, triggerConfetti]); const handleGalleryPick = useCallback(async (event: React.ChangeEvent) => { if (!canUpload) return; const file = event.target.files?.[0]; if (!file) return; setUploadError(null); setUploadWarning(null); setStatusMessage(t('upload.status.optimizing', 'Foto wird optimiert…')); let prepared = file; try { prepared = await compressPhoto(file, { maxEdge: 2400, targetBytes: 4_000_000, qualityStart: 0.82, }); if (prepared.size < file.size - 50_000) { const saved = formatBytes(file.size - prepared.size); setUploadWarning( t('upload.optimizedNotice', 'Wir haben dein Foto verkleinert, damit der Upload schneller klappt. Eingespart: {saved}') .replace('{saved}', saved) ); } } catch (error) { console.warn('Gallery image optimization failed, falling back to original', error); setUploadWarning(t('upload.optimizedFallback', 'Optimierung nicht möglich – wir laden das Original hoch.')); } if (prepared.size > 12_000_000) { setStatusMessage(''); setUploadError( t('upload.errors.tooLargeHint', 'Das Foto war zu groß. Bitte erneut versuchen – wir verkleinern es automatisch.') ); event.target.value = ''; return; } const dataUrl = await readAsDataUrl(prepared); setReviewPhoto({ dataUrl, file: prepared }); setMode('review'); setStatusMessage(''); event.target.value = ''; }, [canUpload, t]); const emotionLabel = useMemo(() => { if (task?.emotion?.name) return task.emotion.name; if (emotionSlug) { return emotionSlug.replace('-', ' ').replace(/\b\w/g, (letter) => letter.toUpperCase()); } return t('upload.hud.moodFallback'); }, [emotionSlug, t, task?.emotion?.name]); const difficultyBadgeClass = useMemo(() => { if (!task) return 'text-white'; switch (task.difficulty) { case 'easy': return 'text-emerald-400'; case 'hard': return 'text-rose-400'; default: return 'text-amber-300'; } }, [task]); const isCameraActive = permissionState === 'granted' && mode !== 'uploading'; const showTaskOverlay = task && mode !== 'uploading'; const relativeLastUpload = useMemo( () => formatRelativeTimeLabel(stats.latestPhotoAt, t), [stats.latestPhotoAt, t], ); const socialChips = useMemo( () => [ { id: 'online', label: t('upload.hud.cards.online'), value: stats.onlineGuests > 0 ? `${stats.onlineGuests}` : '0', }, { id: 'emotion', label: t('upload.taskInfo.emotion').replace('{value}', emotionLabel), value: t('upload.hud.moodLabel').replace('{mood}', emotionLabel), }, { id: 'last-upload', label: t('upload.hud.cards.lastUpload'), value: relativeLastUpload, }, ], [emotionLabel, relativeLastUpload, stats.onlineGuests, t], ); useEffect(() => () => { resetCountdownTimer(); if (uploadProgressTimerRef.current) { window.clearInterval(uploadProgressTimerRef.current); } }, [resetCountdownTimer]); useEffect(() => { setTaskDetailsExpanded(false); }, [task?.id]); useEffect(() => { if (task) { setShowHeroOverlay(false); } else { setShowHeroOverlay(true); } }, [task]); const handlePrimaryAction = useCallback(() => { setShowHeroOverlay(false); if (!isCameraActive) { startCamera(); return; } beginCapture(); }, [beginCapture, isCameraActive, startCamera]); const taskFloatingCard = showTaskOverlay && task ? ( ) : null; const heroOverlay = !task && showHeroOverlay && mode !== 'uploading' ? (

Bereit für dein Foto?

Teile den Moment mit allen Gästen.

Zieh eine Mission oder starte direkt.

Live
Mini-Mission: Fang ein Lachen ein
) : null; const dialogToneIconClass: Record, string> = { danger: 'text-rose-500', warning: 'text-amber-500', info: 'text-sky-500', }; const errorDialogNode = ( { if (!open) setErrorDialog(null); }}>
{errorDialog?.tone === 'info' ? ( ) : ( )} {errorDialog?.title ?? ''}
{errorDialog?.description ?? ''} {errorDialog?.hint ? (

{errorDialog.hint}

) : null}
); const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[120px]') => ( <>
{content}
{errorDialogNode} ); if (loadingTask) { return renderWithDialog(

{t('upload.preparing')}

); } if (!canUpload) { return renderWithDialog( {t('upload.limitReached') .replace('{used}', `${eventPackage?.used_photos || 0}`) .replace('{max}', `${eventPackage?.package?.max_photos || 0}`)} ); } const renderPrimer = () => ( showPrimer && (

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

{t('upload.primer.body.part1')}{' '} {t('upload.primer.body.part2')}

) ); const renderPermissionNotice = () => { if (permissionState === 'granted') return null; const titles: Record = { idle: t('upload.cameraDenied.title'), prompt: t('upload.cameraDenied.title'), granted: '', denied: t('upload.cameraDenied.title'), error: t('upload.cameraError.title'), unsupported: t('upload.cameraUnsupported.title'), blocked: t('upload.cameraBlocked.title'), }; const fallbackMessages: Record = { idle: t('upload.cameraDenied.prompt'), prompt: t('upload.cameraDenied.prompt'), granted: '', denied: t('upload.cameraDenied.explanation'), error: t('upload.cameraError.explanation'), unsupported: t('upload.cameraUnsupported.message'), blocked: t('upload.cameraBlocked.message'), }; const title = titles[permissionState]; const description = permissionMessage ?? fallbackMessages[permissionState]; const canRetryCamera = permissionState !== 'unsupported' && permissionState !== 'blocked'; const canRecheckCamera = permissionState === 'blocked'; const helpText = permissionState === 'blocked' ? t('upload.cameraBlocked.hint') : permissionState === 'denied' ? t('upload.cameraDenied.hint') : permissionState === 'error' ? t('upload.cameraError.hint') : null; return (

{title}

{description}

{helpText ? (

{helpText}

) : null}
{canRetryCamera && ( )} {canRecheckCamera && ( )}
); }; const isCountdownActive = mode === 'countdown'; const countdownProgress = preferences.countdownEnabled && preferences.countdownSeconds > 0 ? Math.max(0, Math.min(1, countdownValue / preferences.countdownSeconds)) : 0; const countdownDegrees = Math.round(countdownProgress * 360); const controlIconButtonBase = 'flex h-10 w-10 items-center justify-center rounded-full border border-white/25 bg-white/10 text-white shadow-sm backdrop-blur transition hover:border-white/40 hover:bg-white/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/60'; return renderWithDialog( <>
} className="relative flex min-h-screen flex-col gap-4 pb-[calc(env(safe-area-inset-bottom,0px)+72px)] pt-3" style={bodyFont ? { fontFamily: bodyFont } : undefined} > {taskFloatingCard} {heroOverlay} {uploadsRequireApproval ? (

{t('upload.review.noticeTitle', 'Uploads werden geprüft')}

{t('upload.review.noticeBody', 'Dein Foto erscheint, sobald es freigegeben wurde.')}

) : null}
{socialChips.length > 0 && ( <>
{socialChips.map((chip) => (
{chip.label} {chip.value}
))}
)} {renderPrimer()}
, 'space-y-6 pb-[140px]' ); } function readAsDataUrl(file: File): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result as string); reader.onerror = () => reject(reader.error ?? new Error('Failed to read file')); reader.readAsDataURL(file); }); } function formatRelativeTimeLabel(value: string | null | undefined, t: TranslateFn): string { if (!value) { return t('upload.hud.relative.now'); } const timestamp = new Date(value).getTime(); if (Number.isNaN(timestamp)) { return t('upload.hud.relative.now'); } const diffMinutes = Math.max(0, Math.round((Date.now() - timestamp) / 60000)); if (diffMinutes < 1) { return t('upload.hud.relative.now'); } if (diffMinutes < 60) { return t('upload.hud.relative.minutes').replace('{count}', `${diffMinutes}`); } const hours = Math.round(diffMinutes / 60); if (hours < 24) { return t('upload.hud.relative.hours').replace('{count}', `${hours}`); } const days = Math.round(hours / 24); return t('upload.hud.relative.days').replace('{count}', `${days}`); }