Files
fotospiel-app/resources/js/pages/dashboard.tsx

676 lines
40 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { edit as passwordSettings } from '@/routes/password';
import profileRoutes from '@/routes/profile';
import { send as resendVerificationRoute } from '@/routes/verification';
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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
type DashboardMetrics = {
total_events: number;
active_events: number;
published_events: number;
events_with_tasks: number;
upcoming_events: number;
new_photos: number;
task_progress: number;
active_package: {
name: string;
expires_at: string | null;
remaining_events: number | null;
price: number | null;
is_active?: boolean;
} | null;
};
type DashboardEvent = {
id: number;
name: string;
slug: string | null;
status: string | null;
isActive: boolean;
date: string | null;
photosCount: number;
tasksCount: number;
joinTokensCount: number;
};
type DashboardPurchase = {
id: number;
packageName: string;
price: number | null;
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;
subscriptionStatus: string | null;
subscriptionExpiresAt: string | null;
activePackage: {
name: string;
price: number | null;
expiresAt: string | null;
remainingEvents: number | null;
isActive?: boolean;
} | null;
} | null;
type DashboardPageProps = {
metrics: DashboardMetrics | null;
upcomingEvents: DashboardEvent[];
recentPurchases: DashboardPurchase[];
latestPurchase: DashboardPurchase | null;
tenant: TenantSummary;
emailVerification: {
mustVerify: boolean;
verified: boolean;
};
onboarding: DashboardOnboardingStep[];
};
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 page = usePage<SharedData & DashboardPageProps>();
const { metrics, upcomingEvents, recentPurchases, latestPurchase, tenant, emailVerification, locale, onboarding } = page.props;
const { auth, supportedLocales } = page.props;
const translations = useMemo(
() => (page.props.translations?.dashboard ?? {}) as Record<string, unknown>,
[page.props.translations?.dashboard],
);
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',
month: 'short',
year: 'numeric',
}), [locale]);
const currencyFormatter = useMemo(() => new Intl.NumberFormat(locale ?? 'de-DE', {
style: 'currency',
currency: 'EUR',
maximumFractionDigits: 2,
}), [locale]);
const onboardingSteps = useMemo(() => onboarding ?? [], [onboarding]);
const stats = useMemo(() => {
const activeEvents = metrics?.active_events ?? 0;
const upcomingEventsCount = metrics?.upcoming_events ?? 0;
const newPhotos = metrics?.new_photos ?? 0;
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 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);
setVerificationSent(false);
router.post(resendVerificationRoute(), {}, {
preserveScroll: true,
onSuccess: () => setVerificationSent(true),
onFinish: () => setSendingVerification(false),
});
};
const renderPrice = (price: number | null) => {
if (price === null) {
return '—';
}
try {
return currencyFormatter.format(price);
} catch {
return `${price.toFixed(2)}`;
}
};
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>
<Head title="Dashboard" />
<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>
</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>
)}
<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 text-muted-foreground">{stat.label}</p>
<div className="mt-2 text-3xl font-semibold">{stat.value}</div>
</div>
<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>
<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>
<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>
</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>
<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>
);
}
Object.assign(Dashboard, { layout: (page: ReactNode) => page });