coupon code system eingeführt. coupons werden vom super admin gemanaged. coupons werden mit paddle synchronisiert und dort validiert. plus: einige mobil-optimierungen im tenant admin pwa.

This commit is contained in:
Codex Agent
2025-11-09 20:26:50 +01:00
parent f3c44be76d
commit 082b78cd43
80 changed files with 4855 additions and 435 deletions

View File

@@ -12,7 +12,6 @@ import {
QrCode,
ClipboardList,
Package as PackageIcon,
ArrowUpRight,
} from 'lucide-react';
import toast from 'react-hot-toast';
@@ -26,7 +25,6 @@ import {
TenantOnboardingChecklistCard,
FrostedSurface,
tenantHeroPrimaryButtonClass,
tenantHeroSecondaryButtonClass,
SectionCard,
SectionHeader,
StatCarousel,
@@ -52,6 +50,7 @@ import {
ADMIN_EVENTS_PATH,
ADMIN_EVENT_PHOTOS_PATH,
ADMIN_EVENT_INVITES_PATH,
ADMIN_EVENT_TASKS_PATH,
ADMIN_BILLING_PATH,
ADMIN_SETTINGS_PATH,
ADMIN_WELCOME_BASE_PATH,
@@ -212,7 +211,7 @@ export default function DashboardPage() {
meta: primary ? { event_id: primary.id } : undefined,
});
}
}, [loading, events.length, progress.eventCreated, navigate, location.pathname, markStep]);
}, [loading, events, events.length, progress.eventCreated, navigate, location.pathname, markStep]);
const greetingName = user?.name ?? translate('welcome.fallbackName');
const greetingTitle = translate('welcome.greeting', { name: greetingName });
@@ -224,6 +223,9 @@ export default function DashboardPage() {
const publishedEvents = events.filter((event) => event.status === 'published');
const primaryEvent = events[0] ?? null;
const primaryEventName = primaryEvent ? resolveEventName(primaryEvent.name, primaryEvent.slug) : null;
const singleEvent = events.length === 1 ? events[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(
@@ -271,6 +273,31 @@ export default function DashboardPage() {
}, [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 statItems = React.useMemo(
() => ([
{
@@ -430,46 +457,77 @@ export default function DashboardPage() {
'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 heroBadge = singleEvent
? translate('overview.eventHero.badge', 'Aktives Event')
: translate('overview.title', 'Kurzer Überblick');
const heroDescription = singleEvent
? translate('overview.eventHero.description', 'Alles richtet sich nach {{event}}. Nächster Termin: {{date}}.', {
event: singleEventName ?? '',
date: singleEventDateLabel ?? translate('overview.eventHero.noDate', 'Noch kein Datum festgelegt'),
})
: translate('overview.description', 'Wichtigste Kennzahlen deines Tenants auf einen Blick.');
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={tenantHeroPrimaryButtonClass}
onClick={() => {
if (readiness.hasEvent) {
navigate(ADMIN_EVENTS_PATH);
} else {
navigate(ADMIN_EVENT_CREATE_PATH);
}
}}
>
{heroPrimaryCtaLabel}
</Button>
);
const heroSecondaryAction = (
<Button
size="sm"
className={tenantHeroSecondaryButtonClass}
onClick={() => window.location.assign('/dashboard')}
>
{marketingDashboardLabel}
<ArrowUpRight className="ml-2 h-4 w-4" />
</Button>
);
const heroAside = (
const heroSupporting = singleEvent
? [
translate('overview.eventHero.supporting.status', 'Status: {{status}}', {
status: formatEventStatus(singleEvent.status ?? null, tc),
}),
singleEventDateLabel
? translate('overview.eventHero.supporting.date', 'Eventdatum: {{date}}', { date: singleEventDateLabel })
: translate('overview.eventHero.noDate', 'Noch kein Datum festgelegt.'),
].filter(Boolean)
: [heroSupportingCopy];
const heroPrimaryAction = (() => {
if (onboardingCompletion < 100) {
return (
<Button
size="sm"
className={tenantHeroPrimaryButtonClass}
onClick={() => {
if (readiness.hasEvent) {
navigate(ADMIN_EVENTS_PATH);
} else {
navigate(ADMIN_EVENT_CREATE_PATH);
}
}}
>
{translate('onboarding.hero.cta', 'Setup fortsetzen')}
</Button>
);
}
if (singleEvent?.slug) {
return (
<Button
size="sm"
className={tenantHeroPrimaryButtonClass}
onClick={() => navigate(ADMIN_EVENT_VIEW_PATH(singleEvent.slug))}
>
{translate('actions.openEvent', 'Event öffnen')}
</Button>
);
}
if (readiness.hasEvent) {
return (
<Button size="sm" className={tenantHeroPrimaryButtonClass} onClick={() => navigate(ADMIN_EVENTS_PATH)}>
{translate('quickActions.moderatePhotos.label', 'Fotos moderieren')}
</Button>
);
}
return (
<Button size="sm" className={tenantHeroPrimaryButtonClass} onClick={() => navigate(ADMIN_EVENT_CREATE_PATH)}>
{translate('actions.newEvent')}
</Button>
);
})();
const heroAside = onboardingCompletion < 100 ? (
<FrostedSurface className="w-full rounded-2xl border-slate-200 bg-white p-5 text-slate-900 shadow-lg shadow-rose-300/20 dark:border-white/20 dark:bg-white/10">
<div className="flex items-center justify-between text-sm font-medium text-slate-700">
<span>{onboardingCardTitle}</span>
@@ -480,9 +538,44 @@ export default function DashboardPage() {
<Progress value={onboardingCompletion} className="mt-4 h-2 bg-rose-100" />
<p className="mt-3 text-xs text-slate-600">{onboardingCardDescription}</p>
</FrostedSurface>
);
) : singleEvent ? (
<FrostedSurface className="w-full rounded-2xl border-slate-200 bg-white p-5 text-slate-900 shadow-lg shadow-rose-300/20 dark:border-white/20 dark:bg-white/10">
<div className="space-y-3 text-sm">
<div>
<p className="text-xs uppercase tracking-[0.35em] text-rose-500">
{translate('overview.eventHero.stats.title', 'Momentaufnahme')}
</p>
<p className="text-lg font-semibold text-slate-900 dark:text-white">
{formatEventStatus(singleEvent.status ?? null, tc)}
</p>
</div>
<dl className="space-y-2">
<div className="flex items-center justify-between">
<dt className="text-xs text-slate-500">{translate('overview.eventHero.stats.date', 'Eventdatum')}</dt>
<dd className="text-sm font-semibold text-slate-900 dark:text-white">
{singleEventDateLabel ?? translate('overview.eventHero.noDate', 'Nicht gesetzt')}
</dd>
</div>
<div className="flex items-center justify-between">
<dt className="text-xs text-slate-500">{translate('overview.eventHero.stats.uploads', 'Uploads gesamt')}</dt>
<dd className="text-sm font-semibold text-slate-900 dark:text-white">
{Number(singleEvent.photo_count ?? 0).toLocaleString(i18n.language)}
</dd>
</div>
<div className="flex items-center justify-between">
<dt className="text-xs text-slate-500">{translate('overview.eventHero.stats.tasks', 'Offene Aufgaben')}</dt>
<dd className="text-sm font-semibold text-slate-900 dark:text-white">
{Number(singleEvent.tasks_count ?? 0).toLocaleString(i18n.language)}
</dd>
</div>
</dl>
</div>
</FrostedSurface>
) : null;
const readinessCompleteLabel = translate('readiness.complete', 'Erledigt');
const readinessPendingLabel = translate('readiness.pending', 'Noch offen');
const hasEventContext = readiness.hasEvent;
const quickActionItems = React.useMemo(
() => [
{
@@ -498,6 +591,7 @@ export default function DashboardPage() {
description: translate('quickActions.moderatePhotos.description'),
icon: <Camera className="h-5 w-5" />,
onClick: () => navigate(ADMIN_EVENTS_PATH),
disabled: !hasEventContext,
},
{
key: 'tasks',
@@ -505,6 +599,7 @@ export default function DashboardPage() {
description: translate('quickActions.organiseTasks.description'),
icon: <ClipboardList className="h-5 w-5" />,
onClick: () => navigate(buildEngagementTabPath('tasks')),
disabled: !hasEventContext,
},
{
key: 'packages',
@@ -513,18 +608,24 @@ export default function DashboardPage() {
icon: <Sparkles className="h-5 w-5" />,
onClick: () => navigate(ADMIN_BILLING_PATH),
},
{
key: 'marketing',
label: marketingDashboardLabel,
description: marketingDashboardDescription,
icon: <ArrowUpRight className="h-5 w-5" />,
onClick: () => window.location.assign('/dashboard'),
},
],
[translate, navigate, marketingDashboardLabel, marketingDashboardDescription],
[translate, navigate, hasEventContext],
);
const layoutActions = (
const layoutActions = singleEvent ? (
<Button
className="rounded-full bg-brand-rose px-4 text-white shadow-lg shadow-rose-400/40 hover:bg-[var(--brand-rose-strong)]"
onClick={() => {
if (singleEvent.slug) {
navigate(ADMIN_EVENT_VIEW_PATH(singleEvent.slug));
} else {
navigate(ADMIN_EVENTS_PATH);
}
}}
>
{translate('actions.openEvent', 'Event öffnen')}
</Button>
) : (
<Button
className="rounded-full bg-brand-rose px-4 text-white shadow-lg shadow-rose-400/40 hover:bg-[var(--brand-rose-strong)]"
onClick={() => navigate(ADMIN_EVENT_CREATE_PATH)}
@@ -533,8 +634,29 @@ export default function DashboardPage() {
</Button>
);
const adminTitle = singleEventName ?? greetingTitle;
const adminSubtitle = singleEvent
? translate('overview.eventHero.subtitle', 'Alle Funktionen konzentrieren sich auf dieses Event.', {
date: singleEventDateLabel ?? translate('overview.eventHero.noDate', 'Noch kein Datum festgelegt'),
})
: subtitle;
const heroTitle = adminTitle;
const liveNowTitle = t('liveNow.title', { defaultValue: 'Während des Events' });
const liveNowDescription = t('liveNow.description', {
defaultValue: 'Direkter Zugriff, solange dein Event läuft.',
count: liveEvents.length,
});
const liveActionLabels = React.useMemo(() => ({
photos: t('liveNow.actions.photos', { defaultValue: 'Uploads' }),
invites: t('liveNow.actions.invites', { defaultValue: 'QR & Einladungen' }),
tasks: t('liveNow.actions.tasks', { defaultValue: 'Aufgaben' }),
}), [t]);
const liveStatusLabel = t('liveNow.status', { defaultValue: 'Live' });
const liveNoDate = t('liveNow.noDate', { defaultValue: 'Kein Datum' });
return (
<AdminLayout title={greetingTitle} subtitle={subtitle} actions={layoutActions}>
<AdminLayout title={adminTitle} subtitle={adminSubtitle} actions={layoutActions}>
{errorMessage && (
<Alert variant="destructive">
<AlertTitle>{t('dashboard.alerts.errorTitle')}</AlertTitle>
@@ -548,14 +670,74 @@ export default function DashboardPage() {
<>
<TenantHeroCard
badge={heroBadge}
title={greetingTitle}
title={heroTitle}
description={heroDescription}
supporting={[heroSupportingCopy]}
supporting={heroSupporting}
primaryAction={heroPrimaryAction}
secondaryAction={heroSecondaryAction}
aside={heroAside}
/>
{liveEvents.length > 0 && (
<Card className="border border-rose-200 bg-rose-50/80 shadow-lg shadow-rose-200/40">
<CardHeader className="space-y-1">
<CardTitle className="text-base font-semibold text-rose-900">{liveNowTitle}</CardTitle>
<CardDescription className="text-sm text-rose-700">{liveNowDescription}</CardDescription>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
{liveEvents.map((event) => {
const name = resolveEventName(event.name, event.slug);
const dateLabel = event.event_date ? formatDate(event.event_date, dateLocale) : liveNoDate;
return (
<div
key={event.id}
className="rounded-2xl border border-white/70 bg-white/80 p-4 shadow-sm shadow-rose-100/50"
>
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-semibold text-slate-900">{name}</p>
<p className="text-xs text-slate-500">{dateLabel}</p>
</div>
<Badge className="bg-rose-600/90 text-white">{liveStatusLabel}</Badge>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<Button
type="button"
size="sm"
variant="outline"
className="flex flex-1 items-center gap-2 border-rose-200 text-rose-700 hover:border-rose-400 hover:text-rose-800"
onClick={() => navigate(ADMIN_EVENT_PHOTOS_PATH(event.slug))}
>
<Camera className="h-4 w-4" />
{liveActionLabels.photos}
</Button>
<Button
type="button"
size="sm"
variant="outline"
className="flex flex-1 items-center gap-2 border-rose-200 text-rose-700 hover:border-rose-400 hover:text-rose-800"
onClick={() => navigate(ADMIN_EVENT_INVITES_PATH(event.slug))}
>
<QrCode className="h-4 w-4" />
{liveActionLabels.invites}
</Button>
<Button
type="button"
size="sm"
variant="outline"
className="flex flex-1 items-center gap-2 border-rose-200 text-rose-700 hover:border-rose-400 hover:text-rose-800"
onClick={() => navigate(ADMIN_EVENT_TASKS_PATH(event.slug))}
>
<ClipboardList className="h-4 w-4" />
{liveActionLabels.tasks}
</Button>
</div>
</div>
);
})}
</CardContent>
</Card>
)}
{events.length === 0 && (
<Card className="border-none bg-white/90 shadow-lg shadow-rose-100/50">
<CardHeader className="space-y-2">
@@ -752,6 +934,17 @@ function formatDate(value: string | null, locale: string): string | null {
}
}
function formatEventStatus(status: TenantEvent['status'] | null, translateFn: (key: string, options?: Record<string, unknown>) => string): string {
const map: Record<string, { key: string; fallback: string }> = {
published: { key: 'events.status.published', fallback: 'Veröffentlicht' },
draft: { key: 'events.status.draft', fallback: 'Entwurf' },
archived: { key: 'events.status.archived', fallback: 'Archiviert' },
};
const target = map[status ?? 'draft'] ?? map.draft;
return translateFn(target.key, { defaultValue: target.fallback });
}
function resolveEventName(name: TenantEvent['name'], fallbackSlug: string): string {
if (typeof name === 'string' && name.trim().length > 0) {
return name;
@@ -914,29 +1107,6 @@ function GalleryStatusRow({
);
}
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 UpcomingEventRow({
event,
onView,