import React from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { AlertCircle, ArrowLeft, CheckCircle2, Circle, Clock3, Loader2, PlugZap, Power, RefreshCw, ShieldCheck, Copy } from 'lucide-react'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { AdminLayout } from '../components/AdminLayout'; import { PhotoboothStatus, TenantEvent, type EventToolkit, type TenantPhoto, disableEventPhotobooth, enableEventPhotobooth, getEvent, getEventPhotoboothStatus, getEventToolkit, rotateEventPhotobooth, } from '../api'; import { isAuthError } from '../auth/tokens'; import { getApiErrorMessage } from '../lib/apiError'; import { ADMIN_EVENTS_PATH, ADMIN_EVENT_VIEW_PATH } from '../constants'; import { buildEventTabs } from '../lib/eventTabs'; type State = { event: TenantEvent | null; status: PhotoboothStatus | null; toolkit: EventToolkit | null; loading: boolean; updating: boolean; error: string | null; }; export default function EventPhotoboothPage() { const { slug } = useParams<{ slug: string }>(); const navigate = useNavigate(); const { t } = useTranslation(['management', 'common']); const [state, setState] = React.useState({ event: null, status: null, toolkit: null, loading: true, updating: false, error: null, }); const load = React.useCallback(async () => { if (!slug) { setState((prev) => ({ ...prev, loading: false, error: t('management.photobooth.errors.missingSlug', 'Kein Event ausgewählt.'), })); return; } setState((prev) => ({ ...prev, loading: true, error: null })); try { const toolkitPromise = getEventToolkit(slug) .then((data) => data) .catch((toolkitError) => { if (!isAuthError(toolkitError)) { console.warn('[Photobooth] Toolkit konnte nicht geladen werden', toolkitError); } return null; }); const [eventData, statusData, toolkitData] = await Promise.all([getEvent(slug), getEventPhotoboothStatus(slug), toolkitPromise]); setState({ event: eventData, status: statusData, toolkit: toolkitData, loading: false, updating: false, error: null, }); } catch (error) { if (!isAuthError(error)) { setState((prev) => ({ ...prev, loading: false, error: getApiErrorMessage(error, t('management.photobooth.errors.loadFailed', 'Photobooth-Link konnte nicht geladen werden.')), })); } else { setState((prev) => ({ ...prev, loading: false })); } } }, [slug, t]); React.useEffect(() => { void load(); }, [load]); async function handleEnable(): Promise { if (!slug) return; setState((prev) => ({ ...prev, updating: true, error: null })); try { const result = await enableEventPhotobooth(slug); setState((prev) => ({ ...prev, status: result, updating: false, })); } catch (error) { if (!isAuthError(error)) { setState((prev) => ({ ...prev, updating: false, error: getApiErrorMessage(error, t('management.photobooth.errors.enableFailed', 'Zugang konnte nicht aktiviert werden.')), })); } else { setState((prev) => ({ ...prev, updating: false })); } } } async function handleRotate(): Promise { if (!slug) return; setState((prev) => ({ ...prev, updating: true, error: null })); try { const result = await rotateEventPhotobooth(slug); setState((prev) => ({ ...prev, status: result, updating: false, })); } catch (error) { if (!isAuthError(error)) { setState((prev) => ({ ...prev, updating: false, error: getApiErrorMessage(error, t('management.photobooth.errors.rotateFailed', 'Zugangsdaten konnten nicht neu generiert werden.')), })); } else { setState((prev) => ({ ...prev, updating: false })); } } } async function handleDisable(options?: { skipConfirm?: boolean }): Promise { if (!slug) return; if (!options?.skipConfirm && !window.confirm(t('management.photobooth.confirm.disable', 'Photobooth-Zugang deaktivieren?'))) { return; } setState((prev) => ({ ...prev, updating: true, error: null })); try { const result = await disableEventPhotobooth(slug); setState((prev) => ({ ...prev, status: result, updating: false, })); } catch (error) { if (!isAuthError(error)) { setState((prev) => ({ ...prev, updating: false, error: getApiErrorMessage(error, t('management.photobooth.errors.disableFailed', 'Zugang konnte nicht deaktiviert werden.')), })); } else { setState((prev) => ({ ...prev, updating: false })); } } } const { event, status, toolkit, loading, updating, error } = state; const title = event ? t('management.photobooth.titleForEvent', { defaultValue: 'Fotobox-Uploads verwalten', event: resolveEventName(event.name) }) : t('management.photobooth.title', 'Fotobox-Uploads'); const subtitle = t( 'management.photobooth.subtitle', 'Erstelle einen einfachen FTP-Link für Photobooth-Software. Rate-Limit: 20 Fotos/Minute.' ); const eventTabs = React.useMemo(() => { if (!event || !slug) { return []; } const translateMenu = (key: string, fallback: string) => t(key, { defaultValue: fallback }); return buildEventTabs(event, translateMenu, { photos: event.photo_count ?? event.pending_photo_count ?? undefined, tasks: event.tasks_count ?? undefined, }); }, [event, slug, t]); const recentPhotos = React.useMemo(() => toolkit?.photos?.recent ?? [], [toolkit?.photos?.recent]); const photoboothRecent = React.useMemo(() => recentPhotos.filter((photo) => photo.ingest_source === 'photobooth'), [recentPhotos]); const effectiveRecentPhotos = React.useMemo( () => (photoboothRecent.length > 0 ? photoboothRecent : recentPhotos), [photoboothRecent, recentPhotos], ); const uploads24h = React.useMemo( () => countUploadsInWindow(effectiveRecentPhotos, 24 * 60 * 60 * 1000), [effectiveRecentPhotos], ); const recentShare = React.useMemo(() => { if (recentPhotos.length === 0) { return null; } const ratio = photoboothRecent.length / recentPhotos.length; return Math.round(ratio * 100); }, [photoboothRecent.length, recentPhotos.length]); const lastUploadAt = React.useMemo(() => { const latestPhoto = selectLatestUpload(effectiveRecentPhotos); if (latestPhoto?.uploaded_at) { return latestPhoto.uploaded_at; } return status?.metrics?.last_upload_at ?? null; }, [effectiveRecentPhotos, status?.metrics?.last_upload_at]); const lastUploadSource: 'photobooth' | 'event' | null = photoboothRecent.length > 0 ? 'photobooth' : recentPhotos.length > 0 ? 'event' : null; const eventUploadsTotal = toolkit?.metrics.uploads_total ?? event?.photo_count ?? 0; const uploadStats = React.useMemo( () => ({ uploads24h, shareRecent: recentShare, totalEventUploads: eventUploadsTotal ?? 0, lastUploadAt, source: lastUploadSource, sampleSize: recentPhotos.length, }), [uploads24h, recentShare, eventUploadsTotal, lastUploadAt, lastUploadSource, recentPhotos.length], ); const actions = (
{slug ? ( ) : null}
); return ( {error ? ( {t('common:messages.error', 'Fehler')} {error} ) : null} {loading ? ( ) : (
handleDisable({ skipConfirm: true })} onRotate={handleRotate} />
)}
); } function resolveEventName(name: TenantEvent['name']): string { if (typeof name === 'string') { return name; } if (name && typeof name === 'object') { return Object.values(name)[0] ?? 'Event'; } return 'Event'; } function PhotoboothSkeleton() { return (
{Array.from({ length: 3 }).map((_, idx) => (
))}
); } type ModePresetsCardProps = { status: PhotoboothStatus | null; updating: boolean; onEnable: () => Promise; onDisable: () => Promise; onRotate: () => Promise; }; function ModePresetsCard({ status, updating, onEnable, onDisable, onRotate }: ModePresetsCardProps) { const { t } = useTranslation('management'); const activePreset: 'plan' | 'live' = status?.enabled ? 'live' : 'plan'; const [selectedPreset, setSelectedPreset] = React.useState<'plan' | 'live'>(activePreset); React.useEffect(() => { setSelectedPreset(activePreset); }, [activePreset]); const presets = React.useMemo( () => [ { key: 'plan' as const, title: t('photobooth.presets.planTitle', 'Planungsmodus'), description: t('photobooth.presets.planDescription', 'Zugang bleibt deaktiviert, um Tests vorzubereiten.'), badge: t('photobooth.presets.badgePlan', 'Planung'), icon: , }, { key: 'live' as const, title: t('photobooth.presets.liveTitle', 'Live-Modus'), description: t('photobooth.presets.liveDescription', 'FTP ist aktiv und Uploads werden direkt entgegen genommen.'), badge: t('photobooth.presets.badgeLive', 'Live'), icon: , }, ], [t], ); const handleApply = React.useCallback(() => { if (selectedPreset === activePreset) { return; } if (selectedPreset === 'live') { void onEnable(); } else { void onDisable(); } }, [activePreset, onDisable, onEnable, selectedPreset]); return ( {t('photobooth.presets.title', 'Modus wählen')} {t('photobooth.presets.description', 'Passe die Photobooth an Vorbereitung oder Live-Betrieb an.')}
{presets.map((preset) => { const isSelected = selectedPreset === preset.key; const isActive = activePreset === preset.key; return ( ); })}
); } type UploadStatsCardProps = { stats: { uploads24h: number; shareRecent: number | null; totalEventUploads: number; lastUploadAt: string | null; source: 'photobooth' | 'event' | null; sampleSize: number; }; }; function UploadStatsCard({ stats }: UploadStatsCardProps) { const { t } = useTranslation('management'); const lastUploadLabel = stats.lastUploadAt ? formatPhotoboothDate(stats.lastUploadAt) : t('photobooth.stats.none', 'Noch keine Uploads'); const shareLabel = stats.shareRecent != null ? `${stats.shareRecent}%` : '—'; const sourceLabel = stats.source === 'photobooth' ? t('photobooth.stats.sourcePhotobooth', 'Quelle: Photobooth') : stats.source === 'event' ? t('photobooth.stats.sourceEvent', 'Quelle: Event') : null; return ( {t('photobooth.stats.title', 'Upload-Status')} {t('photobooth.stats.description', 'Fokussiere deine Photobooth-Uploads der letzten Stunden.')}
{t('photobooth.stats.lastUpload', 'Letzter Upload')} {lastUploadLabel}
{sourceLabel ?

{sourceLabel}

: null}
{t('photobooth.stats.uploads24h', 'Uploads (24h)')} {stats.uploads24h}
{t('photobooth.stats.share', 'Anteil Photobooth (letzte Uploads)')} {shareLabel}
{t('photobooth.stats.totalEvent', 'Uploads gesamt (Event)')} {stats.totalEventUploads}
{t('photobooth.stats.sample', 'Analysierte Uploads')} {stats.sampleSize}
); } function SetupChecklistCard({ status }: { status: PhotoboothStatus | null }) { const { t } = useTranslation('management'); const steps = [ { key: 'enable', label: t('photobooth.checklist.enable', 'Zugang aktivieren'), description: t('photobooth.checklist.enableCopy', 'Aktiviere den FTP-Account für eure Photobooth-Software.'), done: Boolean(status?.enabled), }, { key: 'share', label: t('photobooth.checklist.share', 'Zugang teilen'), description: t('photobooth.checklist.shareCopy', 'Übergib Host, Benutzer & Passwort an den Betreiber.'), done: Boolean(status?.username && status?.password), }, { key: 'monitor', label: t('photobooth.checklist.monitor', 'Uploads beobachten'), description: t('photobooth.checklist.monitorCopy', 'Verfolge Uploads & Limits direkt im Dashboard.'), done: Boolean(status?.status === 'active'), }, ]; return ( {t('photobooth.checklist.title', 'Setup-Checkliste')} {t('photobooth.checklist.description', 'Erledige jeden Schritt, bevor Gäste Zugang erhalten.')} {steps.map((step) => (
{step.done ? ( ) : ( )}

{step.label}

{step.description}

))}
); } function StatusCard({ status }: { status: PhotoboothStatus | null }) { const { t } = useTranslation('management'); const isActive = Boolean(status?.enabled); const badgeColor = isActive ? 'bg-emerald-600 text-white' : 'bg-slate-300 text-slate-800'; const icon = isActive ? : ; return (
{t('photobooth.status.heading', 'Status')} {isActive ? t('photobooth.status.active', 'Photobooth-Link ist aktiv.') : t('photobooth.status.inactive', 'Noch keine Photobooth-Uploads angebunden.')}
{icon} {isActive ? t('photobooth.status.badgeActive', 'AKTIV') : t('photobooth.status.badgeInactive', 'INAKTIV')}
{status?.expires_at ? ( {t('photobooth.status.expiresAt', 'Automatisches Abschalten am {{date}}', { date: new Date(status.expires_at).toLocaleString(), })} ) : null}
); } type CredentialCardProps = { status: PhotoboothStatus | null; updating: boolean; onEnable: () => Promise; onRotate: () => Promise; onDisable: () => Promise; }; function CredentialsCard({ status, updating, onEnable, onRotate, onDisable }: CredentialCardProps) { const { t } = useTranslation('management'); const isActive = Boolean(status?.enabled); return ( {t('photobooth.credentials.heading', 'FTP-Zugangsdaten')} {t( 'photobooth.credentials.description', 'Teile die Zugangsdaten mit der Photobooth-Software. Passwörter werden max. 8 Zeichen lang generiert.' )}
{isActive ? ( <> ) : ( )}
); } function StatusTimelineCard({ status, lastUploadAt }: { status: PhotoboothStatus | null; lastUploadAt?: string | null }) { const { t } = useTranslation('management'); const entries = [ { title: t('photobooth.timeline.activation', 'Freischaltung'), body: status?.enabled ? t('photobooth.timeline.activationReady', 'Zugang ist aktiv.') : t('photobooth.timeline.activationPending', 'Noch nicht aktiviert.'), }, { title: t('photobooth.timeline.credentials', 'Zugangsdaten'), body: status?.username ? t('photobooth.timeline.credentialsReady', { defaultValue: 'Benutzer {{username}} ist bereit.', username: status.username }) : t('photobooth.timeline.credentialsPending', 'Noch keine Logindaten generiert.'), }, { title: t('photobooth.timeline.expiry', 'Ablauf'), body: status?.expires_at ? t('photobooth.timeline.expiryHint', { defaultValue: 'Automatisches Abschalten am {{date}}', date: formatPhotoboothDate(status.expires_at) }) : t('photobooth.timeline.noExpiry', 'Noch kein Ablaufdatum gesetzt.'), }, { title: t('photobooth.timeline.lastUpload', 'Letzter Upload'), body: lastUploadAt ? t('photobooth.timeline.lastUploadAt', { defaultValue: 'Zuletzt am {{date}}', date: formatPhotoboothDate(lastUploadAt) }) : t('photobooth.timeline.lastUploadPending', 'Noch keine Uploads registriert.'), }, ]; return ( {t('photobooth.timeline.title', 'Status-Timeline')} {entries.map((entry, index) => (
{index < entries.length - 1 ? : null}

{entry.title}

{entry.body}

))}
); } function RateLimitCard({ status, uploadsLastHour }: { status: PhotoboothStatus | null; uploadsLastHour: number | null }) { const { t } = useTranslation('management'); const rateLimit = status?.rate_limit_per_minute ?? 20; const usage = uploadsLastHour != null ? Math.max(0, uploadsLastHour) : null; const usageRatio = usage !== null && rateLimit > 0 ? usage / rateLimit : null; const showWarning = usageRatio !== null && usageRatio >= 0.8; return (
{t('photobooth.rateLimit.heading', 'Sicherheit & Limits')} {t('photobooth.rateLimit.description', 'Uploads werden strikt auf {{count}} Fotos pro Minute begrenzt.', { count: rateLimit, })}

{t( 'photobooth.rateLimit.body', 'Bei Überschreitung wird die Verbindung hart geblockt. Nach 60 Sekunden wird der Zugang automatisch wieder freigegeben.' )}

{t('photobooth.rateLimit.usage', 'Uploads letzte Stunde')} {usage !== null ? `${usage}/${rateLimit}` : `≤ ${rateLimit}`}
{showWarning ? (

{t('photobooth.rateLimit.warning', 'Kurz vor dem Limit – bitte Upload-Takt reduzieren oder Support informieren.')}

) : null}

{t( 'photobooth.rateLimit.hint', 'Ablaufzeit stimmt mit dem Event-Ende überein. Nach Ablauf wird der Account automatisch entfernt.' )}

); } type FieldProps = { label: string; value: string; copyable?: boolean; sensitive?: boolean; className?: string; }; function Field({ label, value, copyable, sensitive, className }: FieldProps) { const [copied, setCopied] = React.useState(false); const showValue = sensitive && value && value !== '—' ? '•'.repeat(Math.min(6, value.length)) : value; async function handleCopy() { if (!copyable || !value || value === '—') return; await navigator.clipboard.writeText(value); setCopied(true); setTimeout(() => setCopied(false), 1500); } return (

{label}

{showValue} {copyable ? ( ) : null}
); } function formatPhotoboothDate(iso: string | null): string { if (!iso) { return '—'; } const date = new Date(iso); if (Number.isNaN(date.getTime())) { return '—'; } return date.toLocaleString(undefined, { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', }); } function countUploadsInWindow(photos: TenantPhoto[], windowMs: number): number { if (!photos.length) { return 0; } const now = Date.now(); return photos.reduce((total, photo) => { if (!photo.uploaded_at) { return total; } const timestamp = Date.parse(photo.uploaded_at); if (Number.isNaN(timestamp)) { return total; } return now - timestamp <= windowMs ? total + 1 : total; }, 0); } function selectLatestUpload(photos: TenantPhoto[]): TenantPhoto | null { let latest: TenantPhoto | null = null; let bestTime = -Infinity; photos.forEach((photo) => { if (!photo.uploaded_at) { return; } const timestamp = Date.parse(photo.uploaded_at); if (!Number.isNaN(timestamp) && timestamp > bestTime) { latest = photo; bestTime = timestamp; } }); return latest; }