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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user