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

360 lines
14 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, Plus, Share2 } from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import {
TenantHeroCard,
FrostedCard,
FrostedSurface,
tenantHeroPrimaryButtonClass,
tenantHeroSecondaryButtonClass,
} from '../components/tenant';
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_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 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 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 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-white/30 bg-white/95 p-5 text-slate-900 shadow-lg shadow-pink-200/20 backdrop-blur">
<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>
);
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}
/>
<FrostedCard className="mt-6">
<CardHeader className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<CardTitle>{t('events.list.overview.title', 'Übersicht')}</CardTitle>
<CardDescription>
{loading
? t('events.list.overview.loading', 'Wir sammeln gerade deine Event-Details …')
: totalEvents === 0
? t('events.list.overview.empty', 'Noch keine Events - starte jetzt und lege dein erstes Event an.')
: t('events.list.overview.count', '{{count}} Events aktiv verwaltet.', { count: totalEvents })}
</CardDescription>
</div>
<Badge className="bg-pink-100 text-pink-700">{t('events.list.badge.dashboard', 'Tenant Dashboard')}</Badge>
</CardHeader>
<CardContent className="space-y-4">
{loading ? (
<LoadingState />
) : totalEvents === 0 ? (
<EmptyState onCreate={() => navigate(adminPath('/events/new'))} />
) : (
<div className="space-y-4">
{rows.map((event) => (
<EventCard key={event.id} event={event} translate={translateManagement} translateCommon={translateCommon} />
))}
</div>
)}
</CardContent>
</FrostedCard>
</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],
);
return (
<FrostedSurface className="p-5 transition hover:-translate-y-0.5 hover:shadow-lg">
{limitWarnings.length > 0 && (
<div className="mb-4 space-y-2">
{limitWarnings.map((warning) => (
<Alert
key={warning.id}
variant={warning.tone === 'danger' ? 'destructive' : 'default'}
className={warning.tone === 'warning' ? 'border-amber-400/40 bg-amber-50 text-amber-900' : undefined}
>
<AlertDescription className="flex items-center gap-2 text-xs">
<AlertTriangle className="h-4 w-4" />
{warning.message}
</AlertDescription>
</Alert>
))}
</div>
)}
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div className="space-y-2">
<h3 className="text-lg font-semibold text-slate-900">{renderName(event.name)}</h3>
<div className="flex flex-wrap items-center gap-2 text-xs text-slate-600">
<span className="inline-flex items-center gap-1 rounded-full bg-pink-100/90 px-3 py-1 font-medium text-pink-700">
<CalendarDays className="h-3.5 w-3.5" />
{formatDate(event.event_date)}
</span>
<span className="inline-flex items-center gap-1 rounded-full bg-sky-100/90 px-3 py-1 font-medium text-sky-700">
{translate('events.list.badges.photos', 'Fotos')}: {photoCount}
</span>
<span className="inline-flex items-center gap-1 rounded-full bg-amber-100/90 px-3 py-1 font-medium text-amber-700">
{translate('events.list.badges.likes', 'Likes')}: {likeCount}
</span>
</div>
</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="mt-4 flex flex-wrap gap-2">
<Button asChild variant="outline" className="border-pink-200 text-pink-700 hover:bg-pink-50">
<Link to={ADMIN_EVENT_VIEW_PATH(slug)}>
{translateCommon('actions.open', 'Öffnen')} <ArrowRight className="ml-1 h-3.5 w-3.5" />
</Link>
</Button>
<Button asChild variant="outline" className="border-fuchsia-200 text-fuchsia-700 hover:bg-fuchsia-50">
<Link to={ADMIN_EVENT_EDIT_PATH(slug)}>{translateCommon('actions.edit', 'Bearbeiten')}</Link>
</Button>
<Button asChild variant="outline" className="border-sky-200 text-sky-700 hover:bg-sky-50">
<Link to={ADMIN_EVENT_PHOTOS_PATH(slug)}>
{translate('events.list.actions.photos', 'Fotos moderieren')}
</Link>
</Button>
<Button asChild variant="outline" className="border-emerald-200 text-emerald-600 hover:bg-emerald-50">
<Link to={ADMIN_EVENT_MEMBERS_PATH(slug)}>
{translate('events.list.actions.members', 'Mitglieder')}
</Link>
</Button>
<Button asChild variant="outline" className="border-amber-200 text-amber-600 hover:bg-amber-50">
<Link to={ADMIN_EVENT_TASKS_PATH(slug)}>
{translate('events.list.actions.tasks', 'Tasks')}
</Link>
</Button>
<Button asChild variant="outline" className="border-slate-200 text-slate-700 hover:bg-slate-50">
<Link to={ADMIN_EVENT_INVITES_PATH(slug)}>
<Share2 className="h-3.5 w-3.5" /> {translate('events.list.actions.invites', 'QR-Einladungen')}
</Link>
</Button>
<Button asChild variant="outline" className="border-emerald-200 text-emerald-600 hover:bg-emerald-50">
<Link to={ADMIN_EVENT_TOOLKIT_PATH(slug)}>{translate('events.list.actions.toolkit', 'Toolkit')}</Link>
</Button>
</div>
</FrostedSurface>
);
}
function LoadingState() {
return (
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, index) => (
<FrostedSurface
key={index}
className="h-24 animate-pulse bg-gradient-to-r from-white/40 via-white/70 to-white/40"
/>
))}
</div>
);
}
function EmptyState({ onCreate }: { 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">Noch kein Event angelegt</h3>
<p className="text-sm text-slate-600">
Starte jetzt mit deinem ersten Event und lade Gäste in dein farbenfrohes Erlebnisportal ein.
</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';
}