Files
fotospiel-app/resources/js/admin/pages/DashboardPage.tsx

969 lines
34 KiB
TypeScript
Raw 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.
// @ts-nocheck
import React from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
Camera,
AlertTriangle,
Sparkles,
CalendarDays,
Plus,
Settings,
QrCode,
ClipboardList,
Package as PackageIcon,
} from 'lucide-react';
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import {
TenantOnboardingChecklistCard,
SectionCard,
SectionHeader,
ActionGrid,
} 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 { useEventContext } from '../context/EventContext';
import {
adminPath,
ADMIN_HOME_PATH,
ADMIN_EVENT_VIEW_PATH,
ADMIN_EVENTS_PATH,
ADMIN_EVENT_PHOTOS_PATH,
ADMIN_EVENT_INVITES_PATH,
ADMIN_EVENT_TASKS_PATH,
ADMIN_EVENT_PHOTOBOOTH_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 { DashboardEventFocusCard } from '../components/dashboard/DashboardEventFocusCard';
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 { events: ctxEvents, activeEvent: ctxActiveEvent, selectEvent } = useEventContext();
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({ force: true }).catch(() => [] as TenantEvent[]),
getTenantPackagesOverview().catch(() => ({ packages: [], activePackage: null })),
]);
if (cancelled) {
return;
}
const fallbackSummary = buildSummaryFallback(events, packages.activePackage);
const eventPool = events.length ? events : ctxEvents;
const primaryEvent = ctxActiveEvent ?? eventPool[0] ?? null;
const primaryEventName = primaryEvent ? resolveEventName(primaryEvent.name, primaryEvent.slug) : null;
setReadiness({
hasEvent: eventPool.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: eventPool,
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 (events.length > 0 && !progress.eventCreated) {
const primary = events[0];
markStep({
eventCreated: true,
serverStep: 'event_created',
meta: primary ? { event_id: primary.id } : undefined,
});
}
}, [loading, events, 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 canCreateEvent = React.useMemo(() => {
if (!activePackage) {
return true;
}
if (activePackage.remaining_events === null || activePackage.remaining_events === undefined) {
return true;
}
return activePackage.remaining_events > 0;
}, [activePackage]);
const eventOptions = ctxEvents.length ? ctxEvents : events;
const [selectedSlug, setSelectedSlug] = React.useState<string | null>(() => ctxActiveEvent?.slug ?? eventOptions[0]?.slug ?? null);
React.useEffect(() => {
setSelectedSlug(ctxActiveEvent?.slug ?? eventOptions[0]?.slug ?? null);
}, [ctxActiveEvent?.slug, eventOptions]);
const upcomingEvents = getUpcomingEvents(eventOptions);
const publishedEvents = eventOptions.filter((event) => event.status === 'published');
const primaryEvent = React.useMemo(
() => eventOptions.find((event) => event.slug === selectedSlug) ?? eventOptions[0] ?? null,
[eventOptions, selectedSlug],
);
const primaryEventName = primaryEvent ? resolveEventName(primaryEvent.name, primaryEvent.slug) : null;
const singleEvent = eventOptions.length === 1 ? eventOptions[0] : null;
const singleEventName = singleEvent ? resolveEventName(singleEvent.name, singleEvent.slug) : null;
const singleEventDateLabel = singleEvent?.event_date ? formatDate(singleEvent.event_date, dateLocale) : 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());
// Limit warnings werden ausschließlich in der Limits-Karte angezeigt; keine Toasts mehr
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 liveEvents = React.useMemo(() => {
const now = Date.now();
const windowLengthMs = 2 * 24 * 60 * 60 * 1000; // event day + following day
return events.filter((event) => {
if (!event.slug) {
return false;
}
const isActivated = Boolean(event.is_active || event.status === 'published');
if (!isActivated) {
return false;
}
if (!event.event_date) {
return true;
}
const eventStart = new Date(event.event_date).getTime();
if (Number.isNaN(eventStart)) {
return true;
}
return now >= eventStart && now <= eventStart + windowLengthMs;
});
}, [events]);
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 readinessCompleteLabel = translate('readiness.complete', 'Erledigt');
const readinessPendingLabel = translate('readiness.pending', 'Noch offen');
const hasEventContext = readiness.hasEvent;
const quickActionItems = React.useMemo(
() => [
{
key: 'create',
label: translate('quickActions.createEvent.label'),
description: translate('quickActions.createEvent.description'),
icon: <Plus className="h-5 w-5" />,
onClick: () => {
if (!canCreateEvent) {
toast.error(tc('errors.eventLimit', 'Dein aktuelles Paket enthält keine freien Event-Slots mehr.'));
navigate(ADMIN_BILLING_PATH);
return;
}
navigate(ADMIN_EVENT_CREATE_PATH);
},
disabled: !canCreateEvent,
},
{
key: 'photos',
label: translate('quickActions.moderatePhotos.label'),
description: translate('quickActions.moderatePhotos.description'),
icon: <Camera className="h-5 w-5" />,
onClick: () => navigate(ADMIN_EVENTS_PATH),
disabled: !hasEventContext,
},
{
key: 'tasks',
label: translate('quickActions.organiseTasks.label'),
description: translate('quickActions.organiseTasks.description'),
icon: <ClipboardList className="h-5 w-5" />,
onClick: () => navigate(buildEngagementTabPath('tasks')),
disabled: !hasEventContext,
},
{
key: 'packages',
label: translate('quickActions.managePackages.label'),
description: translate('quickActions.managePackages.description'),
icon: <Sparkles className="h-5 w-5" />,
onClick: () => navigate(ADMIN_BILLING_PATH),
},
],
[translate, navigate, hasEventContext],
);
const adminTitle = singleEventName ?? greetingTitle;
const adminSubtitle = singleEvent
? translate('overview.eventHero.subtitle', {
defaultValue: 'Alle Funktionen konzentrieren sich auf dieses Event.',
date: singleEventDateLabel ?? translate('overview.eventHero.noDate', 'Noch kein Datum festgelegt'),
})
: subtitle;
const focusActions = React.useMemo(
() => ({
createEvent: () => navigate(ADMIN_EVENT_CREATE_PATH),
openEvent: () => {
if (primaryEvent?.slug) {
navigate(ADMIN_EVENT_VIEW_PATH(primaryEvent.slug));
} else {
navigate(ADMIN_EVENTS_PATH);
}
},
openPhotos: () => {
if (primaryEventSlug) {
navigate(ADMIN_EVENT_PHOTOS_PATH(primaryEventSlug));
return;
}
navigate(ADMIN_EVENTS_PATH);
},
openInvites: () => {
if (primaryEventSlug) {
navigate(ADMIN_EVENT_INVITES_PATH(primaryEventSlug));
return;
}
navigate(ADMIN_EVENTS_PATH);
},
openTasks: () => {
if (primaryEventSlug) {
navigate(ADMIN_EVENT_TASKS_PATH(primaryEventSlug));
return;
}
navigate(ADMIN_EVENTS_PATH);
},
openPhotobooth: () => {
if (primaryEventSlug) {
navigate(ADMIN_EVENT_PHOTOBOOTH_PATH(primaryEventSlug));
return;
}
navigate(ADMIN_EVENTS_PATH);
},
}),
[navigate, primaryEvent, primaryEventSlug],
);
return (
<AdminLayout title={adminTitle} subtitle={adminSubtitle}>
{loading ? (
<DashboardSkeleton />
) : (
<>
<div id="overview" className="space-y-6 scroll-mt-32">
{eventOptions.length > 1 ? (
<SectionCard className="space-y-3">
<SectionHeader
eyebrow={translate('overview.eventSwitcherEyebrow', 'Events')}
title={translate('overview.eventSwitcherTitle', 'Event auswählen')}
description={translate('overview.eventSwitcherDescription', 'Wechsle das Event, für das das Dashboard Daten anzeigt.')}
/>
<Select
value={selectedSlug ?? ''}
onValueChange={(value) => {
setSelectedSlug(value);
selectEvent(value);
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder={translate('overview.eventSwitcherPlaceholder', 'Event auswählen')} />
</SelectTrigger>
<SelectContent>
{eventOptions.map((event) => (
<SelectItem key={event.slug} value={event.slug}>
{resolveEventName(event.name, event.slug)}
{event.event_date ? `${formatDate(event.event_date, dateLocale) ?? ''}` : ''}
</SelectItem>
))}
</SelectContent>
</Select>
</SectionCard>
) : null}
<DashboardEventFocusCard
event={primaryEvent}
limitWarnings={limitWarnings}
summary={summary}
dateLocale={dateLocale}
onCreateEvent={focusActions.createEvent}
onOpenEvent={focusActions.openEvent}
onOpenPhotos={focusActions.openPhotos}
onOpenInvites={focusActions.openInvites}
onOpenTasks={focusActions.openTasks}
onOpenPhotobooth={focusActions.openPhotobooth}
/>
</div>
<div id="live" className="space-y-6 scroll-mt-32">
{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}
</div>
<div id="setup" className="space-y-6 scroll-mt-32">
<SectionCard className="space-y-3">
<SectionHeader
eyebrow={translate('quickActions.title')}
title={translate('quickActions.title')}
description={translate('quickActions.description')}
/>
<ActionGrid items={quickActionItems} />
</SectionCard>
<TenantOnboardingChecklistCard
title={onboardingCardTitle}
description={onboardingCardDescription}
steps={onboardingChecklist}
completedLabel={readinessCompleteLabel}
pendingLabel={readinessPendingLabel}
completionPercent={onboardingCompletion}
completedCount={completedOnboardingSteps}
totalCount={onboardingChecklist.length}
emptyCopy={onboardingCompletedCopy}
fallbackActionLabel={onboardingFallbackCta}
/>
</div>
<div id="recap" className="space-y-6 scroll-mt-32">
<section className="space-y-4 rounded-3xl border border-slate-200 bg-white p-5 shadow-sm dark:border-white/10 dark:bg-white/5 dark:shadow-inner">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-xs uppercase tracking-[0.35em] text-rose-500 dark:text-rose-200">
{translate('upcoming.title')}
</p>
<p className="text-sm text-slate-600 dark:text-slate-300">{translate('upcoming.description')}</p>
</div>
<Button variant="outline" size="sm" onClick={() => navigate(ADMIN_SETTINGS_PATH)}>
<Settings className="h-4 w-4" />
{translate('upcoming.settings')}
</Button>
</div>
<div 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'),
}}
/>
))
)}
</div>
</section>
</div>
</>
)}
</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 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-slate-200 bg-white p-4 shadow-sm shadow-white/30 dark:border-white/10 dark:bg-white/5 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="rounded-full 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>
);
}