415 lines
18 KiB
TypeScript
415 lines
18 KiB
TypeScript
import React from 'react';
|
||
import { useNavigate } from 'react-router-dom';
|
||
import { useTranslation } from 'react-i18next';
|
||
import {
|
||
AlertTriangle,
|
||
Camera,
|
||
ClipboardList,
|
||
MessageSquare,
|
||
PlugZap,
|
||
PlusCircle,
|
||
QrCode,
|
||
Sparkles,
|
||
} from 'lucide-react';
|
||
|
||
import { cn } from '@/lib/utils';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Badge } from '@/components/ui/badge';
|
||
import {
|
||
Sheet,
|
||
SheetContent,
|
||
SheetDescription,
|
||
SheetHeader,
|
||
SheetTitle,
|
||
SheetTrigger,
|
||
} from '@/components/ui/sheet';
|
||
import { useEventContext } from '../context/EventContext';
|
||
import { EventSwitcher, EventMenuBar } from './EventNav';
|
||
import {
|
||
ADMIN_EVENT_CREATE_PATH,
|
||
ADMIN_EVENT_INVITES_PATH,
|
||
ADMIN_EVENT_PHOTOS_PATH,
|
||
ADMIN_EVENT_TASKS_PATH,
|
||
ADMIN_EVENT_PHOTOBOOTH_PATH,
|
||
ADMIN_EVENT_TOOLKIT_PATH,
|
||
} from '../constants';
|
||
import { formatEventDate, resolveEngagementMode, resolveEventDisplayName } from '../lib/events';
|
||
|
||
const MOBILE_SHELF_COACHMARK_KEY = 'tenant-admin:command-shelf-mobile-tip';
|
||
|
||
type CommandAction = {
|
||
key: string;
|
||
label: string;
|
||
description: string;
|
||
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
|
||
href: string;
|
||
};
|
||
|
||
function formatNumber(value?: number | null): string {
|
||
if (typeof value !== 'number') {
|
||
return '–';
|
||
}
|
||
if (value > 999) {
|
||
return `${(value / 1000).toFixed(1)}k`;
|
||
}
|
||
return String(value);
|
||
}
|
||
|
||
export function CommandShelf() {
|
||
const { events, activeEvent, isLoading, isError, refetch } = useEventContext();
|
||
const { t, i18n } = useTranslation('common');
|
||
const navigate = useNavigate();
|
||
const [mobileShelfOpen, setMobileShelfOpen] = React.useState(false);
|
||
const [coachmarkDismissed, setCoachmarkDismissed] = React.useState(() => {
|
||
if (typeof window === 'undefined') {
|
||
return false;
|
||
}
|
||
return window.localStorage.getItem(MOBILE_SHELF_COACHMARK_KEY) === '1';
|
||
});
|
||
|
||
if (isLoading) {
|
||
return (
|
||
<section className="border-b border-slate-200/80 bg-white/80 px-4 py-4 backdrop-blur-sm dark:border-white/10 dark:bg-slate-950/70">
|
||
<div className="mx-auto flex w-full max-w-6xl flex-col gap-3">
|
||
<div className="h-6 w-40 animate-pulse rounded-lg bg-slate-200/70 dark:bg-white/10" />
|
||
<div className="grid gap-2 md:grid-cols-2 lg:grid-cols-4">
|
||
{Array.from({ length: 4 }).map((_, index) => (
|
||
<div
|
||
key={`loading-${index.toString()}`}
|
||
className="h-20 animate-pulse rounded-2xl bg-slate-100/80 dark:bg-white/10"
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
if (isError) {
|
||
return (
|
||
<section className="border-b border-slate-200/80 bg-white/80 px-4 py-4 backdrop-blur-sm dark:border-white/10 dark:bg-slate-950/70">
|
||
<div className="mx-auto flex w-full max-w-6xl flex-col gap-3 rounded-3xl border border-amber-200/70 bg-amber-50/70 p-5 dark:border-amber-200/20 dark:bg-amber-500/10">
|
||
<div className="flex items-start gap-3">
|
||
<AlertTriangle className="h-5 w-5 text-amber-500" />
|
||
<div className="space-y-1">
|
||
<p className="text-sm font-semibold text-slate-800 dark:text-white">
|
||
{t('commandShelf.error.title', 'Events konnten nicht geladen werden')}
|
||
</p>
|
||
<p className="text-xs text-slate-600 dark:text-slate-200">
|
||
{t('commandShelf.error.hint', 'Bitte versuche es erneut oder lade die Seite neu.')}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<div className="flex flex-wrap justify-between gap-3">
|
||
<EventSwitcher compact />
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
className="rounded-full"
|
||
onClick={() => refetch()}
|
||
>
|
||
{t('commandShelf.error.retry', 'Erneut laden')}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
if (!events.length) {
|
||
// Hide the empty hero entirely; dashboard content already handles the zero-events case.
|
||
return null;
|
||
}
|
||
|
||
if (!activeEvent) {
|
||
return (
|
||
<section className="border-b border-slate-200/80 bg-white/80 px-4 py-4 backdrop-blur-sm dark:border-white/10 dark:bg-slate-950/70">
|
||
<div className="mx-auto flex w-full max-w-6xl flex-col gap-3 rounded-3xl border border-slate-200 bg-white/80 p-5 dark:border-white/10 dark:bg-white/5">
|
||
<div className="flex items-start gap-3">
|
||
<AlertTriangle className="h-5 w-5 text-amber-500" />
|
||
<div>
|
||
<p className="text-sm font-semibold text-slate-800 dark:text-white">
|
||
{t('commandShelf.selectEvent.title', 'Kein aktives Event ausgewählt')}
|
||
</p>
|
||
<p className="text-xs text-slate-500 dark:text-slate-300">
|
||
{t('commandShelf.selectEvent.hint', 'Wähle unten ein Event aus, um Status und Aktionen zu sehen.')}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<div className="flex justify-end">
|
||
<EventSwitcher compact />
|
||
</div>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
const slug = activeEvent.slug;
|
||
const locale = i18n.language?.startsWith('en') ? 'en-GB' : 'de-DE';
|
||
const formattedDate = formatEventDate(activeEvent.event_date, locale);
|
||
const engagementMode = resolveEngagementMode(activeEvent);
|
||
const handleActionClick = React.useCallback((href: string, closeSheet = false) => {
|
||
if (closeSheet) {
|
||
setMobileShelfOpen(false);
|
||
}
|
||
navigate(href);
|
||
}, [navigate]);
|
||
const handleDismissCoachmark = React.useCallback(() => {
|
||
setCoachmarkDismissed(true);
|
||
if (typeof window !== 'undefined') {
|
||
window.localStorage.setItem(MOBILE_SHELF_COACHMARK_KEY, '1');
|
||
}
|
||
}, []);
|
||
const showCoachmark = !coachmarkDismissed && !mobileShelfOpen;
|
||
|
||
const actionItems: CommandAction[] = [
|
||
{
|
||
key: 'photos',
|
||
label: t('commandShelf.actions.photos.label', 'Fotos moderieren'),
|
||
description: t('commandShelf.actions.photos.desc', 'Prüfe neue Uploads, Highlights & Sperren.'),
|
||
icon: Camera,
|
||
href: ADMIN_EVENT_PHOTOS_PATH(slug),
|
||
},
|
||
{
|
||
key: 'tasks',
|
||
label: t('commandShelf.actions.tasks.label', 'Aufgaben pflegen'),
|
||
description: t('commandShelf.actions.tasks.desc', 'Mission Cards & Moderation im Blick.'),
|
||
icon: ClipboardList,
|
||
href: ADMIN_EVENT_TASKS_PATH(slug),
|
||
},
|
||
{
|
||
key: 'invites',
|
||
label: t('commandShelf.actions.invites.label', 'QR & Einladungen'),
|
||
description: t('commandShelf.actions.invites.desc', 'Layouts exportieren oder Links kopieren.'),
|
||
icon: QrCode,
|
||
href: ADMIN_EVENT_INVITES_PATH(slug),
|
||
},
|
||
{
|
||
key: 'photobooth',
|
||
label: t('commandShelf.actions.photobooth.label', 'Photobooth anbinden'),
|
||
description: t('commandShelf.actions.photobooth.desc', 'FTP-Zugang und Rate-Limits steuern.'),
|
||
icon: PlugZap,
|
||
href: ADMIN_EVENT_PHOTOBOOTH_PATH(slug),
|
||
},
|
||
{
|
||
key: 'toolkit',
|
||
label: t('commandShelf.actions.toolkit.label', 'Event-Day Toolkit'),
|
||
description: t('commandShelf.actions.toolkit.desc', 'Broadcasts, Aufgaben & Quicklinks.'),
|
||
icon: MessageSquare,
|
||
href: ADMIN_EVENT_TOOLKIT_PATH(slug),
|
||
},
|
||
];
|
||
|
||
const metrics = [
|
||
{
|
||
key: 'photos',
|
||
label: t('commandShelf.metrics.photos', 'Uploads'),
|
||
value: activeEvent.photo_count,
|
||
hint: t('commandShelf.metrics.total', 'gesamt'),
|
||
},
|
||
{
|
||
key: 'pending',
|
||
label: t('commandShelf.metrics.pending', 'Moderation'),
|
||
value: activeEvent.pending_photo_count,
|
||
hint: t('commandShelf.metrics.pendingHint', 'offen'),
|
||
},
|
||
{
|
||
key: 'tasks',
|
||
label: t('commandShelf.metrics.tasks', 'Aufgaben'),
|
||
value: activeEvent.tasks_count,
|
||
hint: t('commandShelf.metrics.tasksHint', 'aktiv'),
|
||
},
|
||
{
|
||
key: 'invites',
|
||
label: t('commandShelf.metrics.invites', 'Einladungen'),
|
||
value: activeEvent.active_invites_count ?? activeEvent.total_invites_count,
|
||
hint: t('commandShelf.metrics.invitesHint', 'live'),
|
||
},
|
||
];
|
||
|
||
const statusLabel = activeEvent.status === 'published'
|
||
? t('commandShelf.status.published', 'Veröffentlicht')
|
||
: t('commandShelf.status.draft', 'Entwurf');
|
||
|
||
const liveBadge = activeEvent.is_active
|
||
? t('commandShelf.status.live', 'Live für Gäste')
|
||
: t('commandShelf.status.hidden', 'Versteckt');
|
||
|
||
const engagementLabel = engagementMode === 'photo_only'
|
||
? t('commandShelf.status.photoOnly', 'Nur Foto-Modus')
|
||
: t('commandShelf.status.tasksMode', 'Mission Cards aktiv');
|
||
|
||
return (
|
||
<>
|
||
<section className="hidden border-b border-slate-200/80 bg-white/80 px-4 py-5 backdrop-blur-sm dark:border-white/10 dark:bg-slate-950/70 md:block">
|
||
<div className="mx-auto w-full max-w-6xl space-y-4">
|
||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||
<div className="space-y-2">
|
||
<p className="text-[11px] font-semibold uppercase tracking-[0.4em] text-slate-500 dark:text-slate-300">
|
||
{t('commandShelf.sectionTitle', 'Aktuelles Event')}
|
||
</p>
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<h2 className="text-lg font-semibold text-slate-900 dark:text-white">
|
||
{resolveEventDisplayName(activeEvent)}
|
||
</h2>
|
||
<Badge className={cn(activeEvent.status === 'published' ? 'bg-emerald-500 text-white' : 'bg-amber-500 text-white')}>
|
||
{statusLabel}
|
||
</Badge>
|
||
<Badge variant="outline" className="text-xs uppercase tracking-wide">
|
||
{liveBadge}
|
||
</Badge>
|
||
{engagementMode ? (
|
||
<Badge variant="outline" className="text-xs uppercase tracking-wide">
|
||
{engagementLabel}
|
||
</Badge>
|
||
) : null}
|
||
</div>
|
||
<p className="text-xs text-slate-500 dark:text-slate-300">
|
||
{formattedDate ? `${formattedDate} · ` : ''}
|
||
{activeEvent.package?.name ?? t('commandShelf.packageFallback', 'Standard-Paket')}
|
||
</p>
|
||
</div>
|
||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||
<EventSwitcher compact />
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
className="rounded-full text-rose-600 hover:bg-rose-50 dark:text-rose-200 dark:hover:bg-rose-200/10"
|
||
onClick={() => navigate(ADMIN_EVENT_TOOLKIT_PATH(slug))}
|
||
>
|
||
<Sparkles className="mr-2 h-4 w-4" />
|
||
{t('commandShelf.cta.toolkit', 'Event-Day öffnen')}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex flex-wrap gap-3 text-xs text-slate-500 dark:text-slate-300">
|
||
{metrics.map((metric) => (
|
||
<div key={metric.key} className="rounded-2xl border border-slate-200/80 px-3 py-2 text-center dark:border-white/10">
|
||
<p className="text-sm font-semibold text-slate-900 dark:text-white">{formatNumber(metric.value)}</p>
|
||
<p className="text-[10px] uppercase tracking-[0.3em]">
|
||
{metric.label}
|
||
</p>
|
||
<p className="text-[10px] text-slate-400 dark:text-slate-500">{metric.hint}</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<div className="grid gap-2 md:grid-cols-2 xl:grid-cols-4">
|
||
{actionItems.map((action) => (
|
||
<button
|
||
key={action.key}
|
||
type="button"
|
||
onClick={() => handleActionClick(action.href)}
|
||
className="flex items-start gap-3 rounded-2xl border border-slate-200 bg-white/90 p-3 text-left transition hover:border-rose-200 hover:bg-rose-50 dark:border-white/10 dark:bg-white/5 dark:hover:border-rose-300/40"
|
||
>
|
||
<action.icon className="mt-1 h-5 w-5 text-rose-500 dark:text-rose-200" />
|
||
<div>
|
||
<p className="text-sm font-semibold text-slate-900 dark:text-white">{action.label}</p>
|
||
<p className="text-xs text-slate-500 dark:text-slate-300">{action.description}</p>
|
||
</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
<EventMenuBar />
|
||
</div>
|
||
</section>
|
||
|
||
<section className="border-b border-slate-200/80 bg-white/85 px-4 py-4 backdrop-blur-sm dark:border-white/10 dark:bg-slate-950/80 md:hidden">
|
||
<div className="mx-auto w-full max-w-6xl space-y-4">
|
||
<div className="space-y-2">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<h2 className="text-base font-semibold text-slate-900 dark:text-white">{resolveEventDisplayName(activeEvent)}</h2>
|
||
<Badge className={cn(activeEvent.status === 'published' ? 'bg-emerald-500 text-white' : 'bg-amber-500 text-white')}>
|
||
{statusLabel}
|
||
</Badge>
|
||
<Badge variant="outline" className="text-xs uppercase tracking-wide">
|
||
{liveBadge}
|
||
</Badge>
|
||
</div>
|
||
<p className="text-xs text-slate-500 dark:text-slate-300">
|
||
{formattedDate ? `${formattedDate} · ` : ''}
|
||
{activeEvent.package?.name ?? t('commandShelf.packageFallback', 'Standard-Paket')}
|
||
</p>
|
||
{engagementMode ? (
|
||
<p className="text-[11px] uppercase tracking-[0.3em] text-slate-400 dark:text-slate-500">{engagementLabel}</p>
|
||
) : null}
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-2 text-xs text-slate-500 dark:text-slate-300">
|
||
{metrics.map((metric) => (
|
||
<div key={`mobile-${metric.key}`} className="rounded-2xl border border-slate-200/80 px-3 py-2 text-left dark:border-white/10">
|
||
<p className="text-base font-semibold text-slate-900 dark:text-white">{formatNumber(metric.value)}</p>
|
||
<p className="text-[11px] uppercase tracking-[0.3em]">{metric.label}</p>
|
||
<p className="text-[10px] text-slate-400 dark:text-slate-500">{metric.hint}</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{showCoachmark ? (
|
||
<div className="rounded-2xl border border-rose-200/70 bg-rose-50/80 px-4 py-3 text-sm text-rose-700 dark:border-rose-300/40 dark:bg-rose-300/20 dark:text-rose-100">
|
||
<p>{t('commandShelf.mobile.tip', 'Tipp: Öffne hier deine wichtigsten Aktionen am Eventtag.')}</p>
|
||
<div className="mt-2 flex justify-end">
|
||
<Button size="sm" variant="ghost" onClick={handleDismissCoachmark} className="text-rose-700 hover:bg-rose-100 dark:text-rose-100 dark:hover:bg-rose-300/20">
|
||
{t('commandShelf.mobile.tipCta', 'Verstanden')}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
<Sheet open={mobileShelfOpen} onOpenChange={setMobileShelfOpen}>
|
||
<SheetTrigger asChild>
|
||
<Button className="w-full rounded-2xl bg-rose-600 text-white shadow-lg shadow-rose-400/30">
|
||
{t('commandShelf.mobile.openActions', 'Schnellaktionen öffnen')}
|
||
</Button>
|
||
</SheetTrigger>
|
||
<SheetContent
|
||
side="bottom"
|
||
className="rounded-t-3xl border-t border-slate-200/70 bg-white/95 pb-6 pt-6 dark:border-white/10 dark:bg-slate-950/95"
|
||
>
|
||
<div className="mx-auto flex w-full max-w-xl flex-col gap-4">
|
||
<div className="mx-auto h-1.5 w-12 rounded-full bg-slate-300" aria-hidden />
|
||
<SheetHeader className="px-4 pt-0 text-left">
|
||
<SheetTitle className="text-lg font-semibold text-slate-900 dark:text-white">
|
||
{t('commandShelf.mobile.sheetTitle', 'Schnellaktionen')}
|
||
</SheetTitle>
|
||
<SheetDescription>
|
||
{t('commandShelf.mobile.sheetDescription', 'Moderation, Aufgaben und Einladungen an einem Ort.')}
|
||
</SheetDescription>
|
||
</SheetHeader>
|
||
<div className="flex flex-wrap gap-2 px-4 text-xs text-slate-500 dark:text-slate-300">
|
||
{metrics.map((metric) => (
|
||
<div key={`sheet-${metric.key}`} className="rounded-xl border border-slate-200 px-3 py-1.5 dark:border-white/10">
|
||
<span className="text-sm font-semibold text-slate-900 dark:text-white">{formatNumber(metric.value)}</span>
|
||
<span className="ml-2 uppercase tracking-[0.25em]">{metric.label}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className="grid gap-2 px-4">
|
||
{actionItems.map((action) => (
|
||
<button
|
||
key={`sheet-action-${action.key}`}
|
||
type="button"
|
||
onClick={() => handleActionClick(action.href, true)}
|
||
className="flex items-start gap-3 rounded-2xl border border-slate-200 bg-white/95 p-3 text-left shadow-sm transition hover:border-rose-200 hover:bg-rose-50 dark:border-white/10 dark:bg-white/5"
|
||
>
|
||
<action.icon className="mt-1 h-5 w-5 text-rose-500 dark:text-rose-200" />
|
||
<div>
|
||
<p className="text-sm font-semibold text-slate-900 dark:text-white">{action.label}</p>
|
||
<p className="text-xs text-slate-500 dark:text-slate-300">{action.description}</p>
|
||
</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div className="px-4">
|
||
<EventMenuBar />
|
||
</div>
|
||
</div>
|
||
</SheetContent>
|
||
</Sheet>
|
||
</div>
|
||
</section>
|
||
</>
|
||
);
|
||
}
|