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

@@ -32,7 +32,6 @@ type ProfilePageProps = {
tenant: {
id: number;
name: string;
eventCreditsBalance: number | null;
subscriptionStatus: string | null;
subscriptionExpiresAt: string | null;
activePackage: {
@@ -98,7 +97,7 @@ export default function ProfileIndex() {
<AppLayout breadcrumbs={breadcrumbs}>
<Head title="Profil" />
<div className="flex flex-1 flex-col gap-6 pb-12">
<div className="flex flex-1 flex-col gap-6 pb-12 pt-24 lg:pt-28">
<Card className="border-border/60 bg-gradient-to-br from-background to-muted/50 shadow-sm">
<CardHeader className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
@@ -167,7 +166,7 @@ export default function ProfileIndex() {
<div className="grid gap-3 rounded-lg border border-dashed border-muted-foreground/30 bg-muted/30 p-4 md:grid-cols-2">
<div>
<h3 className="text-base font-medium leading-tight">{tenant.activePackage.name}</h3>
<p className="text-xs text-muted-foreground">{tenant.eventCreditsBalance ?? 0} Credits verfügbar · {tenant.activePackage.remainingEvents ?? 0} Events inklusive</p>
<p className="text-xs text-muted-foreground">{tenant.activePackage.remainingEvents ?? 0} Events inklusive</p>
</div>
<div className="grid gap-2 text-sm text-muted-foreground">
<div className="flex items-center justify-between">
@@ -187,7 +186,7 @@ export default function ProfileIndex() {
) : (
<Alert className="border border-dashed border-muted-foreground/40 bg-muted/20">
<AlertDescription className="text-sm text-muted-foreground">
Du hast aktuell kein aktives Paket. Sichere dir jetzt Credits oder ein Komplettpaket, um neue Events zu planen.
Du hast aktuell kein aktives Paket. Wähle ein Paket, um ohne Unterbrechung neue Events zu planen.
</AlertDescription>
</Alert>
)}

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;