// @ts-nocheck import React from 'react'; import { useNavigate, useLocation } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { Camera, AlertTriangle, Sparkles, CalendarDays, Plus, Settings, QrCode, ClipboardList, Package as PackageIcon, } from 'lucide-react'; import toast from 'react-hot-toast'; 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { TenantOnboardingChecklistCard, SectionCard, SectionHeader, ActionGrid, } from '../components/tenant'; import type { ChecklistStep } from '../components/tenant'; import { AdminLayout } from '../components/AdminLayout'; import { DashboardSummary, getDashboardSummary, getEvents, getTenantPackagesOverview, TenantEvent, TenantPackageSummary, } from '../api'; import { isAuthError } from '../auth/tokens'; import { useAuth } from '../auth/context'; import { useEventContext } from '../context/EventContext'; import { adminPath, ADMIN_HOME_PATH, ADMIN_EVENT_VIEW_PATH, ADMIN_EVENTS_PATH, ADMIN_EVENT_PHOTOS_PATH, ADMIN_EVENT_INVITES_PATH, ADMIN_EVENT_TASKS_PATH, ADMIN_EVENT_PHOTOBOOTH_PATH, ADMIN_BILLING_PATH, ADMIN_SETTINGS_PATH, ADMIN_WELCOME_BASE_PATH, ADMIN_EVENT_CREATE_PATH, buildEngagementTabPath, } from '../constants'; import { useOnboardingProgress } from '../onboarding'; import { buildLimitWarnings } from '../lib/limitWarnings'; import { DashboardEventFocusCard } from '../components/dashboard/DashboardEventFocusCard'; import type { LimitUsageSummary, GallerySummary } from '../lib/limitWarnings'; interface DashboardState { summary: DashboardSummary | null; events: TenantEvent[]; activePackage: TenantPackageSummary | null; loading: boolean; errorKey: string | null; } type ReadinessState = { hasEvent: boolean; hasTasks: boolean; hasQrInvites: boolean; hasPackage: boolean; primaryEventSlug: string | null; primaryEventName: string | null; loading: boolean; }; export default function DashboardPage() { const navigate = useNavigate(); const location = useLocation(); const { user } = useAuth(); const { events: ctxEvents, activeEvent: ctxActiveEvent, selectEvent } = useEventContext(); const { progress, markStep } = useOnboardingProgress(); const { t, i18n } = useTranslation('dashboard', { keyPrefix: 'dashboard' }); const { t: tc } = useTranslation('common'); const translate = React.useCallback( (key: string, optionsOrFallback?: Record | string, explicitFallback?: string) => { const hasOptions = typeof optionsOrFallback === 'object' && optionsOrFallback !== null; const options = hasOptions ? (optionsOrFallback as Record) : undefined; const fallback = typeof optionsOrFallback === 'string' ? optionsOrFallback : explicitFallback; const value = t(key, { defaultValue: fallback, ...(options ?? {}) }); if (value === `dashboard.${key}`) { const fallbackValue = i18n.t(`dashboard:${key}`, { defaultValue: fallback, ...(options ?? {}) }); if (fallbackValue !== `dashboard:${key}`) { return fallbackValue; } if (fallback !== undefined) { return fallback; } } return value; }, [t, i18n], ); const [state, setState] = React.useState({ summary: null, events: [], activePackage: null, loading: true, errorKey: null, }); const [readiness, setReadiness] = React.useState({ hasEvent: false, hasTasks: false, hasQrInvites: false, hasPackage: false, primaryEventSlug: null, primaryEventName: null, loading: false, }); React.useEffect(() => { let cancelled = false; (async () => { try { const [summary, events, packages] = await Promise.all([ getDashboardSummary().catch(() => null), getEvents({ force: true }).catch(() => [] as TenantEvent[]), getTenantPackagesOverview().catch(() => ({ packages: [], activePackage: null })), ]); if (cancelled) { return; } const fallbackSummary = buildSummaryFallback(events, packages.activePackage); const eventPool = events.length ? events : ctxEvents; const primaryEvent = ctxActiveEvent ?? eventPool[0] ?? null; const primaryEventName = primaryEvent ? resolveEventName(primaryEvent.name, primaryEvent.slug) : null; setReadiness({ hasEvent: eventPool.length > 0, hasTasks: primaryEvent ? (Number(primaryEvent.tasks_count ?? 0) > 0) : false, hasQrInvites: primaryEvent ? Number( primaryEvent.active_invites_count ?? primaryEvent.active_join_tokens_count ?? 0 ) > 0 : false, hasPackage: Boolean(packages.activePackage), primaryEventSlug: primaryEvent?.slug ?? null, primaryEventName, loading: false, }); setState({ summary: summary ?? fallbackSummary, events: eventPool, activePackage: packages.activePackage, loading: false, errorKey: null, }); if (!primaryEvent && !cancelled) { setReadiness((prev) => ({ ...prev, hasTasks: false, hasQrInvites: false, loading: false, })); } } catch (error) { if (!isAuthError(error)) { setState((prev) => ({ ...prev, errorKey: 'loadFailed', loading: false, })); } } })(); return () => { cancelled = true; }; }, []); const { summary, events, activePackage, loading, errorKey } = state; React.useEffect(() => { if (loading) { return; } if (events.length > 0 && !progress.eventCreated) { const primary = events[0]; markStep({ eventCreated: true, serverStep: 'event_created', meta: primary ? { event_id: primary.id } : undefined, }); } }, [loading, events, events.length, progress.eventCreated, navigate, location.pathname, markStep]); const greetingName = user?.name ?? translate('welcome.fallbackName'); const greetingTitle = translate('welcome.greeting', { name: greetingName }); const subtitle = translate('welcome.subtitle'); const errorMessage = errorKey ? translate(`errors.${errorKey}`) : null; const dateLocale = i18n.language?.startsWith('en') ? 'en-GB' : 'de-DE'; const canCreateEvent = React.useMemo(() => { if (!activePackage) { return true; } if (activePackage.remaining_events === null || activePackage.remaining_events === undefined) { return true; } return activePackage.remaining_events > 0; }, [activePackage]); const eventOptions = ctxEvents.length ? ctxEvents : events; const [selectedSlug, setSelectedSlug] = React.useState(() => ctxActiveEvent?.slug ?? eventOptions[0]?.slug ?? null); React.useEffect(() => { setSelectedSlug(ctxActiveEvent?.slug ?? eventOptions[0]?.slug ?? null); }, [ctxActiveEvent?.slug, eventOptions]); const upcomingEvents = getUpcomingEvents(eventOptions); const publishedEvents = eventOptions.filter((event) => event.status === 'published'); const primaryEvent = React.useMemo( () => eventOptions.find((event) => event.slug === selectedSlug) ?? eventOptions[0] ?? null, [eventOptions, selectedSlug], ); const primaryEventName = primaryEvent ? resolveEventName(primaryEvent.name, primaryEvent.slug) : null; const singleEvent = eventOptions.length === 1 ? eventOptions[0] : null; const singleEventName = singleEvent ? resolveEventName(singleEvent.name, singleEvent.slug) : null; const singleEventDateLabel = singleEvent?.event_date ? formatDate(singleEvent.event_date, dateLocale) : null; const primaryEventLimits = primaryEvent?.limits ?? null; const limitTranslate = React.useCallback( (key: string, options?: Record) => tc(`limits.${key}`, options), [tc], ); const limitWarnings = React.useMemo( () => buildLimitWarnings(primaryEventLimits, limitTranslate), [primaryEventLimits, limitTranslate], ); const shownToastsRef = React.useRef>(new Set()); React.useEffect(() => { limitWarnings.forEach((warning) => { const toastKey = `${warning.id}-${warning.message}`; if (shownToastsRef.current.has(toastKey)) { return; } shownToastsRef.current.add(toastKey); toast(warning.message, { icon: warning.tone === 'danger' ? '🚨' : '⚠️', id: toastKey, }); }); }, [limitWarnings]); const limitScopeLabels = React.useMemo( () => ({ photos: tc('limits.photosTitle'), guests: tc('limits.guestsTitle'), gallery: tc('limits.galleryTitle'), }), [tc], ); const hasPhotos = React.useMemo(() => { if ((summary?.new_photos ?? 0) > 0) { return true; } return events.some((event) => Number(event.photo_count ?? 0) > 0 || Number(event.pending_photo_count ?? 0) > 0); }, [summary, events]); const primaryEventSlug = readiness.primaryEventSlug; const liveEvents = React.useMemo(() => { const now = Date.now(); const windowLengthMs = 2 * 24 * 60 * 60 * 1000; // event day + following day return events.filter((event) => { if (!event.slug) { return false; } const isActivated = Boolean(event.is_active || event.status === 'published'); if (!isActivated) { return false; } if (!event.event_date) { return true; } const eventStart = new Date(event.event_date).getTime(); if (Number.isNaN(eventStart)) { return true; } return now >= eventStart && now <= eventStart + windowLengthMs; }); }, [events]); const onboardingChecklist = React.useMemo(() => { const steps: ChecklistStep[] = [ { key: 'admin_app', title: translate('onboarding.admin_app.title', 'Admin-App öffnen'), description: translate( 'onboarding.admin_app.description', 'Verwalte Events, Uploads und Gäste direkt in der Admin-App.' ), done: Boolean(progress.adminAppOpenedAt), ctaLabel: translate('onboarding.admin_app.cta', 'Admin-App starten'), onAction: () => navigate(ADMIN_HOME_PATH), icon: Sparkles, }, { key: 'event_setup', title: translate('onboarding.event_setup.title', 'Erstes Event vorbereiten'), description: translate( 'onboarding.event_setup.description', 'Lege in der Admin-App Name, Datum und Aufgaben fest.' ), done: readiness.hasEvent, ctaLabel: translate('onboarding.event_setup.cta', 'Event anlegen'), onAction: () => navigate(ADMIN_EVENT_CREATE_PATH), icon: CalendarDays, }, { key: 'invite_guests', title: translate('onboarding.invite_guests.title', 'Gäste einladen'), description: translate( 'onboarding.invite_guests.description', 'Teile QR-Codes oder Links, damit Gäste sofort starten.' ), done: readiness.hasQrInvites || progress.inviteCreated, ctaLabel: translate('onboarding.invite_guests.cta', 'QR-Links öffnen'), onAction: () => { if (primaryEventSlug) { navigate(`${ADMIN_EVENT_VIEW_PATH(primaryEventSlug)}#qr-invites`); return; } navigate(ADMIN_EVENTS_PATH); }, icon: QrCode, }, { key: 'collect_photos', title: translate('onboarding.collect_photos.title', 'Erste Fotos einsammeln'), description: translate( 'onboarding.collect_photos.description', 'Sobald Uploads eintreffen, moderierst du sie in der Admin-App.' ), done: hasPhotos, ctaLabel: translate('onboarding.collect_photos.cta', 'Uploads prüfen'), onAction: () => { if (primaryEventSlug) { navigate(ADMIN_EVENT_PHOTOS_PATH(primaryEventSlug)); return; } navigate(ADMIN_EVENTS_PATH); }, icon: Camera, }, { key: 'branding', title: translate('onboarding.branding.title', 'Branding & Aufgaben verfeinern'), description: translate( 'onboarding.branding.description', 'Passt Farbwelt und Aufgabenpakete an euren Anlass an.' ), done: (progress.brandingConfigured || readiness.hasTasks) && (readiness.hasPackage || progress.packageSelected), ctaLabel: translate('onboarding.branding.cta', 'Branding öffnen'), onAction: () => { if (primaryEventSlug) { navigate(ADMIN_EVENT_INVITES_PATH(primaryEventSlug)); return; } navigate(ADMIN_EVENTS_PATH); }, icon: ClipboardList, }, ]; return steps; }, [ translate, progress.adminAppOpenedAt, progress.inviteCreated, progress.brandingConfigured, progress.packageSelected, readiness.hasEvent, readiness.hasQrInvites, readiness.hasTasks, readiness.hasPackage, hasPhotos, navigate, primaryEventSlug, ]); const completedOnboardingSteps = React.useMemo( () => onboardingChecklist.filter((step) => step.done).length, [onboardingChecklist] ); const onboardingCompletion = React.useMemo(() => { if (onboardingChecklist.length === 0) { return 0; } return Math.round((completedOnboardingSteps / onboardingChecklist.length) * 100); }, [completedOnboardingSteps, onboardingChecklist]); const onboardingCardTitle = translate('onboarding.card.title', 'Dein Start in fünf Schritten'); const onboardingCardDescription = translate( 'onboarding.card.description', 'Bearbeite die Schritte in der Admin-App – das Dashboard zeigt dir den Status.' ); const onboardingCompletedCopy = translate( 'onboarding.card.completed', 'Alle Schritte abgeschlossen – großartig! Du kannst jederzeit zur Admin-App wechseln.' ); const onboardingFallbackCta = translate('onboarding.card.cta_fallback', 'Jetzt starten'); const readinessCompleteLabel = translate('readiness.complete', 'Erledigt'); const readinessPendingLabel = translate('readiness.pending', 'Noch offen'); const hasEventContext = readiness.hasEvent; const quickActionItems = React.useMemo( () => [ { key: 'create', label: translate('quickActions.createEvent.label'), description: translate('quickActions.createEvent.description'), icon: , onClick: () => { if (!canCreateEvent) { toast.error(tc('errors.eventLimit', 'Dein aktuelles Paket enthält keine freien Event-Slots mehr.')); navigate(ADMIN_BILLING_PATH); return; } navigate(ADMIN_EVENT_CREATE_PATH); }, disabled: !canCreateEvent, }, { key: 'photos', label: translate('quickActions.moderatePhotos.label'), description: translate('quickActions.moderatePhotos.description'), icon: , onClick: () => navigate(ADMIN_EVENTS_PATH), disabled: !hasEventContext, }, { key: 'tasks', label: translate('quickActions.organiseTasks.label'), description: translate('quickActions.organiseTasks.description'), icon: , onClick: () => navigate(buildEngagementTabPath('tasks')), disabled: !hasEventContext, }, { key: 'packages', label: translate('quickActions.managePackages.label'), description: translate('quickActions.managePackages.description'), icon: , onClick: () => navigate(ADMIN_BILLING_PATH), }, ], [translate, navigate, hasEventContext], ); const adminTitle = singleEventName ?? greetingTitle; const adminSubtitle = singleEvent ? translate('overview.eventHero.subtitle', { defaultValue: 'Alle Funktionen konzentrieren sich auf dieses Event.', date: singleEventDateLabel ?? translate('overview.eventHero.noDate', 'Noch kein Datum festgelegt'), }) : subtitle; const focusActions = React.useMemo( () => ({ createEvent: () => navigate(ADMIN_EVENT_CREATE_PATH), openEvent: () => { if (primaryEvent?.slug) { navigate(ADMIN_EVENT_VIEW_PATH(primaryEvent.slug)); } else { navigate(ADMIN_EVENTS_PATH); } }, openPhotos: () => { if (primaryEventSlug) { navigate(ADMIN_EVENT_PHOTOS_PATH(primaryEventSlug)); return; } navigate(ADMIN_EVENTS_PATH); }, openInvites: () => { if (primaryEventSlug) { navigate(ADMIN_EVENT_INVITES_PATH(primaryEventSlug)); return; } navigate(ADMIN_EVENTS_PATH); }, openTasks: () => { if (primaryEventSlug) { navigate(ADMIN_EVENT_TASKS_PATH(primaryEventSlug)); return; } navigate(ADMIN_EVENTS_PATH); }, openPhotobooth: () => { if (primaryEventSlug) { navigate(ADMIN_EVENT_PHOTOBOOTH_PATH(primaryEventSlug)); return; } navigate(ADMIN_EVENTS_PATH); }, }), [navigate, primaryEvent, primaryEventSlug], ); return ( {errorMessage && ( {t('dashboard.alerts.errorTitle')} {errorMessage} )} {loading ? ( ) : ( <>
{eventOptions.length > 1 ? ( ) : null}
{primaryEventLimits ? (
{translate('limitsCard.title')} {primaryEventName ? translate('limitsCard.description', { name: primaryEventName }) : translate('limitsCard.descriptionFallback')}
{primaryEventName ?? translate('limitsCard.descriptionFallback')}
{limitWarnings.length > 0 && (
{limitWarnings.map((warning) => ( {limitScopeLabels[warning.scope]} {warning.message} ))}
)}
) : null}

{translate('upcoming.title')}

{translate('upcoming.description')}

{upcomingEvents.length === 0 ? ( navigate(adminPath('/events/new'))} /> ) : ( upcomingEvents.map((event) => ( navigate(ADMIN_EVENT_VIEW_PATH(event.slug))} locale={dateLocale} labels={{ live: translate('upcoming.status.live'), planning: translate('upcoming.status.planning'), open: tc('actions.open'), noDate: translate('upcoming.status.noDate'), }} /> )) )}
)}
); } function formatDate(value: string | null, locale: string): string | null { if (!value) { return null; } const date = new Date(value); if (Number.isNaN(date.getTime())) { return null; } try { return new Intl.DateTimeFormat(locale, { day: '2-digit', month: 'short', year: 'numeric', }).format(date); } catch { return date.toISOString().slice(0, 10); } } function resolveEventName(name: TenantEvent['name'], fallbackSlug: string): string { if (typeof name === 'string' && name.trim().length > 0) { return name; } if (name && typeof name === 'object') { if (typeof name.de === 'string' && name.de.trim().length > 0) { return name.de; } if (typeof name.en === 'string' && name.en.trim().length > 0) { return name.en; } const first = Object.values(name).find((value) => typeof value === 'string' && value.trim().length > 0); if (typeof first === 'string') { return first; } } return fallbackSlug || 'Event'; } function buildSummaryFallback( events: TenantEvent[], activePackage: TenantPackageSummary | null ): DashboardSummary { const activeEvents = events.filter((event) => Boolean(event.is_active || event.status === 'published')); const totalPhotos = events.reduce((sum, event) => sum + Number(event.photo_count ?? 0), 0); return { active_events: activeEvents.length, new_photos: totalPhotos, task_progress: 0, upcoming_events: activeEvents.length, active_package: activePackage ? { name: activePackage.package_name, remaining_events: activePackage.remaining_events, expires_at: activePackage.expires_at, } : null, }; } function getUpcomingEvents(events: TenantEvent[]): TenantEvent[] { const now = new Date(); return events .filter((event) => { if (!event.event_date) return false; const date = new Date(event.event_date); return !Number.isNaN(date.getTime()) && date >= now; }) .sort((a, b) => { const dateA = a.event_date ? new Date(a.event_date).getTime() : 0; const dateB = b.event_date ? new Date(b.event_date).getTime() : 0; return dateA - dateB; }) .slice(0, 4); } function LimitUsageRow({ label, summary, unlimitedLabel, usageLabel, remainingLabel, }: { label: string; summary: LimitUsageSummary | null; unlimitedLabel: string; usageLabel: string; remainingLabel: string; }) { if (!summary) { return (
{label} {unlimitedLabel}

{unlimitedLabel}

); } const limit = typeof summary.limit === 'number' && summary.limit > 0 ? summary.limit : null; const percent = limit ? Math.min(100, Math.round((summary.used / limit) * 100)) : 0; const remaining = typeof summary.remaining === 'number' ? summary.remaining : null; const barClass = summary.state === 'limit_reached' ? 'bg-rose-500' : summary.state === 'warning' ? 'bg-amber-500' : 'bg-emerald-500'; return (
{label} {limit ? usageLabel.replace('{{used}}', `${summary.used}`).replace('{{limit}}', `${limit}`) : unlimitedLabel}
{limit ? ( <>
{remaining !== null ? (

{remainingLabel .replace('{{remaining}}', `${Math.max(0, remaining)}`) .replace('{{limit}}', `${limit}`)}

) : null} ) : (

{unlimitedLabel}

)}
); } function GalleryStatusRow({ label, summary, locale, messages, }: { label: string; summary: GallerySummary | null; locale: string; messages: { expired: string; noExpiry: string; expires: string }; }) { const expiresAt = summary?.expires_at ? formatDate(summary.expires_at, locale) : null; let statusLabel = messages.noExpiry; let badgeClass = 'bg-emerald-500/20 text-emerald-700'; if (summary?.state === 'expired') { statusLabel = messages.expired; badgeClass = 'bg-rose-500/20 text-rose-700'; } else if (summary?.state === 'warning') { const days = Math.max(0, summary.days_remaining ?? 0); statusLabel = `${messages.expires.replace('{{date}}', expiresAt ?? '')} (${days}d)`; badgeClass = 'bg-amber-500/20 text-amber-700'; } else if (summary?.state === 'ok' && expiresAt) { statusLabel = messages.expires.replace('{{date}}', expiresAt); } return (
{label} {statusLabel}
); } function UpcomingEventRow({ event, onView, locale, labels, }: { event: TenantEvent; onView: () => void; locale: string; labels: { live: string; planning: string; open: string; noDate: string; }; }) { const date = event.event_date ? new Date(event.event_date) : null; const formattedDate = date ? date.toLocaleDateString(locale, { day: '2-digit', month: 'short', year: 'numeric' }) : labels.noDate; return (
{formattedDate}
{event.status === 'published' ? labels.live : labels.planning}
); } function EmptyState({ message, ctaLabel, onCta }: { message: string; ctaLabel: string; onCta: () => void }) { return (

{message}

); } function DashboardSkeleton() { return (
{Array.from({ length: 3 }).map((_, index) => (
{Array.from({ length: 4 }).map((__ , cardIndex) => (
))}
))}
); }