1031 lines
37 KiB
TypeScript
1031 lines
37 KiB
TypeScript
import React from 'react';
|
||
import { useNavigate, useLocation } from 'react-router-dom';
|
||
import { useTranslation } from 'react-i18next';
|
||
import {
|
||
CalendarDays,
|
||
Camera,
|
||
AlertTriangle,
|
||
Sparkles,
|
||
Users,
|
||
Plus,
|
||
Settings,
|
||
QrCode,
|
||
ClipboardList,
|
||
Package as PackageIcon,
|
||
ArrowUpRight,
|
||
} from 'lucide-react';
|
||
import toast from 'react-hot-toast';
|
||
|
||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||
import { Badge } from '@/components/ui/badge';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||
import { Progress } from '@/components/ui/progress';
|
||
import { TenantHeroCard, TenantOnboardingChecklistCard, FrostedSurface } from '../components/tenant';
|
||
import type { ChecklistStep } from '../components/tenant';
|
||
|
||
import { AdminLayout } from '../components/AdminLayout';
|
||
import {
|
||
DashboardSummary,
|
||
getDashboardSummary,
|
||
getEvents,
|
||
getTenantPackagesOverview,
|
||
TenantEvent,
|
||
TenantPackageSummary,
|
||
} from '../api';
|
||
import { isAuthError } from '../auth/tokens';
|
||
import { useAuth } from '../auth/context';
|
||
import {
|
||
adminPath,
|
||
ADMIN_HOME_PATH,
|
||
ADMIN_EVENT_VIEW_PATH,
|
||
ADMIN_EVENTS_PATH,
|
||
ADMIN_EVENT_PHOTOS_PATH,
|
||
ADMIN_EVENT_INVITES_PATH,
|
||
ADMIN_BILLING_PATH,
|
||
ADMIN_SETTINGS_PATH,
|
||
ADMIN_WELCOME_BASE_PATH,
|
||
ADMIN_EVENT_CREATE_PATH,
|
||
buildEngagementTabPath,
|
||
} from '../constants';
|
||
import { useOnboardingProgress } from '../onboarding';
|
||
import { buildLimitWarnings } from '../lib/limitWarnings';
|
||
import type { LimitUsageSummary, GallerySummary } from '../lib/limitWarnings';
|
||
|
||
interface DashboardState {
|
||
summary: DashboardSummary | null;
|
||
events: TenantEvent[];
|
||
activePackage: TenantPackageSummary | null;
|
||
loading: boolean;
|
||
errorKey: string | null;
|
||
}
|
||
|
||
type ReadinessState = {
|
||
hasEvent: boolean;
|
||
hasTasks: boolean;
|
||
hasQrInvites: boolean;
|
||
hasPackage: boolean;
|
||
primaryEventSlug: string | null;
|
||
primaryEventName: string | null;
|
||
loading: boolean;
|
||
};
|
||
|
||
export default function DashboardPage() {
|
||
const navigate = useNavigate();
|
||
const location = useLocation();
|
||
const { user } = useAuth();
|
||
const { progress, markStep } = useOnboardingProgress();
|
||
const { t, i18n } = useTranslation('dashboard', { keyPrefix: 'dashboard' });
|
||
const { t: tc } = useTranslation('common');
|
||
|
||
const translate = React.useCallback(
|
||
(key: string, optionsOrFallback?: Record<string, unknown> | string, explicitFallback?: string) => {
|
||
const hasOptions = typeof optionsOrFallback === 'object' && optionsOrFallback !== null;
|
||
const options = hasOptions ? (optionsOrFallback as Record<string, unknown>) : undefined;
|
||
const fallback = typeof optionsOrFallback === 'string' ? optionsOrFallback : explicitFallback;
|
||
|
||
const value = t(key, { defaultValue: fallback, ...(options ?? {}) });
|
||
if (value === `dashboard.${key}`) {
|
||
const fallbackValue = i18n.t(`dashboard:${key}`, { defaultValue: fallback, ...(options ?? {}) });
|
||
if (fallbackValue !== `dashboard:${key}`) {
|
||
return fallbackValue;
|
||
}
|
||
|
||
if (fallback !== undefined) {
|
||
return fallback;
|
||
}
|
||
}
|
||
|
||
return value;
|
||
},
|
||
[t, i18n],
|
||
);
|
||
const [state, setState] = React.useState<DashboardState>({
|
||
summary: null,
|
||
events: [],
|
||
activePackage: null,
|
||
loading: true,
|
||
errorKey: null,
|
||
});
|
||
|
||
const [readiness, setReadiness] = React.useState<ReadinessState>({
|
||
hasEvent: false,
|
||
hasTasks: false,
|
||
hasQrInvites: false,
|
||
hasPackage: false,
|
||
primaryEventSlug: null,
|
||
primaryEventName: null,
|
||
loading: false,
|
||
});
|
||
|
||
React.useEffect(() => {
|
||
let cancelled = false;
|
||
(async () => {
|
||
try {
|
||
const [summary, events, packages] = await Promise.all([
|
||
getDashboardSummary().catch(() => null),
|
||
getEvents().catch(() => [] as TenantEvent[]),
|
||
getTenantPackagesOverview().catch(() => ({ packages: [], activePackage: null })),
|
||
]);
|
||
|
||
if (cancelled) {
|
||
return;
|
||
}
|
||
|
||
const fallbackSummary = buildSummaryFallback(events, packages.activePackage);
|
||
const primaryEvent = events[0] ?? null;
|
||
const primaryEventName = primaryEvent ? resolveEventName(primaryEvent.name, primaryEvent.slug) : null;
|
||
|
||
setReadiness({
|
||
hasEvent: events.length > 0,
|
||
hasTasks: primaryEvent ? (Number(primaryEvent.tasks_count ?? 0) > 0) : false,
|
||
hasQrInvites: primaryEvent
|
||
? Number(
|
||
primaryEvent.active_invites_count ??
|
||
primaryEvent.active_join_tokens_count ??
|
||
0
|
||
) > 0
|
||
: false,
|
||
hasPackage: Boolean(packages.activePackage),
|
||
primaryEventSlug: primaryEvent?.slug ?? null,
|
||
primaryEventName,
|
||
loading: false,
|
||
});
|
||
|
||
setState({
|
||
summary: summary ?? fallbackSummary,
|
||
events,
|
||
activePackage: packages.activePackage,
|
||
loading: false,
|
||
errorKey: null,
|
||
});
|
||
|
||
if (!primaryEvent && !cancelled) {
|
||
setReadiness((prev) => ({
|
||
...prev,
|
||
hasTasks: false,
|
||
hasQrInvites: false,
|
||
loading: false,
|
||
}));
|
||
}
|
||
} catch (error) {
|
||
if (!isAuthError(error)) {
|
||
setState((prev) => ({
|
||
...prev,
|
||
errorKey: 'loadFailed',
|
||
loading: false,
|
||
}));
|
||
}
|
||
}
|
||
})();
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, []);
|
||
|
||
const { summary, events, activePackage, loading, errorKey } = state;
|
||
|
||
React.useEffect(() => {
|
||
if (loading) {
|
||
return;
|
||
}
|
||
if (!progress.eventCreated && events.length === 0 && !location.pathname.startsWith(ADMIN_WELCOME_BASE_PATH)) {
|
||
navigate(ADMIN_WELCOME_BASE_PATH, { replace: true });
|
||
return;
|
||
}
|
||
if (events.length > 0 && !progress.eventCreated) {
|
||
const primary = events[0];
|
||
markStep({
|
||
eventCreated: true,
|
||
serverStep: 'event_created',
|
||
meta: primary ? { event_id: primary.id } : undefined,
|
||
});
|
||
}
|
||
}, [loading, events.length, progress.eventCreated, navigate, location.pathname, markStep]);
|
||
|
||
const greetingName = user?.name ?? translate('welcome.fallbackName');
|
||
const greetingTitle = translate('welcome.greeting', { name: greetingName });
|
||
const subtitle = translate('welcome.subtitle');
|
||
const errorMessage = errorKey ? translate(`errors.${errorKey}`) : null;
|
||
const dateLocale = i18n.language?.startsWith('en') ? 'en-GB' : 'de-DE';
|
||
|
||
const upcomingEvents = getUpcomingEvents(events);
|
||
const publishedEvents = events.filter((event) => event.status === 'published');
|
||
const primaryEvent = events[0] ?? null;
|
||
const primaryEventName = primaryEvent ? resolveEventName(primaryEvent.name, primaryEvent.slug) : null;
|
||
const primaryEventLimits = primaryEvent?.limits ?? null;
|
||
|
||
const limitTranslate = React.useCallback(
|
||
(key: string, options?: Record<string, unknown>) => tc(`limits.${key}`, options),
|
||
[tc],
|
||
);
|
||
|
||
const limitWarnings = React.useMemo(
|
||
() => buildLimitWarnings(primaryEventLimits, limitTranslate),
|
||
[primaryEventLimits, limitTranslate],
|
||
);
|
||
|
||
const shownToastsRef = React.useRef<Set<string>>(new Set());
|
||
|
||
React.useEffect(() => {
|
||
limitWarnings.forEach((warning) => {
|
||
const toastKey = `${warning.id}-${warning.message}`;
|
||
if (shownToastsRef.current.has(toastKey)) {
|
||
return;
|
||
}
|
||
|
||
shownToastsRef.current.add(toastKey);
|
||
toast(warning.message, {
|
||
icon: warning.tone === 'danger' ? '🚨' : '⚠️',
|
||
id: toastKey,
|
||
});
|
||
});
|
||
}, [limitWarnings]);
|
||
|
||
const limitScopeLabels = React.useMemo(
|
||
() => ({
|
||
photos: tc('limits.photosTitle'),
|
||
guests: tc('limits.guestsTitle'),
|
||
gallery: tc('limits.galleryTitle'),
|
||
}),
|
||
[tc],
|
||
);
|
||
|
||
const hasPhotos = React.useMemo(() => {
|
||
if ((summary?.new_photos ?? 0) > 0) {
|
||
return true;
|
||
}
|
||
|
||
return events.some((event) => Number(event.photo_count ?? 0) > 0 || Number(event.pending_photo_count ?? 0) > 0);
|
||
}, [summary, events]);
|
||
|
||
const primaryEventSlug = readiness.primaryEventSlug;
|
||
|
||
const onboardingChecklist = React.useMemo<ChecklistStep[]>(() => {
|
||
const steps: ChecklistStep[] = [
|
||
{
|
||
key: 'admin_app',
|
||
title: translate('onboarding.admin_app.title', 'Admin-App öffnen'),
|
||
description: translate(
|
||
'onboarding.admin_app.description',
|
||
'Verwalte Events, Uploads und Gäste direkt in der Admin-App.'
|
||
),
|
||
done: Boolean(progress.adminAppOpenedAt),
|
||
ctaLabel: translate('onboarding.admin_app.cta', 'Admin-App starten'),
|
||
onAction: () => navigate(ADMIN_HOME_PATH),
|
||
icon: Sparkles,
|
||
},
|
||
{
|
||
key: 'event_setup',
|
||
title: translate('onboarding.event_setup.title', 'Erstes Event vorbereiten'),
|
||
description: translate(
|
||
'onboarding.event_setup.description',
|
||
'Lege in der Admin-App Name, Datum und Aufgaben fest.'
|
||
),
|
||
done: readiness.hasEvent,
|
||
ctaLabel: translate('onboarding.event_setup.cta', 'Event anlegen'),
|
||
onAction: () => navigate(ADMIN_EVENT_CREATE_PATH),
|
||
icon: CalendarDays,
|
||
},
|
||
{
|
||
key: 'invite_guests',
|
||
title: translate('onboarding.invite_guests.title', 'Gäste einladen'),
|
||
description: translate(
|
||
'onboarding.invite_guests.description',
|
||
'Teile QR-Codes oder Links, damit Gäste sofort starten.'
|
||
),
|
||
done: readiness.hasQrInvites || progress.inviteCreated,
|
||
ctaLabel: translate('onboarding.invite_guests.cta', 'QR-Links öffnen'),
|
||
onAction: () => {
|
||
if (primaryEventSlug) {
|
||
navigate(`${ADMIN_EVENT_VIEW_PATH(primaryEventSlug)}#qr-invites`);
|
||
return;
|
||
}
|
||
navigate(ADMIN_EVENTS_PATH);
|
||
},
|
||
icon: QrCode,
|
||
},
|
||
{
|
||
key: 'collect_photos',
|
||
title: translate('onboarding.collect_photos.title', 'Erste Fotos einsammeln'),
|
||
description: translate(
|
||
'onboarding.collect_photos.description',
|
||
'Sobald Uploads eintreffen, moderierst du sie in der Admin-App.'
|
||
),
|
||
done: hasPhotos,
|
||
ctaLabel: translate('onboarding.collect_photos.cta', 'Uploads prüfen'),
|
||
onAction: () => {
|
||
if (primaryEventSlug) {
|
||
navigate(ADMIN_EVENT_PHOTOS_PATH(primaryEventSlug));
|
||
return;
|
||
}
|
||
navigate(ADMIN_EVENTS_PATH);
|
||
},
|
||
icon: Camera,
|
||
},
|
||
{
|
||
key: 'branding',
|
||
title: translate('onboarding.branding.title', 'Branding & Aufgaben verfeinern'),
|
||
description: translate(
|
||
'onboarding.branding.description',
|
||
'Passt Farbwelt und Aufgabenpakete an euren Anlass an.'
|
||
),
|
||
done: (progress.brandingConfigured || readiness.hasTasks) && (readiness.hasPackage || progress.packageSelected),
|
||
ctaLabel: translate('onboarding.branding.cta', 'Branding öffnen'),
|
||
onAction: () => {
|
||
if (primaryEventSlug) {
|
||
navigate(ADMIN_EVENT_INVITES_PATH(primaryEventSlug));
|
||
return;
|
||
}
|
||
navigate(ADMIN_EVENTS_PATH);
|
||
},
|
||
icon: ClipboardList,
|
||
},
|
||
];
|
||
|
||
return steps;
|
||
}, [
|
||
translate,
|
||
progress.adminAppOpenedAt,
|
||
progress.inviteCreated,
|
||
progress.brandingConfigured,
|
||
progress.packageSelected,
|
||
readiness.hasEvent,
|
||
readiness.hasQrInvites,
|
||
readiness.hasTasks,
|
||
readiness.hasPackage,
|
||
hasPhotos,
|
||
navigate,
|
||
primaryEventSlug,
|
||
]);
|
||
|
||
const completedOnboardingSteps = React.useMemo(
|
||
() => onboardingChecklist.filter((step) => step.done).length,
|
||
[onboardingChecklist]
|
||
);
|
||
|
||
const onboardingCompletion = React.useMemo(() => {
|
||
if (onboardingChecklist.length === 0) {
|
||
return 0;
|
||
}
|
||
return Math.round((completedOnboardingSteps / onboardingChecklist.length) * 100);
|
||
}, [completedOnboardingSteps, onboardingChecklist]);
|
||
|
||
const onboardingCardTitle = translate('onboarding.card.title', 'Dein Start in fünf Schritten');
|
||
const onboardingCardDescription = translate(
|
||
'onboarding.card.description',
|
||
'Bearbeite die Schritte in der Admin-App – das Dashboard zeigt dir den Status.'
|
||
);
|
||
const onboardingCompletedCopy = translate(
|
||
'onboarding.card.completed',
|
||
'Alle Schritte abgeschlossen – großartig! Du kannst jederzeit zur Admin-App wechseln.'
|
||
);
|
||
const onboardingFallbackCta = translate('onboarding.card.cta_fallback', 'Jetzt starten');
|
||
const heroBadge = translate('overview.title', 'Kurzer Überblick');
|
||
const heroDescription = translate(
|
||
'overview.description',
|
||
'Wichtigste Kennzahlen deines Tenants auf einen Blick.'
|
||
);
|
||
const marketingDashboardLabel = translate('onboarding.back_to_marketing', 'Marketing-Dashboard ansehen');
|
||
const marketingDashboardDescription = translate(
|
||
'onboarding.back_to_marketing_description',
|
||
'Zur Zusammenfassung im Kundenportal wechseln.'
|
||
);
|
||
const heroSupportingCopy = onboardingCompletion === 100 ? onboardingCompletedCopy : onboardingCardDescription;
|
||
const heroPrimaryCtaLabel = readiness.hasEvent
|
||
? translate('quickActions.moderatePhotos.label', 'Fotos moderieren')
|
||
: translate('actions.newEvent');
|
||
const heroPrimaryAction = (
|
||
<Button
|
||
size="sm"
|
||
className="rounded-full bg-gradient-to-r from-[#ff5f87] via-[#ec4899] to-[#6366f1] px-6 text-white shadow-md shadow-rose-400/30 transition hover:from-[#ff4470] hover:via-[#ec4899] hover:to-[#4f46e5]"
|
||
onClick={() => {
|
||
if (readiness.hasEvent) {
|
||
navigate(ADMIN_EVENTS_PATH);
|
||
} else {
|
||
navigate(ADMIN_EVENT_CREATE_PATH);
|
||
}
|
||
}}
|
||
>
|
||
{heroPrimaryCtaLabel}
|
||
</Button>
|
||
);
|
||
const heroSecondaryAction = (
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
className="rounded-full border-white/70 bg-white/80 px-6 text-slate-900 shadow-sm hover:bg-white"
|
||
onClick={() => window.location.assign('/dashboard')}
|
||
>
|
||
{marketingDashboardLabel}
|
||
<ArrowUpRight className="ml-2 h-4 w-4" />
|
||
</Button>
|
||
);
|
||
const heroAside = (
|
||
<FrostedSurface className="w-full rounded-2xl border-white/30 bg-white/90 p-5 text-slate-900 shadow-lg shadow-rose-300/20 backdrop-blur">
|
||
<div className="flex items-center justify-between text-sm font-medium text-slate-700">
|
||
<span>{onboardingCardTitle}</span>
|
||
<span>
|
||
{completedOnboardingSteps}/{onboardingChecklist.length}
|
||
</span>
|
||
</div>
|
||
<Progress value={onboardingCompletion} className="mt-4 h-2 bg-rose-100" />
|
||
<p className="mt-3 text-xs text-slate-600">{onboardingCardDescription}</p>
|
||
</FrostedSurface>
|
||
);
|
||
const readinessCompleteLabel = translate('readiness.complete', 'Erledigt');
|
||
const readinessPendingLabel = translate('readiness.pending', 'Noch offen');
|
||
|
||
const actions = (
|
||
<>
|
||
<Button
|
||
className="bg-brand-rose text-white shadow-lg shadow-rose-400/40 hover:bg-[var(--brand-rose-strong)]"
|
||
onClick={() => navigate(ADMIN_EVENT_CREATE_PATH)}
|
||
>
|
||
<Plus className="h-4 w-4" /> {translate('actions.newEvent')}
|
||
</Button>
|
||
<Button variant="outline" onClick={() => navigate(ADMIN_EVENTS_PATH)} className="border-brand-rose-soft text-brand-rose">
|
||
<CalendarDays className="h-4 w-4" /> {translate('actions.allEvents')}
|
||
</Button>
|
||
{events.length === 0 && (
|
||
<Button
|
||
variant="outline"
|
||
onClick={() => navigate(ADMIN_WELCOME_BASE_PATH)}
|
||
className="border-brand-rose-soft text-brand-rose hover:bg-brand-rose-soft/40"
|
||
>
|
||
<Sparkles className="h-4 w-4" /> {translate('actions.guidedSetup')}
|
||
</Button>
|
||
)}
|
||
</>
|
||
);
|
||
|
||
return (
|
||
<AdminLayout title={greetingTitle} subtitle={subtitle} actions={actions}>
|
||
{errorMessage && (
|
||
<Alert variant="destructive">
|
||
<AlertTitle>{t('dashboard.alerts.errorTitle')}</AlertTitle>
|
||
<AlertDescription>{errorMessage}</AlertDescription>
|
||
</Alert>
|
||
)}
|
||
|
||
{loading ? (
|
||
<DashboardSkeleton />
|
||
) : (
|
||
<>
|
||
<TenantHeroCard
|
||
badge={heroBadge}
|
||
title={greetingTitle}
|
||
description={subtitle}
|
||
supporting={[heroDescription, heroSupportingCopy]}
|
||
primaryAction={heroPrimaryAction}
|
||
secondaryAction={heroSecondaryAction}
|
||
aside={heroAside}
|
||
/>
|
||
|
||
{events.length === 0 && (
|
||
<Card className="border-0 bg-brand-card shadow-brand-primary">
|
||
<CardHeader className="space-y-3">
|
||
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
|
||
<Sparkles className="h-5 w-5 text-brand-rose" />
|
||
{translate('welcomeCard.title')}
|
||
</CardTitle>
|
||
<CardDescription className="text-sm text-slate-600">
|
||
{translate('welcomeCard.summary')}
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||
<div className="space-y-2 text-sm text-slate-600">
|
||
<p>{translate('welcomeCard.body1')}</p>
|
||
<p>{translate('welcomeCard.body2')}</p>
|
||
</div>
|
||
<Button
|
||
size="lg"
|
||
className="rounded-full bg-rose-500 text-white shadow-lg shadow-rose-400/40 hover:bg-rose-500/90"
|
||
onClick={() => navigate(ADMIN_WELCOME_BASE_PATH)}
|
||
>
|
||
{translate('welcomeCard.cta')}
|
||
</Button>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
<Card className="border-0 bg-brand-card shadow-brand-primary">
|
||
<CardHeader className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||
<div>
|
||
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
|
||
<Sparkles className="h-5 w-5 text-brand-rose" />
|
||
{translate('overview.title')}
|
||
</CardTitle>
|
||
<CardDescription className="text-sm text-slate-600">
|
||
{translate('overview.description')}
|
||
</CardDescription>
|
||
</div>
|
||
<Badge className="bg-brand-rose-soft text-brand-rose">
|
||
{activePackage?.package_name ?? translate('overview.noPackage')}
|
||
</Badge>
|
||
</CardHeader>
|
||
<CardContent className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||
<StatCard
|
||
label={translate('overview.stats.activeEvents')}
|
||
value={summary?.active_events ?? publishedEvents.length}
|
||
hint={translate('overview.stats.publishedHint', { count: publishedEvents.length })}
|
||
icon={<CalendarDays className="h-5 w-5 text-brand-rose" />}
|
||
/>
|
||
<StatCard
|
||
label={translate('overview.stats.newPhotos')}
|
||
value={summary?.new_photos ?? 0}
|
||
icon={<Camera className="h-5 w-5 text-fuchsia-500" />}
|
||
/>
|
||
<StatCard
|
||
label={translate('overview.stats.taskProgress')}
|
||
value={`${Math.round(summary?.task_progress ?? 0)}%`}
|
||
icon={<Users className="h-5 w-5 text-amber-500" />}
|
||
/>
|
||
{activePackage ? (
|
||
<StatCard
|
||
label={translate('overview.stats.activePackage')}
|
||
value={activePackage.package_name}
|
||
icon={<Sparkles className="h-5 w-5 text-sky-500" />}
|
||
/>
|
||
) : null}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{primaryEventLimits ? (
|
||
<Card className="border-0 bg-brand-card shadow-brand-primary">
|
||
<CardHeader className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||
<div>
|
||
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
|
||
<PackageIcon className="h-5 w-5 text-brand-rose" />
|
||
{translate('limitsCard.title')}
|
||
</CardTitle>
|
||
<CardDescription className="text-sm text-slate-600">
|
||
{primaryEventName
|
||
? translate('limitsCard.description', { name: primaryEventName })
|
||
: translate('limitsCard.descriptionFallback')}
|
||
</CardDescription>
|
||
</div>
|
||
<Badge className="bg-brand-rose-soft text-brand-rose">
|
||
{primaryEventName ?? translate('limitsCard.descriptionFallback')}
|
||
</Badge>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
{limitWarnings.length > 0 && (
|
||
<div className="space-y-2">
|
||
{limitWarnings.map((warning) => (
|
||
<Alert
|
||
key={warning.id}
|
||
variant={warning.tone === 'danger' ? 'destructive' : 'default'}
|
||
className={warning.tone === 'warning' ? 'border-amber-400/50 bg-amber-50 text-amber-900' : undefined}
|
||
>
|
||
<AlertTitle className="flex items-center gap-2 text-sm font-semibold">
|
||
<AlertTriangle className="h-4 w-4" />
|
||
{limitScopeLabels[warning.scope]}
|
||
</AlertTitle>
|
||
<AlertDescription className="text-sm">
|
||
{warning.message}
|
||
</AlertDescription>
|
||
</Alert>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
<div className="grid gap-4 md:grid-cols-2">
|
||
<LimitUsageRow
|
||
label={translate('limitsCard.photosLabel')}
|
||
summary={primaryEventLimits.photos}
|
||
unlimitedLabel={tc('limits.unlimited')}
|
||
usageLabel={translate('limitsCard.usageLabel')}
|
||
remainingLabel={translate('limitsCard.remainingLabel')}
|
||
/>
|
||
<LimitUsageRow
|
||
label={translate('limitsCard.guestsLabel')}
|
||
summary={primaryEventLimits.guests}
|
||
unlimitedLabel={tc('limits.unlimited')}
|
||
usageLabel={translate('limitsCard.usageLabel')}
|
||
remainingLabel={translate('limitsCard.remainingLabel')}
|
||
/>
|
||
</div>
|
||
|
||
<GalleryStatusRow
|
||
label={translate('limitsCard.galleryLabel')}
|
||
summary={primaryEventLimits.gallery}
|
||
locale={dateLocale}
|
||
messages={{
|
||
expired: tc('limits.galleryExpired'),
|
||
noExpiry: translate('limitsCard.galleryNoExpiry'),
|
||
expires: translate('limitsCard.galleryExpires'),
|
||
}}
|
||
/>
|
||
</CardContent>
|
||
</Card>
|
||
) : null}
|
||
|
||
<Card className="border-0 bg-brand-card shadow-brand-primary">
|
||
<CardHeader className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||
<div>
|
||
<CardTitle className="text-xl text-slate-900">{translate('quickActions.title')}</CardTitle>
|
||
<CardDescription className="text-sm text-slate-600">
|
||
{translate('quickActions.description')}
|
||
</CardDescription>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||
<QuickAction
|
||
icon={<Plus className="h-5 w-5" />}
|
||
label={translate('quickActions.createEvent.label')}
|
||
description={translate('quickActions.createEvent.description')}
|
||
onClick={() => navigate(ADMIN_EVENT_CREATE_PATH)}
|
||
/>
|
||
<QuickAction
|
||
icon={<Camera className="h-5 w-5" />}
|
||
label={translate('quickActions.moderatePhotos.label')}
|
||
description={translate('quickActions.moderatePhotos.description')}
|
||
onClick={() => navigate(ADMIN_EVENTS_PATH)}
|
||
/>
|
||
<QuickAction
|
||
icon={<Users className="h-5 w-5" />}
|
||
label={translate('quickActions.organiseTasks.label')}
|
||
description={translate('quickActions.organiseTasks.description')}
|
||
onClick={() => navigate(buildEngagementTabPath('tasks'))}
|
||
/>
|
||
<QuickAction
|
||
icon={<Sparkles className="h-5 w-5" />}
|
||
label={translate('quickActions.managePackages.label')}
|
||
description={translate('quickActions.managePackages.description')}
|
||
onClick={() => navigate(ADMIN_BILLING_PATH)}
|
||
/>
|
||
<QuickAction
|
||
icon={<ArrowUpRight className="h-5 w-5" />}
|
||
label={marketingDashboardLabel}
|
||
description={marketingDashboardDescription}
|
||
onClick={() => window.location.assign('/dashboard')}
|
||
/>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<TenantOnboardingChecklistCard
|
||
title={onboardingCardTitle}
|
||
description={onboardingCardDescription}
|
||
steps={onboardingChecklist}
|
||
completedLabel={readinessCompleteLabel}
|
||
pendingLabel={readinessPendingLabel}
|
||
completionPercent={onboardingCompletion}
|
||
completedCount={completedOnboardingSteps}
|
||
totalCount={onboardingChecklist.length}
|
||
emptyCopy={onboardingCompletedCopy}
|
||
fallbackActionLabel={onboardingFallbackCta}
|
||
/>
|
||
|
||
<Card className="border-0 bg-brand-card shadow-brand-primary">
|
||
<CardHeader className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||
<div>
|
||
<CardTitle className="text-xl text-slate-900">{translate('upcoming.title')}</CardTitle>
|
||
<CardDescription className="text-sm text-slate-600">
|
||
{translate('upcoming.description')}
|
||
</CardDescription>
|
||
</div>
|
||
<Button variant="outline" onClick={() => navigate(ADMIN_SETTINGS_PATH)}>
|
||
<Settings className="h-4 w-4" />
|
||
{translate('upcoming.settings')}
|
||
</Button>
|
||
</CardHeader>
|
||
<CardContent className="space-y-3">
|
||
{upcomingEvents.length === 0 ? (
|
||
<EmptyState
|
||
message={translate('upcoming.empty.message')}
|
||
ctaLabel={translate('upcoming.empty.cta')}
|
||
onCta={() => navigate(adminPath('/events/new'))}
|
||
/>
|
||
) : (
|
||
upcomingEvents.map((event) => (
|
||
<UpcomingEventRow
|
||
key={event.id}
|
||
event={event}
|
||
onView={() => navigate(ADMIN_EVENT_VIEW_PATH(event.slug))}
|
||
locale={dateLocale}
|
||
labels={{
|
||
live: translate('upcoming.status.live'),
|
||
planning: translate('upcoming.status.planning'),
|
||
open: tc('actions.open'),
|
||
noDate: translate('upcoming.status.noDate'),
|
||
}}
|
||
/>
|
||
))
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
</>
|
||
)}
|
||
</AdminLayout>
|
||
);
|
||
}
|
||
|
||
function formatDate(value: string | null, locale: string): string | null {
|
||
if (!value) {
|
||
return null;
|
||
}
|
||
|
||
const date = new Date(value);
|
||
if (Number.isNaN(date.getTime())) {
|
||
return null;
|
||
}
|
||
|
||
try {
|
||
return new Intl.DateTimeFormat(locale, {
|
||
day: '2-digit',
|
||
month: 'short',
|
||
year: 'numeric',
|
||
}).format(date);
|
||
} catch {
|
||
return date.toISOString().slice(0, 10);
|
||
}
|
||
}
|
||
|
||
function resolveEventName(name: TenantEvent['name'], fallbackSlug: string): string {
|
||
if (typeof name === 'string' && name.trim().length > 0) {
|
||
return name;
|
||
}
|
||
|
||
if (name && typeof name === 'object') {
|
||
if (typeof name.de === 'string' && name.de.trim().length > 0) {
|
||
return name.de;
|
||
}
|
||
if (typeof name.en === 'string' && name.en.trim().length > 0) {
|
||
return name.en;
|
||
}
|
||
const first = Object.values(name).find((value) => typeof value === 'string' && value.trim().length > 0);
|
||
if (typeof first === 'string') {
|
||
return first;
|
||
}
|
||
}
|
||
|
||
return fallbackSlug || 'Event';
|
||
}
|
||
|
||
function buildSummaryFallback(
|
||
events: TenantEvent[],
|
||
activePackage: TenantPackageSummary | null
|
||
): DashboardSummary {
|
||
const activeEvents = events.filter((event) => Boolean(event.is_active || event.status === 'published'));
|
||
const totalPhotos = events.reduce((sum, event) => sum + Number(event.photo_count ?? 0), 0);
|
||
|
||
return {
|
||
active_events: activeEvents.length,
|
||
new_photos: totalPhotos,
|
||
task_progress: 0,
|
||
upcoming_events: activeEvents.length,
|
||
active_package: activePackage
|
||
? {
|
||
name: activePackage.package_name,
|
||
remaining_events: activePackage.remaining_events,
|
||
expires_at: activePackage.expires_at,
|
||
}
|
||
: null,
|
||
};
|
||
}
|
||
|
||
function getUpcomingEvents(events: TenantEvent[]): TenantEvent[] {
|
||
const now = new Date();
|
||
return events
|
||
.filter((event) => {
|
||
if (!event.event_date) return false;
|
||
const date = new Date(event.event_date);
|
||
return !Number.isNaN(date.getTime()) && date >= now;
|
||
})
|
||
.sort((a, b) => {
|
||
const dateA = a.event_date ? new Date(a.event_date).getTime() : 0;
|
||
const dateB = b.event_date ? new Date(b.event_date).getTime() : 0;
|
||
return dateA - dateB;
|
||
})
|
||
.slice(0, 4);
|
||
}
|
||
|
||
function LimitUsageRow({
|
||
label,
|
||
summary,
|
||
unlimitedLabel,
|
||
usageLabel,
|
||
remainingLabel,
|
||
}: {
|
||
label: string;
|
||
summary: LimitUsageSummary | null;
|
||
unlimitedLabel: string;
|
||
usageLabel: string;
|
||
remainingLabel: string;
|
||
}) {
|
||
if (!summary) {
|
||
return (
|
||
<div className="rounded-2xl border border-slate-200 bg-white/70 p-4">
|
||
<div className="flex items-center justify-between text-sm font-medium text-slate-700">
|
||
<span>{label}</span>
|
||
<span className="text-xs text-slate-500 dark:text-slate-400">{unlimitedLabel}</span>
|
||
</div>
|
||
<p className="mt-2 text-xs text-slate-500 dark:text-slate-400">{unlimitedLabel}</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const limit = typeof summary.limit === 'number' && summary.limit > 0 ? summary.limit : null;
|
||
const percent = limit ? Math.min(100, Math.round((summary.used / limit) * 100)) : 0;
|
||
const remaining = typeof summary.remaining === 'number' ? summary.remaining : null;
|
||
|
||
const barClass = summary.state === 'limit_reached'
|
||
? 'bg-rose-500'
|
||
: summary.state === 'warning'
|
||
? 'bg-amber-500'
|
||
: 'bg-emerald-500';
|
||
|
||
return (
|
||
<div className="rounded-2xl border border-slate-200 bg-white/70 p-4 dark:border-slate-800/70 dark:bg-slate-950/80">
|
||
<div className="flex items-center justify-between text-sm font-medium text-slate-700 dark:text-slate-200">
|
||
<span>{label}</span>
|
||
<span className="text-xs text-slate-500 dark:text-slate-400">
|
||
{limit ? usageLabel.replace('{{used}}', `${summary.used}`).replace('{{limit}}', `${limit}`) : unlimitedLabel}
|
||
</span>
|
||
</div>
|
||
{limit ? (
|
||
<>
|
||
<div className="mt-3 h-2 rounded-full bg-slate-200 dark:bg-slate-800">
|
||
<div
|
||
className={`h-2 rounded-full transition-all ${barClass}`}
|
||
style={{ width: `${Math.max(6, percent)}%` }}
|
||
/>
|
||
</div>
|
||
{remaining !== null ? (
|
||
<p className="mt-2 text-xs text-slate-500 dark:text-slate-400">
|
||
{remainingLabel
|
||
.replace('{{remaining}}', `${Math.max(0, remaining)}`)
|
||
.replace('{{limit}}', `${limit}`)}
|
||
</p>
|
||
) : null}
|
||
</>
|
||
) : (
|
||
<p className="mt-2 text-xs text-slate-500 dark:text-slate-400">{unlimitedLabel}</p>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function GalleryStatusRow({
|
||
label,
|
||
summary,
|
||
locale,
|
||
messages,
|
||
}: {
|
||
label: string;
|
||
summary: GallerySummary | null;
|
||
locale: string;
|
||
messages: { expired: string; noExpiry: string; expires: string };
|
||
}) {
|
||
const expiresAt = summary?.expires_at ? formatDate(summary.expires_at, locale) : null;
|
||
|
||
let statusLabel = messages.noExpiry;
|
||
let badgeClass = 'bg-emerald-500/20 text-emerald-700';
|
||
|
||
if (summary?.state === 'expired') {
|
||
statusLabel = messages.expired;
|
||
badgeClass = 'bg-rose-500/20 text-rose-700';
|
||
} else if (summary?.state === 'warning') {
|
||
const days = Math.max(0, summary.days_remaining ?? 0);
|
||
statusLabel = `${messages.expires.replace('{{date}}', expiresAt ?? '')} (${days}d)`;
|
||
badgeClass = 'bg-amber-500/20 text-amber-700';
|
||
} else if (summary?.state === 'ok' && expiresAt) {
|
||
statusLabel = messages.expires.replace('{{date}}', expiresAt);
|
||
}
|
||
|
||
return (
|
||
<div className="rounded-2xl border border-slate-200 bg-white/70 p-4 dark:border-slate-800/70 dark:bg-slate-950/80">
|
||
<div className="flex items-center justify-between text-sm font-medium text-slate-700 dark:text-slate-200">
|
||
<span>{label}</span>
|
||
<span className={`rounded-full px-3 py-1 text-xs font-semibold ${badgeClass} dark:text-slate-100`}>{statusLabel}</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function StatCard({
|
||
label,
|
||
value,
|
||
hint,
|
||
icon,
|
||
}: {
|
||
label: string;
|
||
value: string | number;
|
||
hint?: string;
|
||
icon: React.ReactNode;
|
||
}) {
|
||
return (
|
||
<FrostedSurface className="border-brand-rose-soft/40 p-5 shadow-md shadow-pink-100/30 transition-transform duration-200 ease-out hover:-translate-y-0.5 hover:shadow-lg hover:shadow-rose-300/30">
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-xs uppercase tracking-wide text-slate-500 dark:text-slate-400">{label}</span>
|
||
<span className="rounded-full bg-brand-rose-soft p-2 text-brand-rose">{icon}</span>
|
||
</div>
|
||
<div className="mt-4 text-2xl font-semibold text-slate-900 dark:text-slate-100">{value}</div>
|
||
{hint && <p className="mt-2 text-xs text-slate-500 dark:text-slate-400">{hint}</p>}
|
||
</FrostedSurface>
|
||
);
|
||
}
|
||
|
||
function QuickAction({
|
||
icon,
|
||
label,
|
||
description,
|
||
onClick,
|
||
}: {
|
||
icon: React.ReactNode;
|
||
label: string;
|
||
description: string;
|
||
onClick: () => void;
|
||
}) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onClick}
|
||
className="group flex flex-col items-start gap-2 rounded-2xl border border-slate-100 bg-white/85 p-4 text-left shadow-sm transition duration-200 ease-out hover:-translate-y-0.5 hover:border-brand-rose-soft hover:shadow-md focus:outline-none focus:ring-2 focus:ring-brand-rose/40 dark:border-slate-800/70 dark:bg-slate-950/80"
|
||
>
|
||
<span className="rounded-full bg-brand-rose-soft p-2 text-brand-rose shadow-sm shadow-rose-200/60 transition-transform duration-200 group-hover:scale-105">{icon}</span>
|
||
<span className="text-sm font-semibold text-slate-900 dark:text-slate-100">{label}</span>
|
||
<span className="text-xs text-slate-600 dark:text-slate-400">{description}</span>
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function UpcomingEventRow({
|
||
event,
|
||
onView,
|
||
locale,
|
||
labels,
|
||
}: {
|
||
event: TenantEvent;
|
||
onView: () => void;
|
||
locale: string;
|
||
labels: {
|
||
live: string;
|
||
planning: string;
|
||
open: string;
|
||
noDate: string;
|
||
};
|
||
}) {
|
||
const date = event.event_date ? new Date(event.event_date) : null;
|
||
const formattedDate = date
|
||
? date.toLocaleDateString(locale, { day: '2-digit', month: 'short', year: 'numeric' })
|
||
: labels.noDate;
|
||
|
||
return (
|
||
<div className="flex flex-col gap-2 rounded-2xl border border-sky-100 bg-white/90 p-4 shadow-sm sm:flex-row sm:items-center sm:justify-between">
|
||
<div className="flex flex-col gap-1">
|
||
<span className="text-xs font-medium uppercase tracking-wide text-slate-500">{formattedDate}</span>
|
||
<div className="flex items-center gap-2">
|
||
<Badge className={event.status === 'published' ? 'bg-sky-100 text-sky-700' : 'bg-slate-100 text-slate-600'}>
|
||
{event.status === 'published' ? labels.live : labels.planning}
|
||
</Badge>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={onView}
|
||
className="border-brand-rose-soft text-brand-rose hover:bg-brand-rose-soft/40"
|
||
>
|
||
{labels.open}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function EmptyState({ message, ctaLabel, onCta }: { message: string; ctaLabel: string; onCta: () => void }) {
|
||
return (
|
||
<div className="flex flex-col items-center justify-center gap-3 rounded-2xl border border-dashed border-slate-200 bg-white/70 p-10 text-center">
|
||
<div className="rounded-full bg-brand-rose-soft p-3 text-brand-rose shadow-inner shadow-pink-200/80">
|
||
<Sparkles className="h-5 w-5" />
|
||
</div>
|
||
<p className="text-sm text-slate-600">{message}</p>
|
||
<Button onClick={onCta} className="bg-brand-rose text-white shadow-lg shadow-rose-400/40 hover:bg-[var(--brand-rose-strong)]">
|
||
{ctaLabel}
|
||
</Button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function DashboardSkeleton() {
|
||
return (
|
||
<div className="grid gap-6">
|
||
{Array.from({ length: 3 }).map((_, index) => (
|
||
<div key={index} className="space-y-4 rounded-2xl border border-white/60 bg-white/70 p-6 shadow-sm">
|
||
<div className="h-6 w-48 animate-pulse rounded bg-gradient-to-r from-white/40 via-white/60 to-white/40" />
|
||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||
{Array.from({ length: 4 }).map((__ , cardIndex) => (
|
||
<div
|
||
key={cardIndex}
|
||
className="h-24 animate-pulse rounded-2xl bg-gradient-to-r from-white/40 via-white/60 to-white/40"
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|