der tenant admin hat eine neue, mobil unterstützende UI, login redirect funktioniert, typescript fehler wurden bereinigt. Neue Blog Posts von ChatGPT eingebaut, übersetzt von Gemini 2.5
This commit is contained in:
@@ -9,12 +9,10 @@ import {
|
||||
Users,
|
||||
Plus,
|
||||
Settings,
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
QrCode,
|
||||
ClipboardList,
|
||||
Package as PackageIcon,
|
||||
Loader2,
|
||||
ArrowUpRight,
|
||||
} from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
@@ -22,6 +20,9 @@ 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 {
|
||||
@@ -36,9 +37,11 @@ import { isAuthError } from '../auth/tokens';
|
||||
import { useAuth } from '../auth/context';
|
||||
import {
|
||||
adminPath,
|
||||
ADMIN_HOME_PATH,
|
||||
ADMIN_EVENT_VIEW_PATH,
|
||||
ADMIN_EVENTS_PATH,
|
||||
ADMIN_EVENT_TASKS_PATH,
|
||||
ADMIN_EVENT_PHOTOS_PATH,
|
||||
ADMIN_EVENT_INVITES_PATH,
|
||||
ADMIN_BILLING_PATH,
|
||||
ADMIN_SETTINGS_PATH,
|
||||
ADMIN_WELCOME_BASE_PATH,
|
||||
@@ -47,6 +50,7 @@ import {
|
||||
} from '../constants';
|
||||
import { useOnboardingProgress } from '../onboarding';
|
||||
import { buildLimitWarnings } from '../lib/limitWarnings';
|
||||
import type { LimitUsageSummary, GallerySummary } from '../lib/limitWarnings';
|
||||
|
||||
interface DashboardState {
|
||||
summary: DashboardSummary | null;
|
||||
@@ -75,12 +79,23 @@ export default function DashboardPage() {
|
||||
const { t: tc } = useTranslation('common');
|
||||
|
||||
const translate = React.useCallback(
|
||||
(key: string, options?: Record<string, unknown>) => {
|
||||
const value = t(key, options);
|
||||
(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 fallback = i18n.t(`dashboard:${key}`, options);
|
||||
return fallback === `dashboard:${key}` ? value : fallback;
|
||||
const fallbackValue = i18n.t(`dashboard:${key}`, { defaultValue: fallback, ...(options ?? {}) });
|
||||
if (fallbackValue !== `dashboard:${key}`) {
|
||||
return fallbackValue;
|
||||
}
|
||||
|
||||
if (fallback !== undefined) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
},
|
||||
[t, i18n],
|
||||
@@ -237,6 +252,191 @@ export default function DashboardPage() {
|
||||
[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
|
||||
@@ -273,6 +473,16 @@ export default function DashboardPage() {
|
||||
<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">
|
||||
@@ -446,53 +656,26 @@ export default function DashboardPage() {
|
||||
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>
|
||||
|
||||
<ReadinessCard
|
||||
readiness={readiness}
|
||||
labels={{
|
||||
title: translate('readiness.title'),
|
||||
description: translate('readiness.description'),
|
||||
pending: translate('readiness.pending'),
|
||||
complete: translate('readiness.complete'),
|
||||
items: {
|
||||
event: {
|
||||
title: translate('readiness.items.event.title'),
|
||||
hint: translate('readiness.items.event.hint'),
|
||||
},
|
||||
tasks: {
|
||||
title: translate('readiness.items.tasks.title'),
|
||||
hint: translate('readiness.items.tasks.hint'),
|
||||
},
|
||||
qr: {
|
||||
title: translate('readiness.items.qr.title'),
|
||||
hint: translate('readiness.items.qr.hint'),
|
||||
},
|
||||
package: {
|
||||
title: translate('readiness.items.package.title'),
|
||||
hint: translate('readiness.items.package.hint'),
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
createEvent: translate('readiness.actions.createEvent'),
|
||||
openTasks: translate('readiness.actions.openTasks'),
|
||||
openQr: translate('readiness.actions.openQr'),
|
||||
openPackages: translate('readiness.actions.openPackages'),
|
||||
},
|
||||
}}
|
||||
onCreateEvent={() => navigate(ADMIN_EVENT_CREATE_PATH)}
|
||||
onOpenTasks={() =>
|
||||
readiness.primaryEventSlug
|
||||
? navigate(ADMIN_EVENT_TASKS_PATH(readiness.primaryEventSlug))
|
||||
: navigate(buildEngagementTabPath('tasks'))
|
||||
}
|
||||
onOpenQr={() =>
|
||||
readiness.primaryEventSlug
|
||||
? navigate(`${ADMIN_EVENT_VIEW_PATH(readiness.primaryEventSlug)}#qr-invites`)
|
||||
: navigate(ADMIN_EVENTS_PATH)
|
||||
}
|
||||
onOpenPackages={() => navigate(ADMIN_BILLING_PATH)}
|
||||
<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">
|
||||
@@ -619,25 +802,6 @@ function getUpcomingEvents(events: TenantEvent[]): TenantEvent[] {
|
||||
.slice(0, 4);
|
||||
}
|
||||
|
||||
type ReadinessLabels = {
|
||||
title: string;
|
||||
description: string;
|
||||
pending: string;
|
||||
complete: string;
|
||||
items: {
|
||||
event: { title: string; hint: string };
|
||||
tasks: { title: string; hint: string };
|
||||
qr: { title: string; hint: string };
|
||||
package: { title: string; hint: string };
|
||||
};
|
||||
actions: {
|
||||
createEvent: string;
|
||||
openTasks: string;
|
||||
openQr: string;
|
||||
openPackages: string;
|
||||
};
|
||||
};
|
||||
|
||||
function LimitUsageRow({
|
||||
label,
|
||||
summary,
|
||||
@@ -656,9 +820,9 @@ function LimitUsageRow({
|
||||
<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">{unlimitedLabel}</span>
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400">{unlimitedLabel}</span>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-slate-500">{unlimitedLabel}</p>
|
||||
<p className="mt-2 text-xs text-slate-500 dark:text-slate-400">{unlimitedLabel}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -674,23 +838,23 @@ function LimitUsageRow({
|
||||
: 'bg-emerald-500';
|
||||
|
||||
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">
|
||||
<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">
|
||||
<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">
|
||||
<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">
|
||||
<p className="mt-2 text-xs text-slate-500 dark:text-slate-400">
|
||||
{remainingLabel
|
||||
.replace('{{remaining}}', `${Math.max(0, remaining)}`)
|
||||
.replace('{{limit}}', `${limit}`)}
|
||||
@@ -698,7 +862,7 @@ function LimitUsageRow({
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<p className="mt-2 text-xs text-slate-500">{unlimitedLabel}</p>
|
||||
<p className="mt-2 text-xs text-slate-500 dark:text-slate-400">{unlimitedLabel}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -732,155 +896,10 @@ function GalleryStatusRow({
|
||||
}
|
||||
|
||||
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">
|
||||
<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}`}>{statusLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReadinessCard({
|
||||
readiness,
|
||||
labels,
|
||||
onCreateEvent,
|
||||
onOpenTasks,
|
||||
onOpenQr,
|
||||
onOpenPackages,
|
||||
}: {
|
||||
readiness: ReadinessState;
|
||||
labels: ReadinessLabels;
|
||||
onCreateEvent: () => void;
|
||||
onOpenTasks: () => void;
|
||||
onOpenQr: () => void;
|
||||
onOpenPackages: () => void;
|
||||
}) {
|
||||
const checklistItems = [
|
||||
{
|
||||
key: 'event',
|
||||
icon: <CalendarDays className="h-5 w-5" />,
|
||||
completed: readiness.hasEvent,
|
||||
label: labels.items.event.title,
|
||||
hint: labels.items.event.hint,
|
||||
actionLabel: labels.actions.createEvent,
|
||||
onAction: onCreateEvent,
|
||||
showAction: !readiness.hasEvent,
|
||||
},
|
||||
{
|
||||
key: 'tasks',
|
||||
icon: <ClipboardList className="h-5 w-5" />,
|
||||
completed: readiness.hasTasks,
|
||||
label: labels.items.tasks.title,
|
||||
hint: labels.items.tasks.hint,
|
||||
actionLabel: labels.actions.openTasks,
|
||||
onAction: onOpenTasks,
|
||||
showAction: readiness.hasEvent && !readiness.hasTasks,
|
||||
},
|
||||
{
|
||||
key: 'qr',
|
||||
icon: <QrCode className="h-5 w-5" />,
|
||||
completed: readiness.hasQrInvites,
|
||||
label: labels.items.qr.title,
|
||||
hint: labels.items.qr.hint,
|
||||
actionLabel: labels.actions.openQr,
|
||||
onAction: onOpenQr,
|
||||
showAction: readiness.hasEvent && !readiness.hasQrInvites,
|
||||
},
|
||||
{
|
||||
key: 'package',
|
||||
icon: <PackageIcon className="h-5 w-5" />,
|
||||
completed: readiness.hasPackage,
|
||||
label: labels.items.package.title,
|
||||
hint: labels.items.package.hint,
|
||||
actionLabel: labels.actions.openPackages,
|
||||
onAction: onOpenPackages,
|
||||
showAction: !readiness.hasPackage,
|
||||
},
|
||||
] as const;
|
||||
|
||||
const activeEventName = readiness.primaryEventName;
|
||||
|
||||
return (
|
||||
<Card className="border-0 bg-brand-card shadow-brand-primary">
|
||||
<CardHeader className="space-y-2">
|
||||
<CardTitle className="text-xl text-slate-900">{labels.title}</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">{labels.description}</CardDescription>
|
||||
{activeEventName ? (
|
||||
<p className="text-xs uppercase tracking-wide text-brand-rose-soft">
|
||||
{activeEventName}
|
||||
</p>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{readiness.loading ? (
|
||||
<div className="flex items-center gap-2 rounded-2xl border border-slate-200 bg-white/70 px-4 py-3 text-xs text-slate-500">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{labels.pending}
|
||||
</div>
|
||||
) : (
|
||||
checklistItems.map((item) => (
|
||||
<ChecklistRow
|
||||
key={item.key}
|
||||
icon={item.icon}
|
||||
label={item.label}
|
||||
hint={item.hint}
|
||||
completed={item.completed}
|
||||
status={{ complete: labels.complete, pending: labels.pending }}
|
||||
action={
|
||||
item.showAction
|
||||
? {
|
||||
label: item.actionLabel,
|
||||
onClick: item.onAction,
|
||||
disabled:
|
||||
(item.key === 'tasks' || item.key === 'qr') && !readiness.primaryEventSlug,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ChecklistRow({
|
||||
icon,
|
||||
label,
|
||||
hint,
|
||||
completed,
|
||||
status,
|
||||
action,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
hint: string;
|
||||
completed: boolean;
|
||||
status: { complete: string; pending: string };
|
||||
action?: { label: string; onClick: () => void; disabled?: boolean };
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 rounded-2xl border border-brand-rose-soft/40 bg-white/85 p-4 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`flex h-10 w-10 items-center justify-center rounded-full ${completed ? 'bg-emerald-100 text-emerald-600' : 'bg-slate-100 text-slate-500'}`}>
|
||||
{icon}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-semibold text-slate-900">{label}</p>
|
||||
<p className="text-xs text-slate-600">{hint}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className={`flex items-center gap-1 text-xs font-medium ${completed ? 'text-emerald-600' : 'text-slate-500'}`}>
|
||||
{completed ? <CheckCircle2 className="h-4 w-4" /> : <Circle className="h-4 w-4" />}
|
||||
{completed ? status.complete : status.pending}
|
||||
</span>
|
||||
{action ? (
|
||||
<Button size="sm" variant="outline" onClick={action.onClick} disabled={action.disabled}>
|
||||
{action.label}
|
||||
</Button>
|
||||
) : null}
|
||||
<span className={`rounded-full px-3 py-1 text-xs font-semibold ${badgeClass} dark:text-slate-100`}>{statusLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -898,14 +917,14 @@ function StatCard({
|
||||
icon: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-brand-rose-soft bg-brand-card p-5 shadow-md shadow-pink-100/40 transition-transform hover:-translate-y-0.5 hover:shadow-lg">
|
||||
<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">{label}</span>
|
||||
<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">{value}</div>
|
||||
{hint && <p className="mt-2 text-xs text-slate-500">{hint}</p>}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -924,11 +943,11 @@ function QuickAction({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="flex flex-col items-start gap-2 rounded-2xl border border-slate-100 bg-white/80 p-4 text-left shadow-sm transition hover:-translate-y-0.5 hover:border-brand-rose-soft hover:shadow-md focus:outline-none focus:ring-2 focus:ring-brand-rose/40"
|
||||
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">{icon}</span>
|
||||
<span className="text-sm font-semibold text-slate-900">{label}</span>
|
||||
<span className="text-xs text-slate-600">{description}</span>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -1009,4 +1028,3 @@ function DashboardSkeleton() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user