feat: extend event toolkit and polish guest pwa
This commit is contained in:
@@ -1,7 +1,20 @@
|
||||
import React from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CalendarDays, Camera, Sparkles, Users, Plus, Settings } from 'lucide-react';
|
||||
import {
|
||||
CalendarDays,
|
||||
Camera,
|
||||
Sparkles,
|
||||
Users,
|
||||
Plus,
|
||||
Settings,
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
QrCode,
|
||||
ClipboardList,
|
||||
Package as PackageIcon,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -14,6 +27,8 @@ import {
|
||||
getDashboardSummary,
|
||||
getEvents,
|
||||
getTenantPackagesOverview,
|
||||
getEventTasks,
|
||||
getEventQrInvites,
|
||||
TenantEvent,
|
||||
TenantPackageSummary,
|
||||
} from '../api';
|
||||
@@ -23,6 +38,7 @@ import {
|
||||
adminPath,
|
||||
ADMIN_EVENT_VIEW_PATH,
|
||||
ADMIN_EVENTS_PATH,
|
||||
ADMIN_EVENT_TASKS_PATH,
|
||||
ADMIN_TASKS_PATH,
|
||||
ADMIN_BILLING_PATH,
|
||||
ADMIN_SETTINGS_PATH,
|
||||
@@ -39,6 +55,16 @@ interface DashboardState {
|
||||
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();
|
||||
@@ -66,6 +92,16 @@ export default function DashboardPage() {
|
||||
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 () => {
|
||||
@@ -81,14 +117,56 @@ export default function DashboardPage() {
|
||||
}
|
||||
|
||||
const fallbackSummary = buildSummaryFallback(events, packages.activePackage);
|
||||
const primaryEvent = events[0] ?? null;
|
||||
const primaryEventName = primaryEvent ? resolveEventName(primaryEvent.name, primaryEvent.slug) : null;
|
||||
|
||||
setState({
|
||||
summary: summary ?? fallbackSummary,
|
||||
events,
|
||||
activePackage: packages.activePackage,
|
||||
setReadiness({
|
||||
hasEvent: events.length > 0,
|
||||
hasTasks: false,
|
||||
hasQrInvites: false,
|
||||
hasPackage: Boolean(packages.activePackage),
|
||||
primaryEventSlug: primaryEvent?.slug ?? null,
|
||||
primaryEventName,
|
||||
loading: Boolean(primaryEvent),
|
||||
});
|
||||
|
||||
setState({
|
||||
summary: summary ?? fallbackSummary,
|
||||
events,
|
||||
activePackage: packages.activePackage,
|
||||
loading: false,
|
||||
errorKey: null,
|
||||
});
|
||||
|
||||
if (primaryEvent) {
|
||||
try {
|
||||
const [eventTasks, qrInvites] = await Promise.all([
|
||||
getEventTasks(primaryEvent.id, 1),
|
||||
getEventQrInvites(primaryEvent.slug),
|
||||
]);
|
||||
|
||||
if (!cancelled) {
|
||||
setReadiness((prev) => ({
|
||||
...prev,
|
||||
hasTasks: (eventTasks.data ?? []).length > 0,
|
||||
hasQrInvites: qrInvites.length > 0,
|
||||
loading: false,
|
||||
}));
|
||||
}
|
||||
} catch (readinessError) {
|
||||
if (!cancelled) {
|
||||
console.warn('Failed to load readiness checklist', readinessError);
|
||||
setReadiness((prev) => ({ ...prev, loading: false }));
|
||||
}
|
||||
}
|
||||
} else if (!cancelled) {
|
||||
setReadiness((prev) => ({
|
||||
...prev,
|
||||
hasTasks: false,
|
||||
hasQrInvites: false,
|
||||
loading: false,
|
||||
errorKey: null,
|
||||
});
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isAuthError(error)) {
|
||||
setState((prev) => ({
|
||||
@@ -271,6 +349,52 @@ export default function DashboardPage() {
|
||||
</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(ADMIN_TASKS_PATH)
|
||||
}
|
||||
onOpenQr={() =>
|
||||
readiness.primaryEventSlug
|
||||
? navigate(`${ADMIN_EVENT_VIEW_PATH(readiness.primaryEventSlug)}#qr-invites`)
|
||||
: navigate(ADMIN_EVENTS_PATH)
|
||||
}
|
||||
onOpenPackages={() => navigate(ADMIN_BILLING_PATH)}
|
||||
/>
|
||||
|
||||
<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>
|
||||
@@ -315,6 +439,27 @@ export default function DashboardPage() {
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
@@ -353,6 +498,170 @@ 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 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}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
label,
|
||||
value,
|
||||
|
||||
Reference in New Issue
Block a user