Files
fotospiel-app/resources/js/admin/pages/EventsPage.tsx

532 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { AlertTriangle, ArrowRight, CalendarDays, Camera, Heart, Plus } from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
TenantHeroCard,
FrostedSurface,
tenantHeroPrimaryButtonClass,
tenantHeroSecondaryButtonClass,
SectionCard,
SectionHeader,
StatCarousel,
ActionGrid,
} from '../components/tenant';
import { cn } from '@/lib/utils';
import { AdminLayout } from '../components/AdminLayout';
import { getEvents, TenantEvent } from '../api';
import { isAuthError } from '../auth/tokens';
import { getApiErrorMessage } from '../lib/apiError';
import {
adminPath,
ADMIN_SETTINGS_PATH,
ADMIN_WELCOME_BASE_PATH,
ADMIN_EVENT_VIEW_PATH,
ADMIN_EVENT_EDIT_PATH,
ADMIN_EVENT_PHOTOS_PATH,
ADMIN_EVENT_MEMBERS_PATH,
ADMIN_EVENT_TASKS_PATH,
ADMIN_EVENT_INVITES_PATH,
ADMIN_EVENT_TOOLKIT_PATH,
} from '../constants';
import { buildLimitWarnings, type EventLimitSummary } from '../lib/limitWarnings';
import { useTranslation } from 'react-i18next';
export default function EventsPage() {
const { t } = useTranslation('management');
const { t: tCommon } = useTranslation('common');
const [rows, setRows] = React.useState<TenantEvent[]>([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const navigate = useNavigate();
React.useEffect(() => {
(async () => {
try {
setRows(await getEvents());
} catch (err) {
if (!isAuthError(err)) {
setError(getApiErrorMessage(err, t('events.errors.loadFailed', 'Event konnte nicht geladen werden.')));
}
} finally {
setLoading(false);
}
})();
}, []);
const translateManagement = React.useCallback(
(key: string, fallback?: string, options?: Record<string, unknown>) =>
t(key, { defaultValue: fallback, ...(options ?? {}) }),
[t],
);
const translateCommon = React.useCallback(
(key: string, fallback?: string, options?: Record<string, unknown>) =>
tCommon(key, { defaultValue: fallback, ...(options ?? {}) }),
[tCommon],
);
const totalEvents = rows.length;
const publishedEvents = React.useMemo(
() => rows.filter((event) => event.status === 'published').length,
[rows],
);
const nextEvent = React.useMemo(() => {
return (
rows
.filter((event) => event.event_date)
.slice()
.sort((a, b) => {
const dateA = a.event_date ? new Date(a.event_date).getTime() : Infinity;
const dateB = b.event_date ? new Date(b.event_date).getTime() : Infinity;
return dateA - dateB;
})[0] ?? null
);
}, [rows]);
const statItems = React.useMemo(
() => [
{
key: 'total',
label: t('events.list.stats.total', 'Events gesamt'),
value: totalEvents,
},
{
key: 'published',
label: t('events.list.stats.published', 'Veröffentlicht'),
value: publishedEvents,
},
{
key: 'drafts',
label: t('events.list.stats.drafts', 'Entwürfe'),
value: Math.max(0, totalEvents - publishedEvents),
},
nextEvent
? {
key: 'next',
label: t('events.list.stats.nextEvent', 'Nächstes Event'),
value: formatDate(nextEvent.event_date),
}
: null,
].filter(Boolean) as { key: string; label: string; value: string | number }[],
[t, totalEvents, publishedEvents, nextEvent],
);
const actionItems = React.useMemo(
() => [
{
key: 'new',
label: t('events.list.actions.create', 'Neues Event'),
description: t('events.list.actions.createDescription', 'Starte mit einem frischen Setup.'),
onClick: () => navigate(adminPath('/events/new')),
},
{
key: 'welcome',
label: t('events.list.actions.guidedSetup', 'Geführte Einrichtung'),
description: t('events.list.actions.guidedSetupDescription', 'Springe zurück in den Onboarding-Flow.'),
onClick: () => navigate(ADMIN_WELCOME_BASE_PATH),
},
{
key: 'settings',
label: t('events.list.actions.settings', 'Einstellungen öffnen'),
description: t('events.list.actions.settingsDescription', 'Passe Farben, Branding und Aufgabenpakete an.'),
onClick: () => navigate(ADMIN_SETTINGS_PATH),
},
],
[t, navigate],
);
const pageTitle = translateManagement('events.list.title', 'Deine Events');
const pageSubtitle = translateManagement(
'events.list.subtitle',
'Plane Momente, die in Erinnerung bleiben. Hier verwaltest du alles rund um deine Veranstaltungen.'
);
const heroDescription = t(
'events.list.hero.description',
'Aktiviere Storytelling, Moderation und Galerie-Workflows für jeden Anlass in wenigen Minuten.'
);
const heroSummaryCopy = totalEvents > 0
? t('events.list.hero.summary', ':count Events aktiv verwaltet halte Aufgaben und Uploads im Blick.', { count: totalEvents })
: t('events.list.hero.summary_empty', 'Noch keine Events starte jetzt mit deinem ersten Konzept.');
const heroSecondaryCopy = t(
'events.list.hero.secondary',
'Erstelle Events im Admin, begleite Gäste live vor Ort und prüfe Kennzahlen im Marketing-Dashboard.'
);
const heroBadge = t('events.list.badge.dashboard', 'Tenant Dashboard');
const heroPrimaryAction = (
<Button
size="sm"
className={tenantHeroPrimaryButtonClass}
onClick={() => navigate(adminPath('/events/new'))}
>
{t('events.list.actions.create', 'Neues Event')}
</Button>
);
const heroSecondaryAction = (
<Button size="sm" className={tenantHeroSecondaryButtonClass} asChild>
<Link to={ADMIN_SETTINGS_PATH}>
{t('events.list.actions.settings', 'Einstellungen')}
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
);
const heroAside = (
<FrostedSurface className="w-full rounded-2xl border-slate-200 bg-white p-5 text-slate-900 shadow-lg shadow-pink-200/20 dark:border-white/20 dark:bg-white/10">
<div className="space-y-4">
<div>
<p className="text-xs uppercase tracking-wide text-slate-500">
{t('events.list.hero.published_label', 'Veröffentlichte Events')}
</p>
<p className="mt-1 text-2xl font-semibold text-slate-900">{publishedEvents}</p>
<p className="text-xs text-slate-500">
{t('events.list.hero.total_label', ':count insgesamt', { count: totalEvents })}
</p>
</div>
{nextEvent ? (
<div className="rounded-xl border border-pink-100 bg-pink-50/70 p-4 text-slate-900 shadow-inner shadow-pink-200/50">
<p className="text-xs uppercase tracking-wide text-pink-600">
{t('events.list.hero.next_label', 'Nächstes Event')}
</p>
<p className="mt-1 text-sm font-semibold">{renderName(nextEvent.name)}</p>
<p className="text-xs text-slate-600">{formatDate(nextEvent.event_date)}</p>
{nextEvent.slug ? (
<Button
variant="ghost"
size="sm"
className="mt-3 h-8 justify-start px-3 text-pink-600 hover:bg-pink-100"
onClick={() => navigate(ADMIN_EVENT_VIEW_PATH(nextEvent.slug))}
>
{t('events.list.hero.open_event', 'Event öffnen')}
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
) : null}
</div>
) : (
<p className="rounded-xl border border-dashed border-pink-200/70 bg-white/70 p-4 text-xs text-slate-600">
{t('events.list.hero.no_upcoming', 'Plane ein Datum, um hier die nächste Station zu sehen.')}
</p>
)}
</div>
</FrostedSurface>
);
const draftEvents = totalEvents - publishedEvents;
const [statusFilter, setStatusFilter] = React.useState<'all' | 'published' | 'draft'>('all');
const filteredRows = React.useMemo(() => {
if (statusFilter === 'published') {
return rows.filter((event) => event.status === 'published');
}
if (statusFilter === 'draft') {
return rows.filter((event) => event.status !== 'published');
}
return rows;
}, [rows, statusFilter]);
const overviewDescription = React.useMemo(() => {
if (loading) {
return t('events.list.overview.loading', 'Wir sammeln gerade deine Event-Details …');
}
if (filteredRows.length === 0) {
if (statusFilter === 'published') {
return t('events.list.overview.empty_published', 'Noch keine veröffentlichten Events.');
}
if (statusFilter === 'draft') {
return t('events.list.overview.empty_drafts', 'Keine Entwürfe nutze die Zeit für dein nächstes Event.');
}
return t('events.list.overview.empty', 'Noch keine Events - starte jetzt und lege dein erstes Event an.');
}
return t('events.list.overview.count', '{{count}} Events aktiv verwaltet.', { count: filteredRows.length });
}, [filteredRows.length, loading, statusFilter, t]);
const filterOptions: Array<{ key: 'all' | 'published' | 'draft'; label: string; count: number }> = [
{ key: 'all', label: t('events.list.filters.all', 'Alle'), count: totalEvents },
{ key: 'published', label: t('events.list.filters.published', 'Live'), count: publishedEvents },
{ key: 'draft', label: t('events.list.filters.drafts', 'Entwürfe'), count: Math.max(0, draftEvents) },
];
return (
<AdminLayout title={pageTitle} subtitle={pageSubtitle}>
{error && (
<Alert variant="destructive">
<AlertTitle>Fehler beim Laden</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<TenantHeroCard
badge={heroBadge}
title={pageTitle}
description={heroDescription}
supporting={[heroSummaryCopy, heroSecondaryCopy]}
primaryAction={heroPrimaryAction}
secondaryAction={heroSecondaryAction}
aside={heroAside}
/>
<SectionCard className="space-y-4">
<SectionHeader
eyebrow={t('events.list.badge', 'Events')}
title={t('events.list.overview.title', 'Übersicht')}
description={overviewDescription}
endSlot={(
<button
type="button"
className="rounded-full border border-slate-200 bg-white px-4 py-1.5 text-xs font-semibold uppercase tracking-wide text-slate-800 shadow-inner shadow-white/20 dark:border-white/15 dark:bg-white/10 dark:text-white"
>
{t('events.list.badge.dashboard', 'Tenant Dashboard')}
</button>
)}
/>
<StatCarousel items={statItems} />
<ActionGrid items={actionItems} columns={1} />
<div className="flex gap-2 overflow-x-auto pb-1">
{filterOptions.map((option) => (
<button
key={option.key}
type="button"
onClick={() => setStatusFilter(option.key)}
className={cn(
'flex items-center gap-2 rounded-full border px-4 py-1.5 text-xs font-semibold transition',
statusFilter === option.key
? 'border-rose-200 bg-rose-50 text-rose-700 shadow shadow-rose-100/40 dark:border-white/60 dark:bg-white/10 dark:text-white'
: 'border-slate-200 text-slate-600 hover:text-slate-900 dark:border-white/15 dark:text-slate-300 dark:hover:text-white'
)}
>
{option.label}
<span className="text-[11px] text-slate-400 dark:text-slate-500">{option.count}</span>
</button>
))}
</div>
{loading ? (
<LoadingState />
) : filteredRows.length === 0 ? (
<EmptyState
title={t('events.list.empty.title', 'Noch kein Event angelegt')}
description={t(
'events.list.empty.description',
'Starte jetzt mit deinem ersten Event und lade Gäste in dein farbenfrohes Erlebnisportal ein.'
)}
onCreate={() => navigate(adminPath('/events/new'))}
/>
) : (
<div className="space-y-3">
{filteredRows.map((event) => (
<EventCard
key={event.id}
event={event}
translate={translateManagement}
translateCommon={translateCommon}
/>
))}
</div>
)}
</SectionCard>
</AdminLayout>
);
}
function EventCard({
event,
translate,
translateCommon,
}: {
event: TenantEvent;
translate: (key: string, fallback?: string, options?: Record<string, unknown>) => string;
translateCommon: (key: string, fallback?: string, options?: Record<string, unknown>) => string;
}) {
const slug = event.slug;
const isPublished = event.status === 'published';
const photoCount = event.photo_count ?? 0;
const likeCount = event.like_count ?? 0;
const limitWarnings = React.useMemo(
() => buildLimitWarnings(event.limits ?? null, (key, opts) => translateCommon(`limits.${key}`, undefined, opts)),
[event.limits, translateCommon],
);
const metaItems = [
{
key: 'date',
label: translate('events.list.meta.date', 'Eventdatum'),
value: formatDate(event.event_date),
icon: <CalendarDays className="h-4 w-4 text-rose-500" />,
},
{
key: 'photos',
label: translate('events.list.meta.photos', 'Uploads'),
value: photoCount,
icon: <Camera className="h-4 w-4 text-fuchsia-500" />,
},
{
key: 'likes',
label: translate('events.list.meta.likes', 'Likes'),
value: likeCount,
icon: <Heart className="h-4 w-4 text-amber-500" />,
},
];
const secondaryLinks = [
{ key: 'edit', label: translateCommon('actions.edit', 'Bearbeiten'), to: ADMIN_EVENT_EDIT_PATH(slug) },
{ key: 'members', label: translate('events.list.actions.members', 'Mitglieder'), to: ADMIN_EVENT_MEMBERS_PATH(slug) },
{ key: 'tasks', label: translate('events.list.actions.tasks', 'Tasks'), to: ADMIN_EVENT_TASKS_PATH(slug) },
{ key: 'invites', label: translate('events.list.actions.invites', 'QR-Einladungen'), to: ADMIN_EVENT_INVITES_PATH(slug) },
{ key: 'toolkit', label: translate('events.list.actions.toolkit', 'Toolkit'), to: ADMIN_EVENT_TOOLKIT_PATH(slug) },
];
return (
<FrostedSurface className="space-y-4 rounded-3xl p-5 shadow-lg shadow-rose-100/30 transition hover:-translate-y-0.5 hover:shadow-rose-200/60">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<p className="text-xs uppercase tracking-[0.3em] text-rose-300/80">{translate('events.list.item.label', 'Event')}</p>
<h3 className="text-xl font-semibold text-slate-900">{renderName(event.name)}</h3>
</div>
<Badge className={isPublished ? 'bg-emerald-500/90 text-white shadow shadow-emerald-500/30' : 'bg-slate-200 text-slate-700'}>
{isPublished
? translateCommon('events.status.published', 'Veröffentlicht')
: translateCommon('events.status.draft', 'Entwurf')}
</Badge>
</div>
<div className="-mx-1 flex snap-x snap-mandatory gap-3 overflow-x-auto px-1">
{metaItems.map((item) => (
<MetaChip key={item.key} icon={item.icon} label={item.label} value={item.value} />
))}
</div>
{limitWarnings.length > 0 && (
<div className="space-y-2">
{limitWarnings.map((warning) => (
<div
key={warning.id}
className={cn(
'flex items-start gap-2 rounded-2xl border p-3 text-xs',
warning.tone === 'danger'
? 'border-rose-200/60 bg-rose-50 text-rose-900'
: 'border-amber-200/60 bg-amber-50 text-amber-900',
)}
>
<AlertTriangle className="h-4 w-4" />
<span>{warning.message}</span>
</div>
))}
</div>
)}
<div className="grid gap-2 sm:grid-cols-2">
<Button
asChild
className="rounded-full bg-brand-rose text-white shadow shadow-rose-400/40 hover:bg-brand-rose/90"
>
<Link to={ADMIN_EVENT_VIEW_PATH(slug)}>
{translateCommon('actions.open', 'Öffnen')} <ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
<Button asChild variant="outline" className="rounded-full border-rose-200 text-rose-600 hover:bg-rose-50">
<Link to={ADMIN_EVENT_PHOTOS_PATH(slug)}>
{translate('events.list.actions.photos', 'Fotos moderieren')}
</Link>
</Button>
</div>
<div className="flex flex-wrap gap-2">
{secondaryLinks.map((action) => (
<ActionChip key={action.key} to={action.to}>
{action.label}
</ActionChip>
))}
</div>
</FrostedSurface>
);
}
function MetaChip({
icon,
label,
value,
}: {
icon: React.ReactNode;
label: string;
value: string | number;
}) {
return (
<div className="min-w-[55%] snap-center rounded-2xl border border-slate-200 bg-white p-3 text-left text-xs shadow-sm sm:min-w-0 dark:border-white/15 dark:bg-white/10 dark:text-white">
<div className="flex items-center gap-2 text-slate-500 dark:text-slate-300">
{icon}
<span>{label}</span>
</div>
<p className="mt-1 text-sm font-semibold text-slate-900 dark:text-white">{value}</p>
</div>
);
}
function ActionChip({ to, children }: { to: string; children: React.ReactNode }) {
return (
<Link
to={to}
className="inline-flex items-center rounded-full border border-slate-200 px-3 py-1.5 text-xs font-semibold text-slate-600 transition hover:border-rose-200 hover:bg-rose-50 hover:text-rose-700 dark:border-white/15 dark:text-slate-300 dark:hover:border-white/40 dark:hover:bg-white/10 dark:hover:text-white"
>
{children}
</Link>
);
}
function LoadingState() {
return (
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, index) => (
<FrostedSurface
key={index}
className="h-24 animate-pulse rounded-3xl bg-gradient-to-r from-white/20 via-white/60 to-white/20"
/>
))}
</div>
);
}
function EmptyState({
title,
description,
onCreate,
}: {
title: string;
description: string;
onCreate: () => void;
}) {
return (
<FrostedSurface className="flex flex-col items-center justify-center gap-4 border-dashed border-pink-200/70 p-10 text-center">
<div className="rounded-full bg-pink-100 p-3 text-pink-600 shadow-inner shadow-pink-200/80">
<Plus className="h-5 w-5" />
</div>
<div className="space-y-2">
<h3 className="text-lg font-semibold text-slate-900">{title}</h3>
<p className="text-sm text-slate-600">{description}</p>
</div>
<Button
onClick={onCreate}
className="rounded-full bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 px-6 text-white shadow-lg shadow-pink-500/20"
>
<Plus className="mr-1 h-4 w-4" /> Event erstellen
</Button>
</FrostedSurface>
);
}
function formatDate(iso: string | null): string {
if (!iso) return 'Noch kein Datum';
const date = new Date(iso);
if (Number.isNaN(date.getTime())) {
return 'Unbekanntes Datum';
}
return date.toLocaleDateString('de-DE', {
day: '2-digit',
month: 'short',
year: 'numeric',
});
}
function renderName(name: TenantEvent['name']): string {
if (typeof name === 'string') return name;
if (name && typeof name === 'object') {
return name.de ?? name.en ?? Object.values(name)[0] ?? 'Unbenanntes Event';
}
return 'Unbenanntes Event';
}