completed the frontend dashboard component and bound it to the tenant admin pwa for the optimal onboarding experience.. Added a profile page.

This commit is contained in:
Codex Agent
2025-11-04 22:28:37 +01:00
parent fe380689fb
commit b32413b108
29 changed files with 1416 additions and 425 deletions

View File

@@ -1,21 +1,20 @@
import { useMemo, useState } from 'react';
import { AlertTriangle, CalendarDays, Camera, ClipboardList, Package, Sparkles, TrendingUp, UserRound, Key } from 'lucide-react';
import { useCallback, useMemo, useState, type ReactNode } from 'react';
import { AlertTriangle, CalendarDays, Camera, ClipboardList, Key, Package, Smartphone, Sparkles, TrendingUp, UserRound } from 'lucide-react';
import { Head, Link, router, usePage } from '@inertiajs/react';
import AppLayout from '@/layouts/app-layout';
import { dashboard } from '@/routes';
import { edit as passwordSettings } from '@/routes/password';
import profileRoutes from '@/routes/profile';
import { send as resendVerificationRoute } from '@/routes/verification';
import { type BreadcrumbItem, type SharedData } from '@/types';
import { type SharedData } from '@/types';
import { DashboardLanguageSwitcher } from '@/components/dashboard-language-switcher';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Progress } from '@/components/ui/progress';
import { Separator } from '@/components/ui/separator';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
type DashboardMetrics = {
@@ -26,11 +25,12 @@ type DashboardMetrics = {
upcoming_events: number;
new_photos: number;
task_progress: number;
credit_balance: number | null;
active_package: {
name: string;
expires_at: string | null;
remaining_events: number | null;
price: number | null;
is_active?: boolean;
} | null;
};
@@ -53,12 +53,21 @@ type DashboardPurchase = {
purchasedAt: string | null;
type: string | null;
provider: string | null;
source?: string | null;
};
type DashboardOnboardingStep = {
key: string;
title: string;
description: string;
done: boolean;
cta?: string | null;
ctaLabel?: string | null;
};
type TenantSummary = {
id: number;
name: string;
eventCreditsBalance: number | null;
subscriptionStatus: string | null;
subscriptionExpiresAt: string | null;
activePackage: {
@@ -66,6 +75,7 @@ type TenantSummary = {
price: number | null;
expiresAt: string | null;
remainingEvents: number | null;
isActive?: boolean;
} | null;
} | null;
@@ -79,21 +89,66 @@ type DashboardPageProps = {
mustVerify: boolean;
verified: boolean;
};
onboarding: DashboardOnboardingStep[];
};
const breadcrumbs: BreadcrumbItem[] = [
{
title: 'Dashboard',
href: dashboard().url,
},
];
const getNestedValue = (source: unknown, path: string): unknown => {
if (!source || typeof source !== 'object') {
return undefined;
}
return path.split('.').reduce<unknown>((accumulator, segment) => {
if (accumulator && typeof accumulator === 'object' && segment in (accumulator as Record<string, unknown>)) {
return (accumulator as Record<string, unknown>)[segment];
}
return undefined;
}, source);
};
export default function Dashboard() {
const { metrics, upcomingEvents, recentPurchases, latestPurchase, tenant, emailVerification, locale } = usePage<SharedData & DashboardPageProps>().props;
const page = usePage<SharedData & DashboardPageProps>();
const { metrics, upcomingEvents, recentPurchases, latestPurchase, tenant, emailVerification, locale, onboarding } = page.props;
const { auth, supportedLocales } = page.props;
const translations = (page.props.translations?.dashboard ?? {}) as Record<string, unknown>;
const [verificationSent, setVerificationSent] = useState(false);
const [sendingVerification, setSendingVerification] = useState(false);
const translate = useCallback(
(path: string, fallback: string): string => {
const value = getNestedValue(translations, path);
return typeof value === 'string' && value.length > 0 ? value : fallback;
},
[translations],
);
const formatMessage = useCallback(
(path: string, fallback: string, replacements: Record<string, string | number> = {}) => {
const template = translate(path, fallback);
return Object.entries(replacements).reduce((carry, [token, value]) => {
return carry.replace(new RegExp(`:${token}`, 'g'), String(value));
}, template);
},
[translate],
);
const user = auth?.user;
const greetingName = typeof user?.name === 'string' && user.name.trim() !== '' ? user.name : 'Fotospiel-Team';
const greetingTitle = formatMessage('hero.title', 'Willkommen zurück, :name!', { name: greetingName });
const heroBadge = translate('hero.badge', 'Kundenbereich');
const heroSubtitle = translate(
'hero.subtitle',
'Dein Überblick über Pakete, Rechnungen und Fortschritt für alle Details öffne die Admin-App.',
);
const heroDescription = translate(
'hero.description',
'Nutze das Dashboard für Überblick, Abrechnung und Insights die Admin-App begleitet dich bei allen operativen Aufgaben vor Ort.',
);
const needsEmailVerification = emailVerification.mustVerify && !emailVerification.verified;
const taskProgress = metrics?.task_progress ?? 0;
const dateFormatter = useMemo(() => new Intl.DateTimeFormat(locale ?? 'de-DE', {
day: '2-digit',
@@ -107,120 +162,110 @@ export default function Dashboard() {
maximumFractionDigits: 2,
}), [locale]);
const taskProgress = metrics?.task_progress ?? 0;
const onboardingSteps = useMemo(() => onboarding ?? [], [onboarding]);
const checklistItems = [
{
key: 'verify-email',
title: 'E-Mail-Adresse bestätigen',
description: 'Bestätige deine Adresse, um Einladungen zu versenden und Benachrichtigungen zu erhalten.',
done: !needsEmailVerification,
},
{
key: 'create-event',
title: 'Dein erstes Event erstellen',
description: 'Starte mit einem Event-Blueprint und passe Agenda, Uploadregeln und Branding an.',
done: (metrics?.total_events ?? 0) > 0,
},
{
key: 'publish-event',
title: 'Event veröffentlichen',
description: 'Schalte dein Event frei, damit Gäste über den Link Fotos hochladen können.',
done: (metrics?.published_events ?? 0) > 0,
},
{
key: 'invite-guests',
title: 'Gästelink teilen',
description: 'Nutze QR-Code oder Link, um Gäste einzuladen und erste Uploads zu sammeln.',
done: upcomingEvents.some((event) => event.joinTokensCount > 0),
},
{
key: 'collect-photos',
title: 'Fotos sammeln',
description: 'Spare Zeit bei der Nachbereitung: neue Uploads erscheinen direkt in deinem Event.',
done: (metrics?.new_photos ?? 0) > 0,
},
];
const stats = useMemo(() => {
const activeEvents = metrics?.active_events ?? 0;
const upcomingEventsCount = metrics?.upcoming_events ?? 0;
const newPhotos = metrics?.new_photos ?? 0;
const quickActions = [
{
key: 'tenant-admin',
label: 'Event-Admin öffnen',
description: 'Detaillierte Eventverwaltung, Moderation und Live-Features.',
href: '/event-admin',
icon: Sparkles,
},
{
key: 'profile',
label: 'Profil verwalten',
description: 'Kontaktinformationen, Sprache und E-Mail-Adresse anpassen.',
href: profileRoutes.index().url,
icon: UserRound,
},
{
key: 'password',
label: 'Passwort aktualisieren',
description: 'Sichere dein Konto mit einem aktuellen Passwort.',
href: passwordSettings().url,
icon: Key,
},
{
key: 'packages',
label: 'Pakete entdecken',
description: 'Mehr Events oder Speicher buchen du bleibst flexibel.',
href: `/${locale ?? 'de'}/packages`,
icon: Package,
},
];
return [
{
key: 'active-events',
label: translate('stats.active_events.label', 'Aktive Events'),
value: activeEvents,
description: activeEvents > 0
? translate('stats.active_events.description_positive', 'Events sind live und für Gäste sichtbar.')
: translate('stats.active_events.description_zero', 'Noch kein Event veröffentlicht starte heute!'),
icon: CalendarDays,
},
{
key: 'upcoming-events',
label: translate('stats.upcoming_events.label', 'Bevorstehende Events'),
value: upcomingEventsCount,
description: upcomingEventsCount > 0
? translate('stats.upcoming_events.description_positive', 'Planung läuft behalte Checklisten und Aufgaben im Blick.')
: translate('stats.upcoming_events.description_zero', 'Lass dich vom Assistenten beim Planen unterstützen.'),
icon: TrendingUp,
},
{
key: 'new-photos',
label: translate('stats.new_photos.label', 'Neue Fotos (7 Tage)'),
value: newPhotos,
description: newPhotos > 0
? translate('stats.new_photos.description_positive', 'Frisch eingetroffene Erinnerungen deiner Gäste.')
: translate('stats.new_photos.description_zero', 'Sammle erste Uploads über QR-Code oder Direktlink.'),
icon: Camera,
},
{
key: 'task-progress',
label: translate('stats.task_progress.label', 'Event-Checkliste'),
value: `${taskProgress}%`,
description: taskProgress > 0
? translate('stats.task_progress.description_positive', 'Starker Fortschritt! Halte deine Aufgabenliste aktuell.')
: translate('stats.task_progress.description_zero', 'Nutze Aufgaben und Vorlagen für einen strukturierten Ablauf.'),
icon: ClipboardList,
},
];
}, [metrics, taskProgress, translate]);
const stats = [
{
key: 'active-events',
label: 'Aktive Events',
value: metrics?.active_events ?? 0,
description: metrics?.active_events
? 'Events sind live und für Gäste sichtbar.'
: 'Noch kein Event veröffentlicht starte heute!',
icon: CalendarDays,
},
{
key: 'upcoming-events',
label: 'Bevorstehende Events',
value: metrics?.upcoming_events ?? 0,
description: metrics?.upcoming_events
? 'Planung läuft behalte Checklisten und Aufgaben im Blick.'
: 'Lass dich vom Assistenten beim Planen unterstützen.',
icon: TrendingUp,
},
{
key: 'new-photos',
label: 'Neue Fotos (7 Tage)',
value: metrics?.new_photos ?? 0,
description: metrics && metrics.new_photos > 0
? 'Frisch eingetroffene Erinnerungen deiner Gäste.'
: 'Sammle erste Uploads über QR-Code oder Direktlink.',
icon: Camera,
},
{
key: 'credit-balance',
label: 'Event Credits',
value: tenant?.eventCreditsBalance ?? 0,
description: tenant?.eventCreditsBalance
? 'Verfügbare Credits für neue Events.'
: 'Buche Pakete oder Credits, um weitere Events zu planen.',
icon: Package,
extra: metrics?.active_package?.remaining_events ?? null,
},
{
key: 'task-progress',
label: 'Event-Checkliste',
value: `${taskProgress}%`,
description: taskProgress > 0
? 'Starker Fortschritt! Halte deine Aufgabenliste aktuell.'
: 'Nutze Aufgaben und Vorlagen für einen strukturierten Ablauf.',
icon: ClipboardList,
},
];
const quickActions = useMemo(() => {
const languageAwarePackagesHref = `/${locale ?? supportedLocales?.[0] ?? 'de'}/packages`;
return [
{
key: 'tenant-admin',
label: translate('cards_section.quick_actions.items.tenant_admin.label', 'Event-Admin öffnen'),
description: translate('cards_section.quick_actions.items.tenant_admin.description', 'Detaillierte Eventverwaltung, Moderation und Live-Features.'),
href: '/event-admin',
icon: Sparkles,
},
{
key: 'profile',
label: translate('cards_section.quick_actions.items.profile.label', 'Profil verwalten'),
description: translate('cards_section.quick_actions.items.profile.description', 'Kontaktinformationen, Sprache und E-Mail-Adresse anpassen.'),
href: profileRoutes.index().url,
icon: UserRound,
},
{
key: 'password',
label: translate('cards_section.quick_actions.items.password.label', 'Passwort aktualisieren'),
description: translate('cards_section.quick_actions.items.password.description', 'Sichere dein Konto mit einem aktuellen Passwort.'),
href: passwordSettings().url,
icon: Key,
},
{
key: 'packages',
label: translate('cards_section.quick_actions.items.packages.label', 'Pakete entdecken'),
description: translate('cards_section.quick_actions.items.packages.description', 'Mehr Events oder Speicher buchen du bleibst flexibel.'),
href: languageAwarePackagesHref,
icon: Package,
},
];
}, [locale, supportedLocales, translate]);
const spotlight = useMemo(() => ({
title: translate('spotlight.title', 'Admin-App als Schaltzentrale'),
description: translate('spotlight.description', 'Events planen, Uploads freigeben, Gäste begleiten mobil und in Echtzeit.'),
cta: translate('spotlight.cta', 'Admin-App öffnen'),
items: [
{
key: 'live',
title: translate('spotlight.items.live.title', 'Live auf dem Event'),
description: translate('spotlight.items.live.description', 'Moderation der Uploads, Freigaben & Push-Updates jederzeit griffbereit.'),
},
{
key: 'mobile',
title: translate('spotlight.items.mobile.title', 'Optimiert für mobile Einsätze'),
description: translate('spotlight.items.mobile.description', 'PWA heute, native Apps morgen ein Zugang für das ganze Team.'),
},
{
key: 'overview',
title: translate('spotlight.items.overview.title', 'Dashboard als Überblick'),
description: translate('spotlight.items.overview.description', 'Pakete, Rechnungen und Fortschritt siehst du weiterhin hier im Webportal.'),
},
],
}), [translate]);
const handleResendVerification = () => {
setSendingVerification(true);
@@ -247,236 +292,381 @@ export default function Dashboard() {
const formatDate = (value: string | null) => (value ? dateFormatter.format(new Date(value)) : '—');
const emailTitle = translate('email_verification.title', 'Bitte bestätige deine E-Mail-Adresse');
const emailDescription = translate(
'email_verification.description',
'Du kannst alle Funktionen erst vollständig nutzen, sobald deine E-Mail-Adresse bestätigt ist. Prüfe dein Postfach oder fordere einen neuen Link an.',
);
const emailSuccess = translate('email_verification.success', 'Wir haben dir gerade einen neuen Bestätigungslink geschickt.');
const resendIdleLabel = translate('email_verification.cta', 'Link erneut senden');
const resendPendingLabel = translate('email_verification.cta_pending', 'Sende...');
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 upcomingCardTitle = translate('events.card.title', 'Bevorstehende Events');
const upcomingCardDescription = translate(
'events.card.description',
'Status, Uploads und Aufgaben deiner nächsten Events im Überblick.',
);
const upcomingEmptyCopy = translate(
'events.card.empty',
'Plane dein erstes Event und begleite den gesamten Ablauf vom Briefing bis zur Nachbereitung direkt in der Admin-App.',
);
const upcomingBadgeLabel = upcomingEvents.length > 0
? formatMessage('events.card.badge.plural', `${upcomingEvents.length} geplant`, { count: upcomingEvents.length })
: translate('events.card.badge.empty', 'Noch kein Event geplant');
const eventLiveStatus = translate('events.status.live', 'Live');
const eventUpcomingStatus = translate('events.status.upcoming', 'In Vorbereitung');
const eventPhotoLabel = translate('events.badges.photos', 'Fotos');
const eventTaskLabel = translate('events.badges.tasks', 'Aufgaben');
const eventLinksLabel = translate('events.badges.links', 'Links');
const packageCardTitle = translate('cards_section.package.title', 'Aktuelles Paket');
const packageCardDescription = translate('cards_section.package.description', 'Behalte Laufzeiten und verfügbaren Umfang stets im Blick.');
const packageExpiresLabel = translate('cards_section.package.expires_at', 'Läuft ab');
const packagePriceLabel = translate('cards_section.package.price', 'Preis');
const packageEmptyCopy = translate('cards_section.package.empty', 'Noch kein aktives Paket.');
const packageEmptyCta = translate('cards_section.package.empty_cta', 'Jetzt Paket auswählen');
const packageEmptySuffix = translate('cards_section.package.empty_suffix', 'und anschließend in der Admin-App Events anlegen.');
const purchasesCardTitle = translate('cards_section.purchases.title', 'Aktuelle Buchungen');
const purchasesCardDescription = translate('cards_section.purchases.description', 'Verfolge deine gebuchten Pakete und Erweiterungen.');
const purchasesBadge = formatMessage('cards_section.purchases.badge', `${recentPurchases.length} Einträge`, {
count: recentPurchases.length,
});
const purchasesEmptyCopy = translate(
'cards_section.purchases.empty',
'Noch keine Buchungen sichtbar. Starte jetzt und sichere dir dein erstes Kontingent.',
);
const purchasesHeaders = {
package: translate('cards_section.purchases.table.package', 'Paket'),
type: translate('cards_section.purchases.table.type', 'Typ'),
provider: translate('cards_section.purchases.table.provider', 'Anbieter'),
date: translate('cards_section.purchases.table.date', 'Datum'),
price: translate('cards_section.purchases.table.price', 'Preis'),
};
const quickActionsCardTitle = translate('cards_section.quick_actions.title', 'Schnellzugriff');
const quickActionsCardDescription = translate(
'cards_section.quick_actions.description',
'Alles Wichtige für den nächsten Schritt nur einen Klick entfernt.',
);
const quickActionsCta = translate('cards_section.quick_actions.cta', 'Weiter');
const taskProgressNote = formatMessage(
'stats.task_progress.note',
':value% deiner Event-Checkliste erledigt.',
{ value: taskProgress },
);
const latestPurchaseInfo = latestPurchase
? formatMessage(
'cards_section.package.latest',
`Zuletzt gebucht am ${formatDate(latestPurchase.purchasedAt)} via ${latestPurchase.provider?.toUpperCase() ?? 'Checkout'}.`,
{
date: formatDate(latestPurchase.purchasedAt),
provider: latestPurchase.provider?.toUpperCase() ?? 'Checkout',
},
)
: null;
return (
<AppLayout breadcrumbs={breadcrumbs}>
<AppLayout>
<Head title="Dashboard" />
<div className="mt-8 flex flex-1 flex-col gap-8 pb-16">
{needsEmailVerification && (
<Alert variant="warning" className="border-amber-300 bg-amber-100/80 text-amber-950 dark:border-amber-600 dark:bg-amber-900/30 dark:text-amber-100">
<AlertTriangle className="size-5" />
<div className="flex flex-1 flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<AlertTitle>Bitte bestätige deine E-Mail-Adresse</AlertTitle>
<AlertDescription>
Du kannst alle Funktionen erst vollständig nutzen, sobald deine E-Mail-Adresse bestätigt ist.
Prüfe dein Postfach oder fordere einen neuen Link an.
</AlertDescription>
</div>
<div className="flex flex-col gap-2 sm:items-end">
<Button size="sm" variant="outline" onClick={handleResendVerification} disabled={sendingVerification}>
{sendingVerification ? 'Sende...' : 'Link erneut senden'}
</Button>
{verificationSent && <span className="text-xs text-muted-foreground">Wir haben dir gerade einen neuen Bestätigungslink geschickt.</span>}
</div>
</div>
</Alert>
)}
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-4">
{stats.map((stat) => (
<Card key={stat.key} className="bg-gradient-to-br from-background to-muted/60 shadow-sm">
<CardHeader className="flex flex-row items-start justify-between gap-2 pb-2">
<div>
<p className="text-sm font-medium text-muted-foreground">{stat.label}</p>
<div className="mt-2 text-3xl font-semibold">{stat.value}</div>
</div>
<span className="rounded-full border bg-background/70 p-2 text-muted-foreground">
<stat.icon className="size-5" />
</span>
</CardHeader>
<CardContent className="space-y-3 text-sm text-muted-foreground">
<p>{stat.description}</p>
{stat.key === 'credit-balance' && stat.extra !== null && (
<div className="text-xs text-muted-foreground">{stat.extra} weitere Events im aktuellen Paket enthalten.</div>
)}
{stat.key === 'task-progress' && (
<div className="space-y-1">
<Progress value={taskProgress} />
<span className="text-xs text-muted-foreground">{taskProgress}% deiner Event-Checkliste erledigt.</span>
<div className="relative pb-16">
<div
aria-hidden
className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_top,_rgba(255,137,170,0.25),_transparent_55%),radial-gradient(ellipse_at_bottom,_rgba(99,102,241,0.25),_transparent_60%)] blur-3xl"
/>
<div aria-hidden className="absolute inset-0 bg-gradient-to-br from-slate-950 via-slate-900/85 to-[#1d1130]" />
<div className="relative z-10 mx-auto w-full max-w-6xl px-4 sm:px-6">
<div className="overflow-hidden rounded-3xl border border-white/15 bg-white/95 shadow-2xl shadow-fuchsia-500/10 backdrop-blur-2xl dark:border-gray-800/70 dark:bg-gray-950/85">
<div className="flex flex-1 flex-col gap-8 p-6 sm:p-10">
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-6 md:flex-row md:items-start md:justify-between">
<div className="space-y-4">
<span className="inline-flex items-center gap-2 rounded-full border border-pink-200/60 bg-pink-50/80 px-4 py-1 text-xs font-semibold uppercase tracking-[0.4em] text-pink-600 shadow-sm dark:border-pink-500/40 dark:bg-pink-500/10 dark:text-pink-200">
{heroBadge}
</span>
<div className="space-y-3">
<h1 className="font-display text-3xl tracking-tight text-gray-900 sm:text-4xl dark:text-white">{greetingTitle}</h1>
<div className="space-y-2 text-sm text-muted-foreground sm:text-base">
<p>{heroSubtitle}</p>
<p>{heroDescription}</p>
</div>
</div>
</div>
<div className="flex flex-col items-center gap-4 md:items-end">
<DashboardLanguageSwitcher />
<img src="/logo-transparent-lg.png" alt="Fotospiel" className="h-24 w-auto drop-shadow-xl" />
</div>
)}
</CardContent>
</Card>
))}
</div>
<div className="grid gap-6 xl:grid-cols-3">
<Card className="xl:col-span-2">
<CardHeader className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle>Bevorstehende Events</CardTitle>
<p className="text-sm text-muted-foreground">Status, Uploads und Aufgaben deiner nächsten Events im Überblick.</p>
</div>
<Badge variant={upcomingEvents.length > 0 ? 'secondary' : 'outline'}>
{upcomingEvents.length > 0 ? `${upcomingEvents.length} geplant` : 'Noch kein Event geplant'}
</Badge>
</CardHeader>
<CardContent className="space-y-4">
{upcomingEvents.length === 0 && (
<div className="rounded-lg border border-dashed border-muted-foreground/40 p-6 text-center">
<p className="text-sm text-muted-foreground">
Plane dein erstes Event und begleite den gesamten Ablauf vom Briefing bis zur Nachbereitung direkt hier im Dashboard.
</p>
</div>
</div>
{needsEmailVerification && (
<Alert variant="warning" className="border-amber-300/60 bg-white/95 text-amber-900 shadow-sm shadow-amber-200/40 dark:border-amber-500/50 dark:bg-amber-950/60 dark:text-amber-100">
<AlertTriangle className="size-5" />
<div className="flex flex-1 flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<AlertTitle>{emailTitle}</AlertTitle>
<AlertDescription>{emailDescription}</AlertDescription>
</div>
<div className="flex flex-col gap-2 sm:items-end">
<Button size="sm" variant="outline" onClick={handleResendVerification} disabled={sendingVerification}>
{sendingVerification ? resendPendingLabel : resendIdleLabel}
</Button>
{verificationSent && <span className="text-xs text-muted-foreground">{emailSuccess}</span>}
</div>
</div>
</Alert>
)}
{upcomingEvents.map((event) => (
<div key={event.id} className="rounded-lg border border-border/60 bg-background/80 p-4 shadow-sm">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<h3 className="text-base font-semibold leading-tight">{event.name}</h3>
<p className="text-sm text-muted-foreground">
{formatDate(event.date)} · {event.status === 'published' || event.isActive ? 'Live' : 'In Vorbereitung'}
</p>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="rounded-full bg-muted px-2 py-1">{event.photosCount} Fotos</span>
<span className="rounded-full bg-muted px-2 py-1">{event.tasksCount} Aufgaben</span>
<span className="rounded-full bg-muted px-2 py-1">{event.joinTokensCount} Links</span>
</div>
</div>
</div>
))}
</CardContent>
</Card>
<div className="flex flex-col gap-6">
<Card>
<CardHeader>
<CardTitle>Nächstes Paket &amp; Credits</CardTitle>
<p className="text-sm text-muted-foreground">Behalte Laufzeiten und verfügbaren Umfang stets im Blick.</p>
</CardHeader>
<CardContent className="space-y-3">
{tenant?.activePackage ? (
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="font-medium">{tenant.activePackage.name}</span>
<Badge variant="outline">{tenant.activePackage.remainingEvents ?? 0} Events übrig</Badge>
</div>
<div className="grid gap-2 text-sm text-muted-foreground">
<div className="flex items-center justify-between">
<span>Läuft ab</span>
<span>{formatDate(tenant.activePackage.expiresAt)}</span>
</div>
<div className="flex items-center justify-between">
<span>Preis</span>
<span>{renderPrice(tenant.activePackage.price)}</span>
</div>
</div>
{latestPurchase && (
<div className="rounded-md bg-muted/50 p-3 text-xs text-muted-foreground">
Zuletzt gebucht am {formatDate(latestPurchase.purchasedAt)} via {latestPurchase.provider?.toUpperCase() ?? 'Checkout'}.
</div>
)}
</div>
) : (
<div className="rounded-md border border-dashed border-muted-foreground/30 p-4 text-sm text-muted-foreground">
Noch kein aktives Paket. <Link href={`/${locale ?? 'de'}/packages`} className="font-medium underline underline-offset-4">Jetzt Paket auswählen</Link> und direkt Events planen.
</div>
)}
<Separator />
<div className="flex items-center justify-between text-sm">
<span>Event Credits insgesamt</span>
<span className="font-medium">{tenant?.eventCreditsBalance ?? 0}</span>
</div>
<div className="text-xs text-muted-foreground">
Credits werden bei neuen Events automatisch verbraucht. Zusätzliche Kontingente kannst du jederzeit buchen.
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Dein Start in 5 Schritten</CardTitle>
<p className="text-sm text-muted-foreground">Folge den wichtigsten Schritten, um dein Event reibungslos aufzusetzen.</p>
</CardHeader>
<CardContent className="space-y-3">
<div className="space-y-3">
{checklistItems.map((item) => (
<div key={item.key} className="flex gap-3 rounded-md border border-border/60 bg-background/50 p-3">
<Checkbox checked={item.done} className="mt-1" readOnly />
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-4">
{stats.map((stat) => (
<Card key={stat.key} className="border border-white/20 bg-white/90 shadow-lg shadow-fuchsia-500/10 backdrop-blur-sm dark:border-gray-800/70 dark:bg-gray-950/80">
<CardHeader className="flex flex-row items-start justify-between gap-2 pb-2">
<div>
<p className="text-sm font-medium leading-tight">{item.title}</p>
<p className="text-xs text-muted-foreground">{item.description}</p>
<p className="text-sm font-medium text-muted-foreground">{stat.label}</p>
<div className="mt-2 text-3xl font-semibold">{stat.value}</div>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
</div>
<Card>
<CardHeader className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle>Aktuelle Buchungen</CardTitle>
<p className="text-sm text-muted-foreground">Verfolge deine gebuchten Pakete und Erweiterungen.</p>
</div>
<Badge variant="outline">{recentPurchases.length} Einträge</Badge>
</CardHeader>
<CardContent>
{recentPurchases.length === 0 ? (
<div className="rounded-md border border-dashed border-muted-foreground/30 p-6 text-center text-sm text-muted-foreground">
Noch keine Buchungen sichtbar. Starte jetzt und sichere dir dein erstes Kontingent.
<span className="rounded-full bg-gradient-to-br from-pink-500/20 to-purple-500/30 p-2 text-pink-600 dark:text-pink-300">
<stat.icon className="size-5" />
</span>
</CardHeader>
<CardContent className="space-y-3 text-sm text-muted-foreground">
<p>{stat.description}</p>
{stat.key === 'task-progress' && (
<div className="space-y-1">
<Progress value={taskProgress} />
<span className="text-xs text-muted-foreground">{taskProgressNote}</span>
</div>
)}
</CardContent>
</Card>
))}
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Paket</TableHead>
<TableHead className="hidden sm:table-cell">Typ</TableHead>
<TableHead className="hidden md:table-cell">Anbieter</TableHead>
<TableHead className="hidden md:table-cell">Datum</TableHead>
<TableHead className="text-right">Preis</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{recentPurchases.map((purchase) => (
<TableRow key={purchase.id}>
<TableCell className="font-medium">{purchase.packageName}</TableCell>
<TableCell className="hidden capitalize text-muted-foreground sm:table-cell">{purchase.type ?? '—'}</TableCell>
<TableCell className="hidden text-muted-foreground md:table-cell">{purchase.provider ?? 'Checkout'}</TableCell>
<TableCell className="hidden text-muted-foreground md:table-cell">{formatDate(purchase.purchasedAt)}</TableCell>
<TableCell className="text-right font-medium">{renderPrice(purchase.price)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Schnellzugriff</CardTitle>
<p className="text-sm text-muted-foreground">Alles Wichtige für den nächsten Schritt nur einen Klick entfernt.</p>
</CardHeader>
<CardContent className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
{quickActions.map((action) => (
<div key={action.key} className="flex flex-col justify-between gap-3 rounded-lg border border-border/60 bg-background/60 p-4">
<div className="flex items-center gap-3">
<span className="rounded-full bg-muted p-2 text-muted-foreground">
<action.icon className="size-5" />
</span>
<div>
<p className="text-sm font-semibold leading-tight">{action.label}</p>
<p className="text-xs text-muted-foreground">{action.description}</p>
<Card className="overflow-hidden border border-white/20 bg-gradient-to-br from-[#1d1937] via-[#2a1134] to-[#ff5f87] text-white shadow-xl shadow-fuchsia-500/20">
<CardHeader className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-2">
<CardTitle className="text-white">{spotlight.title}</CardTitle>
<p className="text-sm text-white/80">{spotlight.description}</p>
</div>
</div>
<div className="flex justify-end">
<Button asChild variant="ghost" size="sm">
<Link href={action.href} prefetch>
Weiter
<Button asChild size="lg" className="h-11 rounded-full bg-white px-6 text-sm font-semibold text-gray-900 shadow-lg shadow-fuchsia-500/30 transition hover:bg-white/90">
<Link href="/event-admin" prefetch>
<Smartphone className="mr-2 size-4" /> {spotlight.cta}
</Link>
</Button>
</div>
</CardHeader>
<CardContent className="grid gap-4 text-sm text-white/80 md:grid-cols-3">
{spotlight.items.map((item) => (
<div key={item.key}>
<p className="font-semibold text-white">{item.title}</p>
<p>{item.description}</p>
</div>
))}
</CardContent>
</Card>
<div className="grid gap-6 xl:grid-cols-3">
<Card className="border border-white/20 bg-white/92 shadow-lg shadow-fuchsia-500/10 backdrop-blur-sm dark:border-gray-800/70 dark:bg-gray-950/80">
<CardHeader>
<CardTitle>{onboardingCardTitle}</CardTitle>
<p className="text-sm text-muted-foreground">{onboardingCardDescription}</p>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-3">
{onboardingSteps.map((step) => (
<div key={step.key} className="rounded-2xl border border-muted/40 bg-white/90 p-4 shadow-sm dark:border-gray-800/60 dark:bg-gray-950/70">
<div className="flex items-start gap-3">
<Checkbox checked={step.done} className="mt-1" readOnly />
<div className="space-y-2">
<div>
<p className="text-sm font-medium leading-tight text-foreground">{step.title}</p>
<p className="text-xs text-muted-foreground">{step.description}</p>
</div>
{!step.done && step.cta ? (
<Button asChild size="sm" variant="outline">
<Link href={step.cta} prefetch>
{step.ctaLabel ?? onboardingFallbackCta}
</Link>
</Button>
) : null}
</div>
</div>
</div>
))}
{onboardingSteps.length === 0 && (
<p className="text-sm text-muted-foreground">{onboardingCompletedCopy}</p>
)}
</div>
</CardContent>
</Card>
<Card className="xl:col-span-2 border border-white/20 bg-white/92 shadow-lg shadow-fuchsia-500/10 backdrop-blur-sm dark:border-gray-800/70 dark:bg-gray-950/80">
<CardHeader className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle>{upcomingCardTitle}</CardTitle>
<p className="text-sm text-muted-foreground">{upcomingCardDescription}</p>
</div>
<Badge variant={upcomingEvents.length > 0 ? 'secondary' : 'outline'}>{upcomingBadgeLabel}</Badge>
</CardHeader>
<CardContent className="space-y-4">
{upcomingEvents.length === 0 && (
<div className="rounded-lg border border-dashed border-muted-foreground/40 p-6 text-center">
<p className="text-sm text-muted-foreground">{upcomingEmptyCopy}</p>
</div>
)}
{upcomingEvents.map((event) => (
<div key={event.id} className="rounded-xl border border-muted/40 bg-white/90 p-4 shadow-sm dark:border-gray-800/60 dark:bg-gray-950/70">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<h3 className="text-base font-semibold leading-tight">{event.name}</h3>
<p className="text-sm text-muted-foreground">
{formatDate(event.date)} · {event.status === 'published' || event.isActive ? eventLiveStatus : eventUpcomingStatus}
</p>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="rounded-full bg-muted px-2 py-1">{event.photosCount} {eventPhotoLabel}</span>
<span className="rounded-full bg-muted px-2 py-1">{event.tasksCount} {eventTaskLabel}</span>
<span className="rounded-full bg-muted px-2 py-1">{event.joinTokensCount} {eventLinksLabel}</span>
</div>
</div>
</div>
))}
</CardContent>
</Card>
</div>
))}
</CardContent>
</Card>
<div className="grid gap-6 lg:grid-cols-2">
<Card className="border border-white/20 bg-white/92 shadow-lg shadow-fuchsia-500/10 backdrop-blur-sm dark:border-gray-800/70 dark:bg-gray-950/80">
<CardHeader>
<CardTitle>{packageCardTitle}</CardTitle>
<p className="text-sm text-muted-foreground">{packageCardDescription}</p>
</CardHeader>
<CardContent className="space-y-3">
{tenant?.activePackage ? (
<div className="space-y-3">
<div className="flex items-center justify-between text-sm">
<span className="font-medium text-foreground">{tenant.activePackage.name}</span>
<Badge variant="outline">
{formatMessage('cards_section.package.remaining', `${tenant.activePackage.remainingEvents ?? 0} Events`, {
count: tenant.activePackage.remainingEvents ?? 0,
})}
</Badge>
</div>
<div className="grid gap-2 text-sm text-muted-foreground">
<div className="flex items-center justify-between">
<span>{packageExpiresLabel}</span>
<span>{formatDate(tenant.activePackage.expiresAt)}</span>
</div>
<div className="flex items-center justify-between">
<span>{packagePriceLabel}</span>
<span>{renderPrice(tenant.activePackage.price)}</span>
</div>
</div>
{latestPurchaseInfo && (
<div className="rounded-md bg-muted/40 p-3 text-xs text-muted-foreground">{latestPurchaseInfo}</div>
)}
</div>
) : (
<div className="rounded-md border border-dashed border-muted-foreground/40 p-4 text-sm text-muted-foreground">
{packageEmptyCopy}{' '}
<Link href={`/${locale ?? 'de'}/packages`} className="font-medium underline underline-offset-4">
{packageEmptyCta}
</Link>{' '}
{packageEmptySuffix}
</div>
)}
</CardContent>
</Card>
<Card className="border border-white/20 bg-white/92 shadow-lg shadow-fuchsia-500/10 backdrop-blur-sm dark:border-gray-800/70 dark:bg-gray-950/80">
<CardHeader className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle>{purchasesCardTitle}</CardTitle>
<p className="text-sm text-muted-foreground">{purchasesCardDescription}</p>
</div>
<Badge variant="outline">{purchasesBadge}</Badge>
</CardHeader>
<CardContent>
{recentPurchases.length === 0 ? (
<div className="rounded-md border border-dashed border-muted-foreground/40 p-6 text-center text-sm text-muted-foreground">
{purchasesEmptyCopy}
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>{purchasesHeaders.package}</TableHead>
<TableHead className="hidden sm:table-cell">{purchasesHeaders.type}</TableHead>
<TableHead className="hidden md:table-cell">{purchasesHeaders.provider}</TableHead>
<TableHead className="hidden md:table-cell">{purchasesHeaders.date}</TableHead>
<TableHead className="text-right">{purchasesHeaders.price}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{recentPurchases.map((purchase) => (
<TableRow key={`${purchase.source ?? 'entry'}-${purchase.id}`}>
<TableCell className="font-medium">{purchase.packageName}</TableCell>
<TableCell className="hidden capitalize text-muted-foreground sm:table-cell">{purchase.type ?? '—'}</TableCell>
<TableCell className="hidden text-muted-foreground md:table-cell">{purchase.provider ?? 'Checkout'}</TableCell>
<TableCell className="hidden text-muted-foreground md:table-cell">{formatDate(purchase.purchasedAt)}</TableCell>
<TableCell className="text-right font-medium">{renderPrice(purchase.price)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
<Card className="border border-white/20 bg-white/92 shadow-lg shadow-fuchsia-500/10 backdrop-blur-sm dark:border-gray-800/70 dark:bg-gray-950/80">
<CardHeader>
<CardTitle>{quickActionsCardTitle}</CardTitle>
<p className="text-sm text-muted-foreground">{quickActionsCardDescription}</p>
</CardHeader>
<CardContent className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
{quickActions.map((action) => (
<div key={action.key} className="flex flex-col justify-between gap-3 rounded-xl border border-muted/40 bg-white/90 p-4 shadow-sm dark:border-gray-800/60 dark:bg-gray-950/70">
<div className="flex items-center gap-3">
<span className="rounded-full bg-muted p-2 text-muted-foreground">
<action.icon className="size-5" />
</span>
<div>
<p className="text-sm font-semibold leading-tight">{action.label}</p>
<p className="text-xs text-muted-foreground">{action.description}</p>
</div>
</div>
<div className="flex justify-end">
<Button asChild variant="ghost" size="sm">
<Link href={action.href} prefetch>
{quickActionsCta}
</Link>
</Button>
</div>
</div>
))}
</CardContent>
</Card>
</div>
</div>
</div>
</div>
</AppLayout>
);
}
(Dashboard as any).layout = (page: ReactNode) => page;