rework of the event admin UI

This commit is contained in:
Codex Agent
2025-11-24 17:17:39 +01:00
parent 4667ec8073
commit 8947a37261
37 changed files with 4381 additions and 874 deletions

View File

@@ -4,7 +4,7 @@ import type { EventAddonSummary } from '../../api';
type Props = {
addons: EventAddonSummary[];
t: (key: string, fallback: string) => string;
t: (key: string, fallback: string, options?: Record<string, unknown>) => string;
};
export function AddonSummaryList({ addons, t }: Props) {

View File

@@ -1,9 +1,18 @@
import React from 'react';
import { NavLink } from 'react-router-dom';
import { Link, NavLink, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { LayoutDashboard, CalendarDays, Camera, Settings } from 'lucide-react';
import toast from 'react-hot-toast';
import { cn } from '@/lib/utils';
import { Badge } from '@/components/ui/badge';
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@/components/ui/sheet';
import {
ADMIN_HOME_PATH,
ADMIN_EVENTS_PATH,
@@ -19,6 +28,7 @@ import { UserMenu } from './UserMenu';
import { useEventContext } from '../context/EventContext';
import { EventSwitcher, EventMenuBar } from './EventNav';
import { useAuth } from '../auth/context';
import { CommandShelf } from './CommandShelf';
type NavItem = {
key: string;
@@ -30,14 +40,24 @@ type NavItem = {
prefetchKey?: string;
};
type PageTab = {
key: string;
label: string;
href: string;
badge?: React.ReactNode;
};
interface AdminLayoutProps {
title: string;
subtitle?: string;
actions?: React.ReactNode;
children: React.ReactNode;
disableCommandShelf?: boolean;
tabs?: PageTab[];
currentTabKey?: string;
}
export function AdminLayout({ title, subtitle, actions, children }: AdminLayoutProps) {
export function AdminLayout({ title, subtitle, actions, children, disableCommandShelf, tabs, currentTabKey }: AdminLayoutProps) {
const { t } = useTranslation('common');
const prefetchedPathsRef = React.useRef<Set<string>>(new Set());
const { events } = useEventContext();
@@ -167,7 +187,7 @@ export function AdminLayout({ title, subtitle, actions, children }: AdminLayoutP
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<EventSwitcher />
{disableCommandShelf ? <EventSwitcher compact /> : null}
{actions}
<NotificationCenter />
<UserMenu />
@@ -203,7 +223,8 @@ export function AdminLayout({ title, subtitle, actions, children }: AdminLayoutP
))}
</div>
</nav>
<EventMenuBar />
{disableCommandShelf ? <EventMenuBar /> : <CommandShelf />}
{tabs && tabs.length ? <PageTabsNav tabs={tabs} currentKey={currentTabKey} /> : null}
</header>
<main className="relative z-10 mx-auto w-full max-w-5xl flex-1 px-4 pb-[calc(env(safe-area-inset-bottom,0)+5.5rem)] pt-5 sm:px-6 md:pb-16">
@@ -216,6 +237,116 @@ export function AdminLayout({ title, subtitle, actions, children }: AdminLayoutP
);
}
function PageTabsNav({ tabs, currentKey }: { tabs: PageTab[]; currentKey?: string }) {
const location = useLocation();
const { t } = useTranslation('common');
const [mobileOpen, setMobileOpen] = React.useState(false);
const isActive = (tab: PageTab): boolean => {
if (currentKey) {
return tab.key === currentKey;
}
return location.pathname === tab.href || location.pathname.startsWith(tab.href);
};
const activeTab = React.useMemo(() => tabs.find((tab) => isActive(tab)), [tabs, location.pathname, currentKey]);
return (
<div className="border-t border-slate-200/70 bg-white/80 backdrop-blur dark:border-white/10 dark:bg-slate-950/70">
<div className="mx-auto flex w-full max-w-6xl flex-col gap-2 px-4 py-2 sm:px-6">
<div className="hidden gap-2 md:flex">
{tabs.map((tab) => {
const active = isActive(tab);
return (
<Link
key={tab.key}
to={tab.href}
className={cn(
'flex items-center gap-2 rounded-2xl px-4 py-2 text-sm font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/60',
active
? 'bg-rose-600 text-white shadow shadow-rose-300/40'
: 'bg-white text-slate-600 hover:text-slate-900 dark:bg-white/5 dark:text-slate-300 dark:hover:text-white'
)}
>
<span>{tab.label}</span>
{tab.badge !== undefined ? (
<Badge
variant={active ? 'secondary' : 'outline'}
className={cn(
active ? 'bg-white/20 text-white' : 'text-slate-600 dark:text-slate-300',
'rounded-full text-[11px]'
)}
>
{tab.badge}
</Badge>
) : null}
</Link>
);
})}
</div>
<div className="md:hidden">
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
<SheetTrigger asChild>
<button
type="button"
className="flex w-full items-center justify-between rounded-2xl border border-slate-200/70 bg-white px-3 py-2 text-left text-sm font-semibold text-slate-700 shadow-sm dark:border-white/10 dark:bg-white/10 dark:text-slate-200"
>
<span>
{activeTab?.label ?? t('navigation.tabs.active', { defaultValue: 'Bereich wählen' })}
</span>
<span className="text-xs uppercase tracking-[0.3em] text-rose-500">
{t('navigation.tabs.open', { defaultValue: 'Tabs' })}
</span>
</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"
>
<SheetHeader className="px-4 pt-0 text-left">
<SheetTitle className="text-lg font-semibold text-slate-900 dark:text-white">
{t('navigation.tabs.title', { defaultValue: 'Bereich auswählen' })}
</SheetTitle>
<SheetDescription>
{t('navigation.tabs.subtitle', { defaultValue: 'Wechsle schnell zwischen Event-Bereichen.' })}
</SheetDescription>
</SheetHeader>
<div className="mt-4 grid gap-2 px-4">
{tabs.map((tab) => {
const active = isActive(tab);
return (
<Link
key={`sheet-${tab.key}`}
to={tab.href}
onClick={() => setMobileOpen(false)}
className={cn(
'flex items-center justify-between rounded-2xl border px-4 py-3 text-sm font-medium shadow-sm transition',
active
? 'border-rose-200 bg-rose-50 text-rose-700'
: 'border-slate-200 bg-white text-slate-700 dark:border-white/10 dark:bg-white/5 dark:text-slate-200'
)}
>
<span>{tab.label}</span>
{tab.badge !== undefined ? (
<Badge
variant={active ? 'secondary' : 'outline'}
className={cn(active ? 'bg-white/30 text-rose-700' : 'text-slate-600 dark:text-slate-200', 'rounded-full text-[11px]')}
>
{tab.badge}
</Badge>
) : null}
</Link>
);
})}
</div>
</SheetContent>
</Sheet>
</div>
</div>
</div>
);
}
function TenantMobileNav({
items,
onPrefetch,

View File

@@ -0,0 +1,404 @@
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 } = 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 (!events.length) {
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-dashed border-rose-200/60 bg-white/70 p-5 text-center dark:border-white/10 dark:bg-white/5">
<Sparkles className="mx-auto h-6 w-6 text-rose-500 dark:text-rose-200" />
<p className="text-sm font-semibold text-slate-800 dark:text-white">
{t('commandShelf.empty.title', 'Starte mit deinem ersten Event')}
</p>
<p className="text-xs text-slate-500 dark:text-slate-300">
{t('commandShelf.empty.hint', 'Erstelle ein Event, dann bündeln wir hier deine wichtigsten Tools.')}
</p>
<div className="flex justify-center">
<Button
size="sm"
className="rounded-full"
onClick={() => navigate(ADMIN_EVENT_CREATE_PATH)}
>
<PlusCircle className="mr-2 h-4 w-4" />
{t('commandShelf.empty.cta', 'Event anlegen')}
</Button>
</div>
</div>
</section>
);
}
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>
</>
);
}

View File

@@ -5,6 +5,7 @@ import { CalendarDays, ChevronDown, PlusCircle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { type TenantEvent } from '../api';
import {
Sheet,
SheetContent,
@@ -25,39 +26,8 @@ import {
ADMIN_EVENT_VIEW_PATH,
ADMIN_EVENT_PHOTOBOOTH_PATH,
} from '../constants';
import type { TenantEvent } from '../api';
import { cn } from '@/lib/utils';
function resolveEventName(event: TenantEvent): string {
const name = event.name;
if (typeof name === 'string' && name.trim().length > 0) {
return name;
}
if (name && typeof name === 'object') {
const first = Object.values(name).find((value) => typeof value === 'string' && value.trim().length > 0);
if (first) {
return first;
}
}
return event.slug ?? 'Event';
}
function formatEventDate(value?: string | null, locale = 'de-DE'): string | null {
if (!value) {
return null;
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return null;
}
try {
return new Intl.DateTimeFormat(locale, { day: '2-digit', month: 'short', year: 'numeric' }).format(date);
} catch {
return date.toISOString().slice(0, 10);
}
}
import { resolveEventDisplayName, formatEventDate } from '../lib/events';
function buildEventLinks(slug: string, t: ReturnType<typeof useTranslation>['t']) {
return [
@@ -71,14 +41,19 @@ function buildEventLinks(slug: string, t: ReturnType<typeof useTranslation>['t']
];
}
export function EventSwitcher() {
type EventSwitcherProps = {
buttonClassName?: string;
compact?: boolean;
};
export function EventSwitcher({ buttonClassName, compact = false }: EventSwitcherProps = {}) {
const { events, activeEvent, selectEvent } = useEventContext();
const { t, i18n } = useTranslation('common');
const navigate = useNavigate();
const [open, setOpen] = React.useState(false);
const locale = i18n.language?.startsWith('en') ? 'en-GB' : 'de-DE';
const buttonLabel = activeEvent ? resolveEventName(activeEvent) : t('eventSwitcher.placeholder', 'Event auswählen');
const buttonLabel = activeEvent ? resolveEventDisplayName(activeEvent) : t('eventSwitcher.placeholder', 'Event auswählen');
const buttonHint = activeEvent?.event_date
? formatEventDate(activeEvent.event_date, locale)
: events.length > 1
@@ -93,13 +68,24 @@ export function EventSwitcher() {
}
};
const buttonClasses = cn(
'rounded-full border-rose-100 bg-white/80 px-4 text-sm font-semibold text-slate-700 shadow-sm hover:bg-rose-50 dark:border-white/20 dark:bg-white/10 dark:text-white',
compact && 'px-3 text-xs sm:text-sm',
buttonClassName,
);
const buttonLabelClasses = compact ? 'text-sm' : 'hidden sm:inline';
const hintClasses = compact
? 'text-xs text-slate-500 dark:text-slate-300'
: 'text-xs text-slate-500 dark:text-slate-300 sm:ml-2';
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<Button variant="outline" size="sm" className="rounded-full border-rose-100 bg-white/80 px-4 text-sm font-semibold text-slate-700 shadow-sm hover:bg-rose-50 dark:border-white/20 dark:bg-white/10 dark:text-white">
<Button variant="outline" size="sm" className={buttonClasses}>
<CalendarDays className="mr-2 h-4 w-4" />
<span className="hidden sm:inline">{buttonLabel}</span>
<span className="text-xs text-slate-500 dark:text-slate-300 sm:ml-2">
<span className={buttonLabelClasses}>{buttonLabel}</span>
<span className={hintClasses}>
{buttonHint}
</span>
<ChevronDown className="ml-2 h-4 w-4" />
@@ -138,7 +124,7 @@ export function EventSwitcher() {
>
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-semibold">{resolveEventName(event)}</p>
<p className="text-sm font-semibold">{resolveEventDisplayName(event)}</p>
<p className="text-xs text-slate-500 dark:text-slate-300">{date ?? t('eventSwitcher.noDate', 'Kein Datum')}</p>
</div>
{isActive ? (

View File

@@ -0,0 +1,254 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import {
AlertTriangle,
Camera,
ClipboardList,
PlugZap,
QrCode,
Sparkles,
CalendarDays,
} from 'lucide-react';
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import type { TenantEvent, DashboardSummary } from '../../api';
import type { LimitWarning } from '../../lib/limitWarnings';
import { resolveEventDisplayName, formatEventDate, formatEventStatusLabel, resolveEngagementMode } from '../../lib/events';
type DashboardEventFocusCardProps = {
event: TenantEvent | null;
limitWarnings: LimitWarning[];
summary: DashboardSummary | null;
dateLocale: string;
onCreateEvent: () => void;
onOpenEvent: () => void;
onOpenPhotos: () => void;
onOpenInvites: () => void;
onOpenTasks: () => void;
onOpenPhotobooth: () => void;
};
export function DashboardEventFocusCard({
event,
limitWarnings,
summary,
dateLocale,
onCreateEvent,
onOpenEvent,
onOpenPhotos,
onOpenInvites,
onOpenTasks,
onOpenPhotobooth,
}: DashboardEventFocusCardProps) {
const { t } = useTranslation('dashboard', { keyPrefix: 'dashboard.eventFocus' });
const { t: tc } = useTranslation('common');
if (!event) {
return (
<Card className="border border-dashed border-rose-200/80 bg-white/80 shadow-sm shadow-rose-100/40 dark:border-white/20 dark:bg-white/5">
<CardHeader>
<div className="flex items-center gap-2 text-sm font-semibold text-rose-600 dark:text-rose-200">
<Sparkles className="h-4 w-4" />
{t('empty.eyebrow', 'Noch kein Event aktiv')}
</div>
<CardTitle className="text-lg text-slate-900 dark:text-white">
{t('empty.title', 'Leg mit deinem ersten Event los')}
</CardTitle>
<CardDescription className="text-sm text-slate-600 dark:text-slate-300">
{t('empty.description', 'Importiere ein Mission Pack, lege Branding fest und teile sofort den Gästelink.')}
</CardDescription>
</CardHeader>
<CardContent>
<Button className="rounded-full bg-brand-rose px-6 text-white shadow-lg shadow-rose-400/40 hover:bg-[var(--brand-rose-strong)]" onClick={onCreateEvent}>
{t('empty.cta', 'Event anlegen')}
</Button>
</CardContent>
</Card>
);
}
const eventName = resolveEventDisplayName(event);
const dateLabel = formatEventDate(event.event_date, dateLocale) ?? t('noDate', 'Kein Datum gesetzt');
const statusLabel = formatEventStatusLabel(event.status ?? null, tc);
const isLive = Boolean(event.is_active || event.status === 'published');
const engagementMode = resolveEngagementMode(event);
const overviewStats = [
{
key: 'uploads',
label: t('stats.uploads', 'Uploads gesamt'),
value: Number(event.photo_count ?? 0).toLocaleString(),
},
{
key: 'likes',
label: t('stats.likes', 'Likes'),
value: Number(event.like_count ?? 0).toLocaleString(),
},
{
key: 'tasks',
label: t('stats.tasks', 'Aktive Aufgaben'),
value: Number(event.tasks_count ?? 0).toLocaleString(),
},
{
key: 'invites',
label: t('stats.invites', 'Einladungen live'),
value: Number(event.active_invites_count ?? event.total_invites_count ?? 0).toLocaleString(),
},
];
const quickActions = [
{
key: 'photos',
label: t('actions.photos', 'Uploads prüfen'),
description: t('actions.photosHint', 'Neueste Uploads ansehen und verstecken.'),
icon: Camera,
handler: onOpenPhotos,
},
{
key: 'invites',
label: t('actions.invites', 'QR & Einladungen'),
description: t('actions.invitesHint', 'Layouts exportieren oder Links kopieren.'),
icon: QrCode,
handler: onOpenInvites,
},
{
key: 'tasks',
label: t('actions.tasks', 'Mission Packs & Emotionen'),
description: t('actions.tasksHint', 'Kollektionen importieren und Emotionen aktivieren.'),
icon: ClipboardList,
handler: onOpenTasks,
},
{
key: 'photobooth',
label: t('actions.photobooth', 'Photobooth binden'),
description: t('actions.photoboothHint', 'FTP-Daten freigeben und Rate-Limit prüfen.'),
icon: PlugZap,
handler: onOpenPhotobooth,
},
];
const latestUploads = summary?.new_photos ?? 0;
return (
<div className="space-y-4">
<Card className="border border-slate-200 bg-white/90 shadow-lg shadow-rose-100/40 dark:border-white/10 dark:bg-white/5">
<CardHeader className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<div className="flex flex-wrap items-center gap-2 text-xs uppercase tracking-[0.35em] text-rose-500 dark:text-rose-200">
{t('eyebrow', 'Aktuelles Event')}
</div>
<CardTitle className="mt-2 text-2xl font-semibold text-slate-900 dark:text-white">
{eventName}
</CardTitle>
<CardDescription className="mt-1 text-sm text-slate-600 dark:text-slate-300">
{t('dateLabel', { defaultValue: 'Eventdatum: {{date}}', date: dateLabel })}
</CardDescription>
<div className="mt-3 flex flex-wrap items-center gap-2">
<Badge className={isLive ? 'bg-emerald-500 text-white' : 'bg-slate-200 text-slate-800'}>
{statusLabel}
</Badge>
<Badge variant="outline" className="text-xs font-semibold">
{isLive ? t('badges.live', 'Live für Gäste') : t('badges.hidden', 'Noch versteckt')}
</Badge>
{engagementMode === 'photo_only' ? (
<Badge variant="outline" className="text-xs font-semibold">
{t('badges.photoOnly', 'Nur Foto-Modus')}
</Badge>
) : (
<Badge variant="outline" className="text-xs font-semibold">
{t('badges.missionMode', 'Mission Cards aktiv')}
</Badge>
)}
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline" size="sm" className="rounded-full border-brand-rose-soft text-brand-rose hover:bg-brand-rose-soft/40" onClick={onOpenEvent}>
<CalendarDays className="mr-2 h-4 w-4" />
{t('viewEvent', 'Event öffnen')}
</Button>
</div>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{overviewStats.map((stat) => (
<div key={stat.key} className="rounded-2xl border border-slate-200 bg-white/80 p-4 text-sm text-slate-600 dark:border-white/10 dark:bg-white/5">
<p className="text-xs uppercase tracking-wide text-slate-500 dark:text-slate-300">{stat.label}</p>
<p className="mt-2 text-2xl font-semibold text-slate-900 dark:text-white">{stat.value}</p>
</div>
))}
</div>
<div className="grid gap-3 lg:grid-cols-2">
{quickActions.map((action) => (
<button
key={action.key}
type="button"
onClick={action.handler}
className="flex items-start gap-3 rounded-2xl border border-slate-200 bg-white/90 p-4 text-left 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="grid gap-3 md:grid-cols-2">
<div className="rounded-2xl border border-slate-200 bg-sky-50 p-4 text-slate-800 shadow-inner shadow-sky-100 dark:border-white/10 dark:bg-white/10 dark:text-white">
<p className="text-xs uppercase tracking-[0.3em] text-slate-500">{t('latestUploads.title', 'Neueste Uploads')}</p>
<p className="mt-2 text-3xl font-semibold">{latestUploads}</p>
<p className="text-xs text-slate-600 dark:text-slate-300">{t('latestUploads.hint', 'Gerade eingetroffen prüfe sie schnell.')}</p>
<Button size="sm" variant="secondary" className="mt-4" onClick={onOpenPhotos}>
{t('actions.photos', 'Uploads prüfen')}
</Button>
</div>
<div className="rounded-2xl border border-slate-200 bg-white/80 p-4 text-slate-800 shadow-inner shadow-slate-100 dark:border-white/10 dark:bg-white/5 dark:text-white">
<p className="text-xs uppercase tracking-[0.3em] text-slate-500">{t('invitesCard.title', 'Galerie & Einladungen')}</p>
<p className="mt-2 text-sm text-slate-600 dark:text-slate-200">
{t('invitesCard.description', 'Kopiere den Gästelink oder exportiere QR-Karten.')}
</p>
<div className="mt-3 flex flex-wrap gap-2">
<Button size="sm" variant="outline" onClick={onOpenInvites}>
{t('invitesCard.cta', 'Links verwalten')}
</Button>
<Button size="sm" variant="ghost" onClick={onOpenPhotobooth}>
{t('invitesCard.secondaryCta', 'Photobooth öffnen')}
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
{limitWarnings.length > 0 && (
<div className="grid gap-3 md:grid-cols-2">
{limitWarnings.map((warning) => (
<Alert
key={warning.id}
variant={warning.tone === 'danger' ? 'destructive' : 'default'}
className={warning.tone === 'warning' ? 'border-amber-400/50 bg-amber-50 text-amber-900 dark:border-amber-300/40 dark:bg-amber-500/10 dark:text-amber-100' : undefined}
>
<AlertTitle className="flex items-center gap-2 text-sm font-semibold">
<AlertTriangle className="h-4 w-4" />
{t(`limitWarnings.${warning.scope}`, {
defaultValue:
warning.scope === 'photos'
? 'Fotos'
: warning.scope === 'guests'
? 'Gäste'
: 'Galerie',
})}
</AlertTitle>
<AlertDescription className="text-sm">{warning.message}</AlertDescription>
</Alert>
))}
</div>
)}
</div>
);
}