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

@@ -122,18 +122,54 @@ export type PaginatedResult<T> = {
};
export type DashboardSummary = {
active_events: number;
new_photos: number;
task_progress: number;
credit_balance?: number | null;
upcoming_events?: number | null;
active_package?: {
name: string;
expires_at?: string | null;
remaining_events?: number | null;
} | null;
active_events: number;
new_photos: number;
task_progress: number;
credit_balance?: number | null;
upcoming_events?: number | null;
active_package?: {
name: string;
expires_at?: string | null;
remaining_events?: number | null;
} | null;
};
export type TenantOnboardingStatus = {
steps: {
admin_app_opened_at?: string | null;
primary_event_id?: number | string | null;
selected_packages?: unknown;
branding_completed?: boolean;
tasks_configured?: boolean;
event_created?: boolean;
invite_created?: boolean;
};
};
export async function trackOnboarding(step: string, meta?: Record<string, unknown>): Promise<void> {
try {
await authorizedFetch('/api/v1/tenant/onboarding', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ step, meta }),
});
} catch (error) {
emitApiErrorEvent(new ApiError('onboarding.track_failed', i18n.t('common.errors.generic', 'Etwas ist schiefgelaufen.'), error));
}
}
export async function fetchOnboardingStatus(): Promise<TenantOnboardingStatus | null> {
try {
const response = await authorizedFetch('/api/v1/tenant/onboarding');
return (await response.json()) as TenantOnboardingStatus;
} catch (error) {
emitApiErrorEvent(new ApiError('onboarding.fetch_failed', i18n.t('common.errors.generic', 'Etwas ist schiefgelaufen.'), error));
return null;
}
}
export type TenantPackageSummary = {
id: number;
package_id: number;

View File

@@ -78,6 +78,13 @@ export default function WelcomePackagesPage() {
isSubscription: Boolean(active.package_limits?.subscription),
}
: null,
serverStep: active ? "package_selected" : undefined,
meta: active
? {
packages: [active.package_id],
is_active: active.active,
}
: undefined,
});
}
}, [packagesState, markStep, currencyFormatter, t]);
@@ -96,6 +103,8 @@ export default function WelcomePackagesPage() {
priceText,
isSubscription: Boolean(pkg.features?.subscription),
},
serverStep: "package_selected",
meta: { packages: [pkg.id] },
});
navigate(ADMIN_WELCOME_SUMMARY_PATH, { state: { packageId: pkg.id } });

View File

@@ -1,10 +1,12 @@
import React from 'react';
import { fetchOnboardingStatus, trackOnboarding } from '../api';
export type OnboardingProgress = {
welcomeSeen: boolean;
packageSelected: boolean;
eventCreated: boolean;
lastStep?: string | null;
adminAppOpenedAt?: string | null;
selectedPackage?: {
id: number;
name: string;
@@ -13,10 +15,15 @@ export type OnboardingProgress = {
} | null;
};
type OnboardingUpdate = Partial<OnboardingProgress> & {
serverStep?: string;
meta?: Record<string, unknown>;
};
type OnboardingContextValue = {
progress: OnboardingProgress;
setProgress: (updater: (prev: OnboardingProgress) => OnboardingProgress) => void;
markStep: (step: Partial<OnboardingProgress>) => void;
markStep: (step: OnboardingUpdate) => void;
reset: () => void;
};
@@ -25,6 +32,7 @@ const DEFAULT_PROGRESS: OnboardingProgress = {
packageSelected: false,
eventCreated: false,
lastStep: null,
adminAppOpenedAt: null,
selectedPackage: null,
};
@@ -65,6 +73,45 @@ function writeStoredProgress(progress: OnboardingProgress) {
export function OnboardingProgressProvider({ children }: { children: React.ReactNode }) {
const [progress, setProgressState] = React.useState<OnboardingProgress>(() => readStoredProgress());
const [synced, setSynced] = React.useState(false);
React.useEffect(() => {
if (synced) {
return;
}
fetchOnboardingStatus().then((status) => {
if (!status) {
setSynced(true);
return;
}
setProgressState((prev) => {
const next: OnboardingProgress = {
...prev,
adminAppOpenedAt: status.steps.admin_app_opened_at ?? prev.adminAppOpenedAt ?? null,
eventCreated: Boolean(status.steps.event_created ?? prev.eventCreated),
packageSelected: Boolean(status.steps.selected_packages ?? prev.packageSelected),
};
writeStoredProgress(next);
return next;
});
if (!status.steps.admin_app_opened_at) {
const timestamp = new Date().toISOString();
trackOnboarding('admin_app_opened').catch(() => {});
setProgressState((prev) => {
const next = { ...prev, adminAppOpenedAt: timestamp };
writeStoredProgress(next);
return next;
});
}
setSynced(true);
});
}, [synced]);
const setProgress = React.useCallback((updater: (prev: OnboardingProgress) => OnboardingProgress) => {
setProgressState((prev) => {
@@ -74,12 +121,18 @@ export function OnboardingProgressProvider({ children }: { children: React.React
});
}, []);
const markStep = React.useCallback((step: Partial<OnboardingProgress>) => {
const markStep = React.useCallback((step: OnboardingUpdate) => {
const { serverStep, meta, ...rest } = step;
setProgress((prev) => ({
...prev,
...step,
lastStep: typeof step.lastStep === 'undefined' ? prev.lastStep : step.lastStep,
...rest,
lastStep: typeof rest.lastStep === 'undefined' ? prev.lastStep : rest.lastStep,
}));
if (serverStep) {
trackOnboarding(serverStep, meta).catch(() => {});
}
}, [setProgress]);
const reset = React.useCallback(() => {

View File

@@ -180,7 +180,12 @@ export default function DashboardPage() {
return;
}
if (events.length > 0 && !progress.eventCreated) {
markStep({ eventCreated: true });
const primary = events[0];
markStep({
eventCreated: true,
serverStep: 'event_created',
meta: primary ? { event_id: primary.id } : undefined,
});
}
}, [loading, events.length, progress.eventCreated, navigate, location.pathname, markStep]);

View File

@@ -48,6 +48,7 @@ import {
triggerDownloadFromBlob,
triggerDownloadFromDataUrl,
} from './components/invite-layout/export-utils';
import { useOnboardingProgress } from '../onboarding';
interface PageState {
event: TenantEvent | null;
@@ -180,6 +181,7 @@ export default function EventInvitesPage(): React.ReactElement {
const [exportError, setExportError] = React.useState<string | null>(null);
const exportPreviewContainerRef = React.useRef<HTMLDivElement | null>(null);
const [exportScale, setExportScale] = React.useState(0.34);
const { markStep } = useOnboardingProgress();
const load = React.useCallback(async () => {
if (!slug) {
@@ -479,6 +481,11 @@ export default function EventInvitesPage(): React.ReactElement {
} catch {
// ignore clipboard failures
}
markStep({
lastStep: 'invite',
serverStep: 'invite_created',
meta: { invite_id: invite.id },
});
} catch (error) {
if (!isAuthError(error)) {
setState((prev) => ({ ...prev, error: 'QR-Einladung konnte nicht erstellt werden.' }));
@@ -544,6 +551,14 @@ export default function EventInvitesPage(): React.ReactElement {
invites: prev.invites.map((invite) => (invite.id === updated.id ? updated : invite)),
}));
setCustomizerDraft(null);
markStep({
lastStep: 'branding',
serverStep: 'branding_configured',
meta: {
invite_id: selectedInvite.id,
has_custom_branding: true,
},
});
} catch (error) {
if (!isAuthError(error)) {
setState((prev) => ({ ...prev, error: 'Anpassung konnte nicht gespeichert werden.' }));

View File

@@ -1,14 +1,19 @@
import AppLogoIcon from './app-logo-icon';
import { usePage } from '@inertiajs/react';
import { type SharedData } from '@/types';
export default function AppLogo() {
const { translations } = usePage<SharedData>().props;
const areaLabel =
(translations?.dashboard?.navigation?.group_label as string | undefined) ?? 'Kundenbereich';
return (
<>
<div className="flex aspect-square size-8 items-center justify-center rounded-md bg-sidebar-primary text-sidebar-primary-foreground">
<AppLogoIcon className="size-5 fill-current text-white dark:text-black" />
<div className="flex items-center gap-3">
<img src="/logo-transparent-md.png" alt="Fotospiel" className="h-10 w-auto" />
<div className="grid text-left leading-tight">
<span className="text-sm font-semibold text-sidebar-foreground">Fotospiel</span>
<span className="text-xs text-sidebar-foreground/70">{areaLabel}</span>
</div>
<div className="ml-1 grid flex-1 text-left text-sm">
<span className="mb-0.5 truncate leading-tight font-semibold">Laravel Starter Kit</span>
</div>
</>
</div>
);
}

View File

@@ -8,11 +8,11 @@ interface AppShellProps {
}
export function AppShell({ children, variant = 'header' }: AppShellProps) {
const isOpen = usePage<SharedData>().props.sidebarOpen;
const { sidebarOpen = false } = usePage<SharedData>().props;
if (variant === 'header') {
return <div className="flex min-h-screen w-full flex-col">{children}</div>;
}
return <SidebarProvider defaultOpen={isOpen}>{children}</SidebarProvider>;
return <SidebarProvider defaultOpen={sidebarOpen}>{children}</SidebarProvider>;
}

View File

@@ -1,11 +1,10 @@
import { NavFooter } from '@/components/nav-footer';
import { NavMain } from '@/components/nav-main';
import { NavUser } from '@/components/nav-user';
import { Sidebar, SidebarContent, SidebarFooter, SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarMenuItem } from '@/components/ui/sidebar';
import { dashboard } from '@/routes';
import { type NavItem } from '@/types';
import { Link } from '@inertiajs/react';
import { BookOpen, Folder, LayoutGrid, UserRound } from 'lucide-react';
import { LayoutGrid, UserRound } from 'lucide-react';
import AppLogo from './app-logo';
const mainNavItems: NavItem[] = [
@@ -21,19 +20,6 @@ const mainNavItems: NavItem[] = [
},
];
const footerNavItems: NavItem[] = [
{
title: 'Repository',
href: 'https://github.com/laravel/react-starter-kit',
icon: Folder,
},
{
title: 'Documentation',
href: 'https://laravel.com/docs/starter-kits#react',
icon: BookOpen,
},
];
export function AppSidebar() {
return (
<Sidebar collapsible="icon" variant="inset">
@@ -54,7 +40,6 @@ export function AppSidebar() {
</SidebarContent>
<SidebarFooter>
<NavFooter items={footerNavItems} className="mt-auto" />
<NavUser />
</SidebarFooter>
</Sidebar>

View File

@@ -0,0 +1,86 @@
import { useState } from 'react';
import { router, usePage } from '@inertiajs/react';
import { Check, Languages } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { type SharedData } from '@/types';
import { setLocale } from '@/routes';
export function DashboardLanguageSwitcher() {
const page = usePage<SharedData>();
const { locale, supportedLocales, translations } = page.props;
const locales = supportedLocales && supportedLocales.length > 0 ? supportedLocales : ['de', 'en'];
const activeLocale = locales.includes(locale as string) && typeof locale === 'string' ? locale : locales[0];
const languageCopy = (translations?.dashboard?.language_switcher as Record<string, string | undefined>) ?? {};
const label = languageCopy.label ?? 'Sprache';
const changeLabel = languageCopy.change ?? 'Sprache wechseln';
const [pendingLocale, setPendingLocale] = useState<string | null>(null);
const handleChange = (nextLocale: string) => {
if (nextLocale === activeLocale || pendingLocale === nextLocale || !locales.includes(nextLocale)) {
return;
}
setPendingLocale(nextLocale);
router.post(
setLocale().url,
{ locale: nextLocale },
{
preserveScroll: true,
preserveState: true,
onFinish: () => setPendingLocale(null),
},
);
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="flex items-center gap-2 rounded-full border-white/30 bg-white/80 px-3 text-xs font-semibold uppercase tracking-wide text-slate-900 backdrop-blur transition hover:bg-white dark:border-white/20 dark:bg-white/10 dark:text-white"
aria-label={label}
>
<Languages className="size-4" />
<span>{label}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[9rem]">
<DropdownMenuLabel className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
{changeLabel}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{locales.map((code) => {
const isActive = code === activeLocale;
const isPending = code === pendingLocale;
return (
<DropdownMenuItem
key={code}
onSelect={(event) => {
event.preventDefault();
handleChange(code);
}}
disabled={isPending}
className="flex items-center justify-between gap-3"
>
<span className="text-sm font-medium uppercase tracking-wide">{code}</span>
{(isActive || isPending) && <Check className="size-4 text-pink-500" />}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -1,12 +1,16 @@
import { SidebarGroup, SidebarGroupLabel, SidebarMenu, SidebarMenuButton, SidebarMenuItem } from '@/components/ui/sidebar';
import { type NavItem } from '@/types';
import { type NavItem, type SharedData } from '@/types';
import { Link, usePage } from '@inertiajs/react';
export function NavMain({ items = [] }: { items: NavItem[] }) {
const page = usePage();
const page = usePage<SharedData>();
const { translations } = page.props;
const groupLabel =
(translations?.dashboard?.navigation?.group_label as string | undefined) ?? 'Kundenbereich';
return (
<SidebarGroup className="px-2 py-0">
<SidebarGroupLabel>Platform</SidebarGroupLabel>
<SidebarGroupLabel>{groupLabel}</SidebarGroupLabel>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.title}>

View File

@@ -2,10 +2,10 @@ import { DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSep
import { UserInfo } from '@/components/user-info';
import { useMobileNavigation } from '@/hooks/use-mobile-navigation';
import { logout } from '@/routes';
import { edit } from '@/routes/settings/profile';
import profileRoutes from '@/routes/profile';
import { type User } from '@/types';
import { Link, router } from '@inertiajs/react';
import { LogOut, Settings } from 'lucide-react';
import { LogOut, UserRound } from 'lucide-react';
interface UserMenuContentProps {
user: User;
@@ -29,9 +29,9 @@ export function UserMenuContent({ user }: UserMenuContentProps) {
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem asChild>
<Link className="block w-full" href={edit()} as="button" prefetch onClick={cleanup}>
<Settings className="mr-2" />
Settings
<Link className="block w-full" href={profileRoutes.index().url} as="button" prefetch onClick={cleanup}>
<UserRound className="mr-2" />
Profil
</Link>
</DropdownMenuItem>
</DropdownMenuGroup>
@@ -39,7 +39,7 @@ export function UserMenuContent({ user }: UserMenuContentProps) {
<DropdownMenuItem asChild>
<Link className="block w-full" href={logout()} as="button" onClick={handleLogout}>
<LogOut className="mr-2" />
Log out
Abmelden
</Link>
</DropdownMenuItem>
</>

View File

@@ -236,12 +236,14 @@ function getErrorContent(
function SimpleLayout({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="pb-16">
<Header title={title} />
<div className="px-4 py-3">
{children}
<EventBrandingProvider>
<div className="pb-16">
<Header title={title} />
<div className="px-4 py-3">
{children}
</div>
<BottomNav />
</div>
<BottomNav />
</div>
</EventBrandingProvider>
);
}

View File

@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
import i18n from '@/i18n';
import { useAppearance } from '@/hooks/use-appearance';
import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes';
import profileRoutes from '@/routes/profile';
import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
@@ -249,7 +250,7 @@ const Header: React.FC = () => {
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild className="font-sans-marketing">
<Link href={localizedPath('/profile')}>
<Link href={profileRoutes.index().url}>
Profil
</Link>
</DropdownMenuItem>
@@ -376,7 +377,7 @@ const Header: React.FC = () => {
<>
<SheetClose asChild>
<Link
href={localizedPath('/profile')}
href={profileRoutes.index().url}
className="rounded-md border border-transparent px-3 py-2 text-base font-medium text-gray-700 transition hover:border-gray-200 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-900/60 font-sans-marketing"
onClick={handleNavSelect}
>

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;

View File

@@ -29,6 +29,7 @@ export interface SharedData {
sidebarOpen: boolean;
supportedLocales?: string[];
locale?: string;
translations?: Record<string, Record<string, string>>;
security?: {
csp?: {
scriptNonce?: string;