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

1569 lines
62 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.
// @ts-nocheck
import React from 'react';
import { useNavigate, useParams, useSearchParams, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
AlertTriangle,
ArrowLeft,
Bell,
Camera,
CheckCircle2,
Circle,
Clock3,
Loader2,
MessageSquare,
Printer,
QrCode,
PlugZap,
RefreshCw,
Smile,
Sparkles,
ShoppingCart,
Users,
} from 'lucide-react';
import toast from 'react-hot-toast';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { AdminLayout } from '../components/AdminLayout';
import {
EventToolkit,
EventToolkitTask,
TenantEmotion,
TenantEvent,
TenantPhoto,
EventStats,
getEvent,
getEventStats,
getEventToolkit,
toggleEvent,
submitTenantFeedback,
updatePhotoVisibility,
createEventAddonCheckout,
featurePhoto,
unfeaturePhoto,
getEmotions,
} from '../api';
import { buildLimitWarnings } from '../lib/limitWarnings';
import { getApiErrorMessage } from '../lib/apiError';
import { isAuthError } from '../auth/tokens';
import {
ADMIN_EVENTS_PATH,
ADMIN_EVENT_EDIT_PATH,
ADMIN_EVENT_INVITES_PATH,
ADMIN_EVENT_MEMBERS_PATH,
ADMIN_EVENT_PHOTOBOOTH_PATH,
ADMIN_EVENT_PHOTOS_PATH,
ADMIN_EVENT_TASKS_PATH,
buildEngagementTabPath,
} from '../constants';
import {
SectionCard,
SectionHeader,
ActionGrid,
TenantHeroCard,
} from '../components/tenant';
import { AddonsPicker } from '../components/Addons/AddonsPicker';
import { AddonSummaryList } from '../components/Addons/AddonSummaryList';
import { EventAddonCatalogItem, getAddonCatalog } from '../api';
import { GuestBroadcastCard } from '../components/GuestBroadcastCard';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { filterEmotionsByEventType } from '../lib/emotions';
import { buildEventTabs } from '../lib/eventTabs';
type EventDetailPageProps = {
mode?: 'detail' | 'toolkit';
};
type ToolkitState = {
data: EventToolkit | null;
loading: boolean;
error: string | null;
};
type WorkspaceState = {
event: TenantEvent | null;
stats: EventStats | null;
loading: boolean;
busy: boolean;
error: string | null;
};
export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProps) {
const { slug: slugParam } = useParams<{ slug?: string }>();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const location = useLocation();
const { t } = useTranslation('management');
const { t: tCommon } = useTranslation('common');
const slug = slugParam ?? null;
const [state, setState] = React.useState<WorkspaceState>({
event: null,
stats: null,
loading: true,
busy: false,
error: null,
});
const [toolkit, setToolkit] = React.useState<ToolkitState>({ data: null, loading: true, error: null });
const [addonBusyId, setAddonBusyId] = React.useState<string | null>(null);
const [addonRefreshCount, setAddonRefreshCount] = React.useState(0);
const [addonsCatalog, setAddonsCatalog] = React.useState<EventAddonCatalogItem[]>([]);
const [emotions, setEmotions] = React.useState<TenantEmotion[]>([]);
const load = React.useCallback(async () => {
if (!slug) {
setState({ event: null, stats: null, loading: false, busy: false, error: t('events.errors.missingSlug', 'Kein Event ausgewählt.') });
setToolkit({ data: null, loading: false, error: null });
return;
}
setState((prev) => ({ ...prev, loading: true, error: null }));
setToolkit((prev) => ({ ...prev, loading: true, error: null }));
try {
const [eventData, statsData, addonOptions] = await Promise.all([getEvent(slug), getEventStats(slug), getAddonCatalog()]);
setState((prev) => ({ ...prev, event: eventData, stats: statsData, loading: false }));
setAddonsCatalog(addonOptions);
} catch (error) {
if (!isAuthError(error)) {
setState((prev) => ({
...prev,
error: getApiErrorMessage(error, t('events.errors.loadFailed', 'Event konnte nicht geladen werden.')),
loading: false,
}));
}
}
try {
const toolkitData = await getEventToolkit(slug);
setToolkit({ data: toolkitData, loading: false, error: null });
} catch (error) {
if (!isAuthError(error)) {
setToolkit({
data: null,
loading: false,
error: getApiErrorMessage(error, t('toolkit.errors.loadFailed', 'Toolkit-Daten konnten nicht geladen werden.')),
});
}
}
}, [slug, t]);
React.useEffect(() => {
void load();
}, [load]);
React.useEffect(() => {
let cancelled = false;
(async () => {
try {
const list = await getEmotions();
if (!cancelled) {
setEmotions(list);
}
} catch (error) {
if (!isAuthError(error)) {
console.warn('Failed to load emotions for event detail', error);
}
}
})();
return () => {
cancelled = true;
};
}, []);
async function handleToggle(): Promise<void> {
if (!slug) {
return;
}
setState((prev) => ({ ...prev, busy: true, error: null }));
try {
const updated = await toggleEvent(slug);
setState((prev) => ({
...prev,
busy: false,
event: updated,
stats: prev.stats
? {
...prev.stats,
status: updated.status,
is_active: Boolean(updated.is_active),
}
: prev.stats,
}));
} catch (error) {
if (!isAuthError(error)) {
setState((prev) => ({
...prev,
busy: false,
error: getApiErrorMessage(error, t('events.errors.toggleFailed', 'Status konnte nicht angepasst werden.')),
}));
} else {
setState((prev) => ({ ...prev, busy: false }));
}
}
}
const { event, stats, loading, busy, error } = state;
const toolkitData = toolkit.data;
const eventName = event ? resolveName(event.name) : t('events.placeholders.untitled', 'Unbenanntes Event');
const subtitle = mode === 'toolkit'
? t('events.workspace.toolkitSubtitle', 'Moderation, Aufgaben und Einladungen für deinen Eventtag bündeln.')
: t('events.workspace.detailSubtitle', 'Behalte Status, Aufgaben und Einladungen deines Events im Blick.');
const limitWarnings = React.useMemo(
() => (event?.limits ? buildLimitWarnings(event.limits, (key, options) => tCommon(`limits.${key}`, options)) : []),
[event?.limits, tCommon],
);
const [dismissedWarnings, setDismissedWarnings] = React.useState<Set<string>>(new Set());
React.useEffect(() => {
const slug = event?.slug;
if (!slug || typeof window === 'undefined') {
setDismissedWarnings(new Set());
return;
}
try {
const raw = window.localStorage.getItem(`tenant-admin:dismissed-limit-warnings:${slug}`);
if (!raw) {
setDismissedWarnings(new Set());
return;
}
const parsed = JSON.parse(raw) as string[];
setDismissedWarnings(new Set(parsed));
} catch {
setDismissedWarnings(new Set());
}
}, [event?.slug]);
const visibleWarnings = React.useMemo(
() => limitWarnings.filter((warning) => !dismissedWarnings.has(warning.id)),
[limitWarnings, dismissedWarnings],
);
const dismissWarning = React.useCallback(
(id: string) => {
const slug = event?.slug;
setDismissedWarnings((prev) => {
const next = new Set(prev);
next.add(id);
if (slug && typeof window !== 'undefined') {
window.localStorage.setItem(
`tenant-admin:dismissed-limit-warnings:${slug}`,
JSON.stringify(Array.from(next)),
);
}
return next;
});
},
[event?.slug],
);
const eventTabs = React.useMemo(() => {
if (!event) {
return [];
}
const translateMenu = (key: string, fallback: string) => t(key, { defaultValue: fallback });
return buildEventTabs(event, translateMenu, {
photos: toolkitData?.photos?.pending?.length ?? event.photo_count ?? 0,
tasks: toolkitData?.tasks?.summary.total ?? event.tasks_count ?? 0,
invites: toolkitData?.invites?.summary.active ?? event.active_invites_count ?? event.total_invites_count ?? 0,
});
}, [event, toolkitData?.photos?.pending?.length, toolkitData?.tasks?.summary.total, toolkitData?.invites?.summary.active, t]);
const isRecapRoute = React.useMemo(
() => location.pathname.endsWith('/recap'),
[location.pathname],
);
const shownWarningToasts = React.useRef<Set<string>>(new Set());
//const [addonBusyId, setAddonBusyId] = React.useState<string | null>(null);
const handleAddonPurchase = React.useCallback(
async (scope: 'photos' | 'guests' | 'gallery', addonKeyOverride?: string) => {
if (!slug) return;
const defaultAddons: Record<typeof scope, string> = {
photos: 'extra_photos_500',
guests: 'extra_guests_100',
gallery: 'extend_gallery_30d',
};
const addonKey = addonKeyOverride ?? defaultAddons[scope];
setAddonBusyId(scope);
try {
const currentUrl = window.location.origin + window.location.pathname;
const successUrl = `${currentUrl}?addon_success=1`;
const checkout = await createEventAddonCheckout(slug, {
addon_key: addonKey,
quantity: 1,
success_url: successUrl,
cancel_url: currentUrl,
});
if (checkout.checkout_url) {
window.location.href = checkout.checkout_url;
} else {
toast(t('events.errors.checkoutMissing', 'Checkout konnte nicht gestartet werden.'));
}
} catch (err) {
toast(getApiErrorMessage(err, t('events.errors.checkoutFailed', 'Add-on Checkout fehlgeschlagen.')));
} finally {
setAddonBusyId(null);
}
},
[slug, t],
);
React.useEffect(() => {
limitWarnings.forEach((warning) => {
const id = `${warning.id}-${warning.message}`;
if (shownWarningToasts.current.has(id)) {
return;
}
shownWarningToasts.current.add(id);
toast(warning.message, {
icon: warning.tone === 'danger' ? '🚨' : '⚠️',
id,
});
});
}, [limitWarnings]);
React.useEffect(() => {
const success = searchParams.get('addon_success');
if (success && slug) {
toast(t('events.success.addonApplied', 'Add-on angewendet. Limits aktualisieren sich in Kürze.'));
void load();
setAddonRefreshCount(3);
const params = new URLSearchParams(window.location.search);
params.delete('addon_success');
const search = params.toString();
navigate(search ? `${window.location.pathname}?${search}` : window.location.pathname, { replace: true });
}
}, [searchParams, slug, load, navigate, t]);
React.useEffect(() => {
if (addonRefreshCount <= 0) {
return;
}
const timer = setTimeout(() => {
void load();
setAddonRefreshCount((count) => count - 1);
}, 8000);
return () => clearTimeout(timer);
}, [addonRefreshCount, load]);
if (!slug) {
return (
<AdminLayout
title={t('events.errors.notFoundTitle', 'Event nicht gefunden')}
subtitle={t('events.errors.notFoundCopy', 'Bitte wähle ein Event aus der Übersicht.')}
>
<SectionCard>
<p className="text-sm text-slate-600 dark:text-slate-300">
{t('events.errors.notFoundBody', 'Ohne gültige Kennung können wir keine Daten laden. Kehre zur Eventliste zurück und wähle dort ein Event aus.')}
</p>
</SectionCard>
</AdminLayout>
);
}
return (
<AdminLayout
title={eventName}
subtitle={subtitle}
tabs={eventTabs}
currentTabKey={isRecapRoute ? 'recap' : 'overview'}
>
{error && (
<Alert variant="destructive">
<AlertTitle>{t('events.alerts.failedTitle', 'Aktion fehlgeschlagen')}</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{visibleWarnings.length > 0 && (
<div className="mb-6 space-y-2">
{visibleWarnings.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}
>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<AlertDescription className="flex items-center gap-2 text-sm">
<AlertTriangle className="h-4 w-4" />
{warning.message}
</AlertDescription>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
{(['photos', 'guests', 'gallery'] as const).includes(warning.scope) ? (
<>
<Button
variant="outline"
size="sm"
onClick={() => { void handleAddonPurchase(warning.scope as 'photos' | 'guests' | 'gallery'); }}
disabled={addonBusyId === warning.scope}
className="justify-start"
>
<ShoppingCart className="mr-2 h-4 w-4" />
{warning.scope === 'photos'
? t('events.actions.buyMorePhotos', 'Mehr Fotos freischalten')
: warning.scope === 'guests'
? t('events.actions.buyMoreGuests', 'Mehr Gäste freischalten')
: t('events.actions.extendGallery', 'Galerie verlängern')}
</Button>
{addonsCatalog.length > 0 ? (
<AddonsPicker
addons={addonsCatalog}
scope={warning.scope as 'photos' | 'guests' | 'gallery'}
onCheckout={(key) => { void handleAddonPurchase(warning.scope as 'photos' | 'guests' | 'gallery', key); }}
busy={addonBusyId === warning.scope}
t={(key, fallback) => t(key, fallback)}
/>
) : null}
</>
) : null}
<Button
variant="ghost"
size="sm"
onClick={() => dismissWarning(warning.id)}
className="justify-start text-slate-600 hover:text-slate-900"
>
{tCommon('actions.dismiss', 'Hinweis ausblenden')}
</Button>
</div>
</div>
</Alert>
))}
</div>
)}
{toolkit.error && (
<Alert variant="default">
<AlertTitle>{toolkit.error}</AlertTitle>
</Alert>
)}
{loading ? (
<WorkspaceSkeleton />
) : event ? (
<div className="space-y-6">
<EventHeroCardSection
event={event}
stats={stats}
onRefresh={() => { void load(); }}
loading={state.busy}
navigate={navigate}
/>
<Tabs defaultValue={isRecapRoute ? 'recap' : 'overview'} className="space-y-6">
<TabsList className="grid gap-2 rounded-2xl bg-slate-100/80 p-1 dark:bg-white/5 sm:grid-cols-3">
<TabsTrigger value="overview">{t('events.workspace.tabs.overview', 'Überblick')}</TabsTrigger>
<TabsTrigger value="setup">{t('events.workspace.tabs.setup', 'Vorbereitung')}</TabsTrigger>
<TabsTrigger value="recap">{t('events.workspace.tabs.recap', 'Nachbereitung')}</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-6">
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.6fr)_minmax(0,0.6fr)]">
<StatusCard event={event} stats={stats} busy={busy} onToggle={handleToggle} />
<QuickActionsCard slug={event.slug} busy={busy} onToggle={handleToggle} navigate={navigate} />
</div>
<MetricsGrid metrics={toolkitData?.metrics} stats={stats} />
</TabsContent>
<TabsContent value="setup" className="space-y-6">
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.4fr)_minmax(0,0.8fr)]">
<TaskOverviewCard tasks={toolkitData?.tasks} navigateToTasks={() => navigate(ADMIN_EVENT_TASKS_PATH(event.slug))} />
<InviteSummary
invites={toolkitData?.invites}
navigateToInvites={() => navigate(`${ADMIN_EVENT_INVITES_PATH(event.slug)}?tab=layout`)}
/>
</div>
<BrandingMissionCard
event={event}
invites={toolkitData?.invites}
emotions={emotions}
onOpenBranding={() => navigate(`${ADMIN_EVENT_INVITES_PATH(event.slug)}?tab=layout`)}
onOpenCollections={() => navigate(buildEngagementTabPath('collections'))}
onOpenTasks={() => navigate(ADMIN_EVENT_TASKS_PATH(event.slug))}
onOpenEmotions={() => navigate(buildEngagementTabPath('emotions'))}
/>
{event.addons?.length ? (
<SectionCard>
<SectionHeader
title={t('events.sections.addons.title', 'Add-ons & Upgrades')}
description={t('events.sections.addons.description', 'Zusätzliche Kontingente für dieses Event.')}
/>
<AddonSummaryList addons={event.addons} t={(key, fallback) => t(key, fallback)} />
</SectionCard>
) : null}
</TabsContent>
<TabsContent value="recap" className="space-y-6">
<GalleryShareCard
invites={toolkitData?.invites}
onManageInvites={() => navigate(`${ADMIN_EVENT_INVITES_PATH(event.slug)}?tab=layout`)}
/>
{event.limits?.gallery ? (
<GalleryStatusCard gallery={event.limits.gallery} />
) : null}
<FeedbackCard slug={event.slug} />
</TabsContent>
</Tabs>
</div>
) : (
<SectionCard>
<p className="text-sm text-slate-600 dark:text-slate-300">
{t('events.errors.notFoundBody', 'Ohne gültige Kennung können wir keine Daten laden. Kehre zur Eventliste zurück und wähle dort ein Event aus.')}
</p>
</SectionCard>
)}
</AdminLayout>
);
}
function resolveName(name: TenantEvent['name']): string {
if (typeof name === 'string' && name.trim().length > 0) {
return name.trim();
}
if (name && typeof name === 'object') {
return name.de ?? name.en ?? Object.values(name)[0] ?? 'Event';
}
return 'Event';
}
function EventHeroCardSection({ event, stats, onRefresh, loading, navigate }: {
event: TenantEvent;
stats: EventStats | null;
onRefresh: () => void;
loading: boolean;
navigate: ReturnType<typeof useNavigate>;
}) {
const { t } = useTranslation('management');
const statusLabel = getStatusLabel(event, t);
const supporting = [
t('events.workspace.hero.status', { defaultValue: 'Status: {{status}}', status: statusLabel }),
t('events.workspace.hero.date', { defaultValue: 'Eventdatum: {{date}}', date: formatDate(event.event_date) }),
t('events.workspace.hero.metrics', {
defaultValue: 'Uploads gesamt: {{count}} · Likes: {{likes}}',
count: stats?.uploads_total ?? stats?.total ?? 0,
likes: stats?.likes_total ?? stats?.likes ?? 0,
}),
];
const aside = (
<div className="space-y-3 text-sm text-slate-700 dark:text-slate-200">
<InfoRow
icon={<Sparkles className="h-4 w-4 text-pink-500" />}
label={t('events.workspace.fields.status', 'Status')}
value={statusLabel}
/>
<InfoRow
icon={<CalendarIcon />}
label={t('events.workspace.fields.date', 'Eventdatum')}
value={formatDate(event.event_date)}
/>
<InfoRow
icon={<Users className="h-4 w-4 text-sky-500" />}
label={t('events.workspace.fields.active', 'Aktiv für Gäste')}
value={event.is_active ? t('events.workspace.activeYes', 'Ja') : t('events.workspace.activeNo', 'Nein')}
/>
</div>
);
return (
<TenantHeroCard
badge={t('events.workspace.hero.badge', 'Event')}
title={resolveName(event.name)}
description={t('events.workspace.hero.description', 'Konzentriere dich auf Aufgaben, Moderation und Einladungen für dieses Event.')}
supporting={supporting}
primaryAction={(
<Button variant="outline" onClick={() => navigate(ADMIN_EVENTS_PATH)} className="rounded-full border-pink-200 text-pink-600 hover:bg-pink-50">
<ArrowLeft className="h-4 w-4" /> {t('events.actions.backToList', 'Zurück zur Liste')}
</Button>
)}
secondaryAction={(
<Button variant="outline" onClick={() => navigate(ADMIN_EVENT_EDIT_PATH(event.slug))} className="rounded-full border-fuchsia-200 text-fuchsia-700 hover:bg-fuchsia-50">
<Sparkles className="h-4 w-4" /> {t('events.actions.edit', 'Bearbeiten')}
</Button>
)}
aside={aside}
>
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
onClick={onRefresh}
disabled={loading}
className="rounded-full border-slate-200"
>
{loading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <RefreshCw className="mr-2 h-4 w-4" />}
{t('events.actions.refresh', 'Aktualisieren')}
</Button>
</div>
</TenantHeroCard>
);
}
function StatusCard({ event, stats, busy, onToggle }: { event: TenantEvent; stats: EventStats | null; busy: boolean; onToggle: () => void }) {
const { t } = useTranslation('management');
const statusLabel = getStatusLabel(event, t);
return (
<SectionCard className="space-y-4">
<SectionHeader
eyebrow={t('events.workspace.sections.statusBadge', 'Status')}
title={t('events.workspace.sections.statusTitle', 'Eventstatus & Sichtbarkeit')}
description={t('events.workspace.sections.statusSubtitle', 'Aktiviere dein Event für Gäste oder verstecke es vorübergehend.')}
/>
<div className="space-y-4 text-sm text-slate-700 dark:text-slate-300">
<InfoRow icon={<Sparkles className="h-4 w-4 text-pink-500" />} label={t('events.workspace.fields.status', 'Status')} value={statusLabel} />
<InfoRow icon={<Circle className="h-4 w-4 text-amber-500" />} label={t('events.workspace.fields.active', 'Aktiv für Gäste')} value={event.is_active ? t('events.workspace.activeYes', 'Ja') : t('events.workspace.activeNo', 'Nein')} />
<InfoRow icon={<CalendarIcon />} label={t('events.workspace.fields.date', 'Eventdatum')} value={formatDate(event.event_date)} />
<InfoRow icon={<Smile className="h-4 w-4 text-rose-500" />} label={t('events.workspace.fields.eventType', 'Event-Typ')} value={resolveEventType(event)} />
{stats && (
<div className="rounded-xl border border-pink-100 bg-pink-50/60 p-4 text-xs text-pink-900">
<p className="font-semibold text-pink-700">{t('events.workspace.fields.insights', 'Letzte Aktivität')}</p>
<p>
{t('events.workspace.fields.uploadsTotal', {
defaultValue: '{{count}} Uploads gesamt',
count: stats.uploads_total ?? stats.total ?? 0,
})}
{' · '}
{t('events.workspace.fields.uploadsToday', {
defaultValue: '{{count}} Uploads (24h)',
count: stats.uploads_24h ?? stats.recent_uploads ?? 0,
})}
</p>
<p>
{t('events.workspace.fields.likesTotal', {
defaultValue: '{{count}} Likes vergeben',
count: stats.likes_total ?? stats.likes ?? 0,
})}
</p>
</div>
)}
<div className="flex flex-wrap gap-2 pt-2">
<Button onClick={onToggle} disabled={busy} className="bg-gradient-to-r from-pink-500 via-rose-500 to-purple-500 text-white shadow-md shadow-rose-200/60">
{busy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : event.is_active ? <Circle className="mr-2 h-4 w-4" /> : <CheckCircle2 className="mr-2 h-4 w-4" />}
{event.is_active ? t('events.workspace.actions.pause', 'Event pausieren') : t('events.workspace.actions.activate', 'Event aktivieren')}
</Button>
</div>
</div>
</SectionCard>
);
}
function QuickActionsCard({ slug, busy, onToggle, navigate }: { slug: string; busy: boolean; onToggle: () => void | Promise<void>; navigate: ReturnType<typeof useNavigate> }) {
const { t } = useTranslation('management');
const gridItems = [
{
key: 'photos',
icon: <Camera className="h-4 w-4" />,
label: t('events.quickActions.moderate', 'Fotos moderieren'),
description: t('events.quickActions.moderateDesc', 'Prüfe Uploads und veröffentliche Highlights.'),
onClick: () => navigate(ADMIN_EVENT_PHOTOS_PATH(slug)),
},
{
key: 'tasks',
icon: <Sparkles className="h-4 w-4" />,
label: t('events.quickActions.tasks', 'Aufgaben bearbeiten'),
description: t('events.quickActions.tasksDesc', 'Passe Story-Prompts und Moderation an.'),
onClick: () => navigate(ADMIN_EVENT_TASKS_PATH(slug)),
},
{
key: 'invites',
icon: <QrCode className="h-4 w-4" />,
label: t('events.quickActions.invites', 'Layouts & QR verwalten'),
description: t('events.quickActions.invitesDesc', 'Aktualisiere QR-Kits und Einladungslayouts.'),
onClick: () => navigate(`${ADMIN_EVENT_INVITES_PATH(slug)}?tab=layout`),
},
{
key: 'roles',
icon: <Users className="h-4 w-4" />,
label: t('events.quickActions.roles', 'Team & Rollen anpassen'),
description: t('events.quickActions.rolesDesc', 'Verwalte Moderatoren und Co-Leads.'),
onClick: () => navigate(ADMIN_EVENT_MEMBERS_PATH(slug)),
},
{
key: 'photobooth',
icon: <PlugZap className="h-4 w-4" />,
label: t('events.quickActions.photobooth', 'Photobooth anbinden'),
description: t('events.quickActions.photoboothDesc', 'FTP-Link aktivieren und Zugangsdaten kopieren.'),
onClick: () => navigate(ADMIN_EVENT_PHOTOBOOTH_PATH(slug)),
},
{
key: 'print',
icon: <Printer className="h-4 w-4" />,
label: t('events.quickActions.print', 'Layouts als PDF drucken'),
description: t('events.quickActions.printDesc', 'Exportiere QR-Sets für den Druck.'),
onClick: () => navigate(`${ADMIN_EVENT_INVITES_PATH(slug)}?tab=export`),
},
];
return (
<SectionCard className="space-y-4">
<SectionHeader
eyebrow={t('events.quickActions.badge', 'Schnellaktionen')}
title={t('events.quickActions.title', 'Schnellaktionen')}
description={t('events.quickActions.subtitle', 'Nutze die wichtigsten Schritte vor und während deines Events.')}
/>
<ActionGrid items={gridItems} columns={1} />
<div className="flex flex-wrap gap-2">
<Button onClick={() => { void onToggle(); }} disabled={busy} variant="outline" className="rounded-full border-rose-200 text-rose-600 hover:bg-rose-50">
{busy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <CheckCircle2 className="mr-2 h-4 w-4" />}
{t('events.quickActions.toggle', 'Status ändern')}
</Button>
</div>
</SectionCard>
);
}
function MetricsGrid({ metrics, stats }: { metrics: EventToolkit['metrics'] | null | undefined; stats: EventStats | null }) {
const { t } = useTranslation('management');
const cards = [
{
icon: <Camera className="h-5 w-5 text-emerald-500" />,
label: t('events.metrics.uploadsTotal', 'Uploads gesamt'),
value: metrics?.uploads_total ?? stats?.uploads_total ?? 0,
},
{
icon: <Camera className="h-5 w-5 text-sky-500" />,
label: t('events.metrics.uploads24h', 'Uploads (24h)'),
value: metrics?.uploads_24h ?? stats?.uploads_24h ?? 0,
},
{
icon: <AlertTriangle className="h-5 w-5 text-amber-500" />,
label: t('events.metrics.pending', 'Fotos in Moderation'),
value: metrics?.pending_photos ?? stats?.pending_photos ?? 0,
},
{
icon: <QrCode className="h-5 w-5 text-indigo-500" />,
label: t('events.metrics.activeInvites', 'Aktive Einladungen'),
value: metrics?.active_invites ?? 0,
},
];
return (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{cards.map((card) => (
<SectionCard key={card.label} className="p-4">
<div className="flex items-center gap-4">
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-slate-100 dark:bg-white/10">
{card.icon}
</span>
<div>
<p className="text-xs uppercase tracking-wide text-slate-500 dark:text-slate-400">{card.label}</p>
<p className="text-2xl font-semibold text-slate-900 dark:text-white">{card.value}</p>
</div>
</div>
</SectionCard>
))}
</div>
);
}
function InviteSummary({ invites, navigateToInvites }: { invites: EventToolkit['invites'] | undefined; navigateToInvites: () => void }) {
const { t } = useTranslation('management');
return (
<SectionCard className="space-y-3">
<SectionHeader
eyebrow={t('events.invites.badge', 'Einladungen')}
title={t('events.invites.title', 'QR-Einladungen')}
description={t('events.invites.subtitle', 'Behält aktive Einladungen und Layouts im Blick.')}
/>
<div className="space-y-3 text-sm text-slate-700 dark:text-slate-300">
<div className="flex gap-2 text-sm text-slate-900">
<Badge variant="outline" className="border-amber-200 text-amber-700 dark:border-amber-500/30 dark:text-amber-200">
{t('events.invites.activeCount', { defaultValue: '{{count}} aktiv', count: invites?.summary.active ?? 0 })}
</Badge>
<Badge variant="outline" className="border-amber-200 text-amber-700 dark:border-amber-500/30 dark:text-amber-200">
{t('events.invites.totalCount', { defaultValue: '{{count}} gesamt', count: invites?.summary.total ?? 0 })}
</Badge>
</div>
{invites?.items?.length ? (
<ul className="space-y-2 text-xs">
{invites.items.slice(0, 3).map((invite) => (
<li key={invite.id} className="rounded-lg border border-amber-100 bg-amber-50/70 p-3">
<p className="text-sm font-semibold text-slate-900">{invite.label ?? invite.url}</p>
<p className="truncate text-[11px] text-amber-700">{invite.url}</p>
</li>
))}
</ul>
) : (
<p className="text-xs text-slate-500">{t('events.invites.empty', 'Noch keine Einladungen erstellt.')}</p>
)}
<Button variant="outline" onClick={navigateToInvites} className="border-amber-200 text-amber-700 hover:bg-amber-50">
<QrCode className="mr-2 h-4 w-4" /> {t('events.invites.manage', 'Layouts & Einladungen verwalten')}
</Button>
</div>
</SectionCard>
);
}
function TaskOverviewCard({ tasks, navigateToTasks }: { tasks: EventToolkit['tasks'] | undefined; navigateToTasks: () => void }) {
const { t } = useTranslation('management');
return (
<SectionCard className="space-y-3">
<SectionHeader
eyebrow={t('events.tasks.badge', 'Aufgaben')}
title={t('events.tasks.title', 'Aktive Aufgaben')}
description={t('events.tasks.subtitle', 'Motiviere Gäste mit klaren Aufgaben & Highlights.')}
endSlot={(
<Badge variant="outline" className="border-pink-200 text-pink-600 dark:border-pink-500/30 dark:text-pink-200">
{t('events.tasks.summary', {
defaultValue: '{{completed}} von {{total}} erledigt',
completed: tasks?.summary.completed ?? 0,
total: tasks?.summary.total ?? 0,
})}
</Badge>
)}
/>
<div className="space-y-3 text-sm text-slate-700 dark:text-slate-300">
{tasks?.items?.length ? (
<div className="space-y-2">
{tasks.items.slice(0, 4).map((task) => (
<TaskRow key={task.id} task={task} />
))}
</div>
) : (
<p className="text-xs text-slate-500">{t('events.tasks.empty', 'Noch keine Aufgaben zugewiesen.')}</p>
)}
<Button variant="outline" onClick={navigateToTasks} className="border-pink-200 text-pink-600 hover:bg-pink-50">
<Sparkles className="mr-2 h-4 w-4" /> {t('events.tasks.manage', 'Aufgabenbereich öffnen')}
</Button>
</div>
</SectionCard>
);
}
function TaskRow({ task }: { task: EventToolkitTask }) {
return (
<div className="flex items-start justify-between rounded-lg border border-pink-100 bg-white/80 px-3 py-2 text-xs text-slate-600">
<div className="space-y-1">
<p className="text-sm font-semibold text-slate-900">{task.title}</p>
{task.description ? <p>{task.description}</p> : null}
</div>
<Badge variant={task.is_completed ? 'default' : 'outline'} className={task.is_completed ? 'bg-emerald-500/20 text-emerald-600' : 'border-pink-200 text-pink-600'}>
{task.is_completed ? 'Erledigt' : 'Offen'}
</Badge>
</div>
);
}
function BrandingMissionCard({
event,
invites,
emotions,
onOpenBranding,
onOpenCollections,
onOpenTasks,
onOpenEmotions,
}: {
event: TenantEvent;
invites?: EventToolkit['invites'];
emotions?: TenantEmotion[];
onOpenBranding: () => void;
onOpenCollections: () => void;
onOpenTasks: () => void;
onOpenEmotions: () => void;
}) {
const { t } = useTranslation('management');
const palette = extractBrandingPalette(event.settings);
const activeInvites = invites?.summary.active ?? 0;
const eventTypeId = event.event_type_id ?? event.event_type?.id ?? null;
const spotlightEmotions = React.useMemo(
() => filterEmotionsByEventType(emotions ?? [], eventTypeId).slice(0, 4),
[emotions, eventTypeId],
);
return (
<SectionCard className="space-y-4">
<SectionHeader
eyebrow={t('events.branding.badge', 'Branding & Story')}
title={t('events.branding.title', 'Branding & Mission Packs')}
description={t('events.branding.subtitle', 'Stimme Farben, Schriftarten und Aufgabenpakete aufeinander ab.')}
/>
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded-2xl border border-indigo-100 bg-indigo-50/80 p-4 text-sm text-indigo-900 shadow-inner shadow-indigo-100 dark:border-indigo-300/40 dark:bg-indigo-500/10 dark:text-indigo-100">
<p className="text-xs uppercase tracking-[0.3em]">{t('events.branding.brandingTitle', 'Branding')}</p>
<p className="mt-1 text-base font-semibold">{palette.font ?? t('events.branding.brandingFallback', 'Aktuelle Auswahl')}</p>
<p className="text-xs text-indigo-900/70 dark:text-indigo-100/80">
{t('events.branding.brandingCopy', 'Passe Farben & Schriftarten im Layout-Editor an.')}
</p>
<div className="mt-3 flex gap-2">
{(palette.colors.length ? palette.colors : ['#f472b6', '#fef3c7', '#312e81']).map((color) => (
<span
key={color}
className="h-10 w-10 rounded-xl border border-white/70 shadow"
style={{ backgroundColor: color }}
/>
))}
</div>
<Button size="sm" variant="secondary" className="mt-4 rounded-full bg-white/80 text-indigo-900 hover:bg-white" onClick={onOpenBranding}>
{t('events.branding.brandingCta', 'Branding anpassen')}
</Button>
</div>
<div className="rounded-2xl border border-rose-100 bg-rose-50/80 p-4 text-sm text-rose-900 shadow-inner shadow-rose-100 dark:border-rose-300/40 dark:bg-rose-500/10 dark:text-rose-100">
<p className="text-xs uppercase tracking-[0.3em]">{t('events.branding.collectionsTitle', 'Mission Packs')}</p>
<p className="mt-1 text-base font-semibold">
{event.event_type?.name ?? t('events.branding.collectionsFallback', 'Empfohlene Story')}
</p>
<p className="text-xs text-rose-900/70 dark:text-rose-100/80">
{t('events.branding.collectionsCopy', 'Importiere passende Kollektionen oder aktiviere Emotionen im Aufgabenbereich.')}
</p>
<div className="mt-3 flex flex-wrap gap-2 text-xs">
<Badge variant="outline" className="border-rose-200 text-rose-700 dark:border-rose-300/40 dark:text-rose-100">
{t('events.branding.collectionsActive', { defaultValue: '{{count}} aktive Links', count: activeInvites })}
</Badge>
<Badge variant="outline" className="border-rose-200 text-rose-700 dark:border-rose-300/40 dark:text-rose-100">
{t('events.branding.tasksCount', {
defaultValue: '{{count}} Aufgaben',
count: Number(event.tasks_count ?? 0),
})}
</Badge>
</div>
<div className="mt-4 rounded-xl border border-rose-100/80 bg-white/70 p-3 text-xs text-rose-900/80">
<p className="text-[10px] uppercase tracking-[0.3em] text-rose-400">
{t('events.branding.emotionsTitle', 'Emotionen')}
</p>
{spotlightEmotions.length ? (
<div className="mt-2 flex flex-wrap gap-2">
{spotlightEmotions.map((emotion) => (
<span
key={emotion.id}
className="flex items-center gap-1 rounded-full px-3 py-1 text-xs font-semibold shadow-sm"
style={{
backgroundColor: `${emotion.color ?? '#fecdd3'}33`,
color: emotion.color ?? '#be123c',
}}
>
{emotion.icon ? <span>{emotion.icon}</span> : null}
{emotion.name}
</span>
))}
</div>
) : (
<p className="mt-2 text-xs text-rose-900/70">
{t('events.branding.emotionsEmpty', 'Aktiviere Emotionen, um Aufgaben zu kategorisieren.')}
</p>
)}
<Button
size="sm"
variant="ghost"
className="mt-3 h-8 px-0 text-rose-700 hover:bg-rose-100/70"
onClick={onOpenEmotions}
>
{t('events.branding.emotionsCta', 'Emotionen verwalten')}
</Button>
</div>
<div className="mt-4 flex flex-wrap gap-2">
<Button size="sm" variant="outline" className="border-rose-200 text-rose-700 hover:bg-rose-100" onClick={onOpenTasks}>
{t('events.branding.collectionsManage', 'Aufgaben bearbeiten')}
</Button>
<Button size="sm" variant="ghost" className="text-rose-700 hover:bg-rose-100/80" onClick={onOpenCollections}>
{t('events.branding.collectionsImport', 'Mission Pack importieren')}
</Button>
</div>
</div>
</div>
</SectionCard>
);
}
function GalleryShareCard({
invites,
onManageInvites,
}: {
invites?: EventToolkit['invites'];
onManageInvites: () => void;
}) {
const { t } = useTranslation('management');
const primaryInvite = React.useMemo(
() => invites?.items?.find((invite) => invite.is_active) ?? invites?.items?.[0] ?? null,
[invites?.items],
);
const handleCopy = React.useCallback(async () => {
if (!primaryInvite?.url) {
return;
}
try {
await navigator.clipboard.writeText(primaryInvite.url);
toast.success(t('events.galleryShare.copied', 'Link kopiert'));
} catch (err) {
console.error(err);
toast.error(t('events.galleryShare.copyFailed', 'Konnte Link nicht kopieren'));
}
}, [primaryInvite, t]);
if (!primaryInvite) {
return (
<SectionCard className="space-y-3">
<SectionHeader
eyebrow={t('events.galleryShare.badge', 'Galerie')}
title={t('events.galleryShare.title', 'Galerie teilen')}
description={t('events.galleryShare.emptyDescription', 'Erstelle einen Einladungslink, um Fotos zu teilen.')}
/>
<Button onClick={onManageInvites} className="w-full rounded-full bg-brand-rose text-white shadow-md shadow-rose-300/40">
{t('events.galleryShare.createInvite', 'Einladung erstellen')}
</Button>
</SectionCard>
);
}
return (
<SectionCard className="space-y-3">
<SectionHeader
eyebrow={t('events.galleryShare.badge', 'Galerie')}
title={t('events.galleryShare.title', 'Galerie-Link & QR')}
description={t('events.galleryShare.description', 'Teile den Link nach dem Event oder lade QR-Karten herunter.')}
/>
<div className="rounded-2xl border border-slate-200 bg-white/90 p-4 text-sm text-slate-700 dark:border-white/10 dark:bg-white/5 dark:text-slate-200">
<p className="text-xs uppercase tracking-[0.3em] text-slate-500 dark:text-slate-400">
{primaryInvite.label ?? t('events.galleryShare.linkLabel', 'Standard-Link')}
</p>
<p className="mt-2 truncate text-base font-semibold text-slate-900 dark:text-white">{primaryInvite.url}</p>
<div className="mt-3 flex flex-wrap gap-2 text-xs">
<Badge variant="outline" className="border-slate-200 text-slate-600 dark:border-white/20 dark:text-white">
{t('events.galleryShare.scans', { defaultValue: '{{count}} Aufrufe', count: primaryInvite.usage_count })}
</Badge>
{typeof primaryInvite.usage_limit === 'number' && (
<Badge variant="outline" className="border-slate-200 text-slate-600 dark:border-white/20 dark:text-white">
{t('events.galleryShare.limit', { defaultValue: 'Limit {{count}}', count: primaryInvite.usage_limit })}
</Badge>
)}
</div>
<div className="mt-4 flex flex-wrap gap-2">
<Button size="sm" className="rounded-full bg-brand-rose px-4 text-white shadow-rose-400/40" onClick={handleCopy}>
{t('events.galleryShare.copy', 'Link kopieren')}
</Button>
<Button size="sm" variant="outline" onClick={onManageInvites}>
{t('events.galleryShare.manage', 'Layouts & QR öffnen')}
</Button>
</div>
</div>
</SectionCard>
);
}
function GalleryStatusCard({ gallery }: { gallery: GallerySummary }) {
const { t } = useTranslation('management');
const stateLabel =
gallery.state === 'expired'
? t('events.galleryStatus.stateExpired', 'Galerie abgelaufen')
: gallery.state === 'warning'
? t('events.galleryStatus.stateWarning', 'Galerie läuft bald ab')
: t('events.galleryStatus.stateOk', 'Galerie aktiv');
const expiresLabel =
gallery.expires_at && gallery.state !== 'unlimited'
? formatDate(gallery.expires_at)
: t('events.galleryStatus.noExpiry', 'Kein Ablaufdatum gesetzt');
const daysRemaining =
typeof gallery.days_remaining === 'number' && gallery.days_remaining >= 0
? gallery.days_remaining
: null;
return (
<SectionCard className="space-y-3">
<SectionHeader
eyebrow={t('events.galleryStatus.badge', 'Laufzeit')}
title={t('events.galleryStatus.title', 'Galerie-Laufzeit & Verfügbarkeit')}
description={t('events.galleryStatus.subtitle', 'Halte im Blick, wie lange Gäste noch auf die Galerie zugreifen können.')}
/>
<div className="flex flex-col gap-3 rounded-2xl border border-slate-200 bg-white/90 p-4 text-sm text-slate-700 dark:border-white/10 dark:bg-white/5 dark:text-slate-200 md:flex-row md:items-center md:justify-between">
<div className="space-y-1">
<p className="text-xs uppercase tracking-[0.3em] text-slate-500 dark:text-slate-400">
{t('events.galleryStatus.stateLabel', 'Status')}
</p>
<p className="text-sm font-semibold text-slate-900 dark:text-white">{stateLabel}</p>
<p className="text-xs text-slate-600 dark:text-slate-300">
{t('events.galleryStatus.expiresAt', {
defaultValue: 'Ablaufdatum: {{date}}',
date: expiresLabel,
})}
</p>
</div>
<div className="space-y-1">
<p className="text-xs uppercase tracking-[0.3em] text-slate-500 dark:text-slate-400">
{t('events.galleryStatus.daysLabel', 'Verbleibende Tage')}
</p>
<p className="text-2xl font-semibold text-slate-900 dark:text-white">
{daysRemaining !== null ? daysRemaining : '—'}
</p>
</div>
</div>
</SectionCard>
);
}
function extractBrandingPalette(
settings: TenantEvent['settings'],
): { colors: string[]; font?: string } {
const colors: string[] = [];
let font: string | undefined;
if (settings && typeof settings === 'object') {
const brandingSource =
(settings as Record<string, unknown>).branding && typeof (settings as Record<string, unknown>).branding === 'object'
? (settings as Record<string, unknown>).branding
: settings;
const candidateKeys = ['primary_color', 'secondary_color', 'accent_color', 'background_color', 'color'];
candidateKeys.forEach((key) => {
const value = (brandingSource as Record<string, unknown>)[key];
if (typeof value === 'string' && value.trim()) {
colors.push(value);
}
});
const fontKeys = ['font_family', 'font', 'heading_font'];
fontKeys.some((key) => {
const value = (brandingSource as Record<string, unknown>)[key];
if (typeof value === 'string' && value.trim()) {
font = value;
return true;
}
return false;
});
}
return { colors, font };
}
// Pending photos summary moved to the dedicated Live/Photos view.
function RecentUploadsCard({ slug, photos }: { slug: string; photos: TenantPhoto[] }) {
const { t } = useTranslation('management');
const [entries, setEntries] = React.useState(photos);
const [updatingId, setUpdatingId] = React.useState<number | null>(null);
React.useEffect(() => {
setEntries(photos);
}, [photos]);
const handleVisibility = async (photo: TenantPhoto, visible: boolean) => {
setUpdatingId(photo.id);
try {
const updated = await updatePhotoVisibility(slug, photo.id, visible);
setEntries((prev) => prev.map((item) => (item.id === photo.id ? updated : item)));
} catch (err) {
toast.error(
isAuthError(err)
? t('events.photos.errorAuth', 'Session abgelaufen. Bitte erneut anmelden.')
: t('events.photos.errorVisibility', 'Sichtbarkeit konnte nicht geändert werden.'),
);
} finally {
setUpdatingId(null);
}
};
return (
<SectionCard className="space-y-3">
<SectionHeader
eyebrow={t('events.photos.recentBadge', 'Uploads')}
title={t('events.photos.recentTitle', 'Neueste Uploads')}
description={t('events.photos.recentSubtitle', 'Halte Ausschau nach Highlight-Momenten der Gäste.')}
/>
<div className="space-y-2 text-sm text-slate-700 dark:text-slate-300">
{entries.length ? (
<div className="grid gap-3 sm:grid-cols-2">
{entries.slice(0, 6).map((photo) => {
const hidden = photo.status === 'hidden';
return (
<div key={photo.id} className="rounded-xl border border-slate-200 bg-white/90 p-2">
<div className="relative overflow-hidden rounded-lg">
<img
src={photo.thumbnail_url ?? photo.url ?? undefined}
alt={photo.caption ?? 'Foto'}
className={`h-28 w-full object-cover ${hidden ? 'opacity-60' : ''}`}
/>
{photo.is_featured ? (
<span className="absolute left-2 top-2 rounded-full bg-pink-500/90 px-2 py-0.5 text-[10px] font-semibold text-white">
Highlight
</span>
) : null}
</div>
<div className="mt-2 flex flex-wrap gap-2 text-[11px] text-slate-500">
<Badge variant="outline"> {photo.likes_count}</Badge>
<Badge variant="outline">{photo.uploader_name ?? 'Gast'}</Badge>
</div>
<div className="mt-2 flex flex-wrap gap-2 text-xs">
<Button size="sm" variant="outline" disabled={updatingId === photo.id} onClick={() => handleVisibility(photo, hidden)}>
{hidden ? t('events.photos.show', 'Einblenden') : t('events.photos.hide', 'Verstecken')}
</Button>
<Button
size="sm"
variant={photo.is_featured ? 'secondary' : 'outline'}
disabled={updatingId === photo.id}
onClick={() => handleFeature(photo, !photo.is_featured)}
>
{photo.is_featured ? t('events.photos.unfeature', 'Highlight entfernen') : t('events.photos.feature', 'Highlight')}
</Button>
</div>
</div>
);
})}
</div>
) : (
<p className="text-xs text-slate-500">{t('events.photos.recentEmpty', 'Noch keine neuen Uploads.')}</p>
)}
</div>
</SectionCard>
);
}
function FeedbackCard({ slug }: { slug: string }) {
const { t } = useTranslation('management');
const [sentiment, setSentiment] = React.useState<'positive' | 'neutral' | 'negative' | null>(null);
const [message, setMessage] = React.useState('');
const [busy, setBusy] = React.useState(false);
const [submitted, setSubmitted] = React.useState(false);
const [error, setError] = React.useState<string | undefined>(undefined);
const copy = {
positive: t('events.feedback.positive', 'Super Lauf!'),
neutral: t('events.feedback.neutral', 'Läuft'),
negative: t('events.feedback.negative', 'Braucht Support'),
};
return (
<SectionCard className="space-y-4">
<SectionHeader
eyebrow={t('events.feedback.badge', 'Feedback')}
title={t('events.feedback.title', 'Wie läuft dein Event?')}
description={t('events.feedback.subtitle', 'Feedback hilft uns, neue Features zu priorisieren.')}
/>
<div className="space-y-4">
{error && (
<Alert variant="destructive">
<AlertTitle>{t('events.feedback.errorTitle', 'Feedback konnte nicht gesendet werden.')}</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="flex flex-wrap gap-2">
{(Object.keys(copy) as Array<'positive' | 'neutral' | 'negative'>).map((key) => (
<Button
key={key}
type="button"
variant={sentiment === key ? 'default' : 'outline'}
className={sentiment === key ? 'bg-slate-900 text-white' : 'border-slate-300 text-slate-700'}
onClick={() => setSentiment(key)}
>
{copy[key]}
</Button>
))}
</div>
<textarea
value={message}
onChange={(event) => setMessage(event.target.value)}
placeholder={t('events.feedback.placeholder', 'Optional: Lass uns wissen, was gut funktioniert oder wo du Unterstützung brauchst.')}
className="min-h-[120px] w-full rounded-md border border-slate-200 bg-white p-3 text-sm text-slate-700 outline-none focus:border-slate-400 focus:ring-1 focus:ring-slate-300"
/>
<Button
type="button"
className="bg-slate-900 text-white hover:bg-slate-800"
disabled={busy || submitted}
onClick={async () => {
if (busy || submitted) return;
setBusy(true);
setError(undefined);
try {
await submitTenantFeedback({
category: 'event_workspace',
event_slug: slug,
sentiment: sentiment ?? undefined,
message: message.trim() ? message.trim() : undefined,
});
setSubmitted(true);
} catch (err) {
setError(isAuthError(err)
? t('events.feedback.authError', 'Deine Session ist abgelaufen. Bitte melde dich erneut an.')
: t('events.feedback.genericError', 'Feedback konnte nicht gesendet werden.'));
} finally {
setBusy(false);
}
}}
>
{busy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <MessageSquare className="mr-2 h-4 w-4" />} {submitted ? t('events.feedback.submitted', 'Danke!') : t('events.feedback.submit', 'Feedback senden')}
</Button>
</div>
</SectionCard>
);
}
function GuestNotificationStatsCard({ notifications }: { notifications?: EventToolkit['notifications'] }) {
const { t } = useTranslation('management');
if (!notifications || notifications.summary.total === 0) {
return (
<div className="flex h-full flex-col items-center justify-center rounded-2xl border border-dashed border-slate-200 bg-slate-50 px-4 py-6 text-center text-sm text-slate-500">
<MessageSquare className="mb-3 h-6 w-6 text-slate-400" aria-hidden />
<p>{t('events.notifications.statsEmpty', 'Noch keine Nachrichten versendet starte mit einem Broadcast.')}</p>
</div>
);
}
const { summary, recent } = notifications;
const topTypes = Object.entries(summary.by_type ?? {})
.sort(([, a], [, b]) => (b ?? 0) - (a ?? 0))
.slice(0, 3);
return (
<div className="space-y-4">
<div className="grid gap-3 sm:grid-cols-3">
<StatPill
icon={<Bell className="h-4 w-4" />}
label={t('events.notifications.statsTotal', 'Gesendete Nachrichten')}
value={summary.total}
/>
<StatPill
icon={<MessageSquare className="h-4 w-4" />}
label={t('events.notifications.statsBroadcasts', 'Broadcasts')}
value={summary.broadcasts.total}
sublabel={summary.broadcasts.last_title ?? t('events.notifications.statsBroadcastsEmpty', 'Noch kein Broadcast')}
/>
<StatPill
icon={<Clock3 className="h-4 w-4" />}
label={t('events.notifications.statsLastSent', 'Letzte Sendung')}
value={summary.last_sent_at ? formatRelativeDateTime(summary.last_sent_at) : t('events.notifications.never', 'Noch nie')}
/>
</div>
<div className="rounded-2xl border border-slate-200 bg-white/80 p-4">
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">
{t('events.notifications.topTypes', 'Beliebteste Typen')}
</p>
{topTypes.length > 0 ? (
<div className="mt-3 flex flex-wrap gap-2">
{topTypes.map(([type, count]) => (
<Badge key={type} variant="secondary" className="gap-2">
{getNotificationTypeLabel(type, t)}
<span className="rounded-full bg-slate-200 px-2 text-xs font-semibold text-slate-700">{count as number}</span>
</Badge>
))}
</div>
) : (
<p className="mt-2 text-sm text-slate-500">{t('events.notifications.topTypesEmpty', 'Noch keine Verteilung verfügbar.')}</p>
)}
</div>
<div className="rounded-2xl border border-slate-200 bg-white/80 p-4">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">
{t('events.notifications.recent', 'Letzte Nachrichten')}
</p>
<span className="text-xs text-slate-400">{recent.length} {t('events.notifications.recentCount', 'Einträge')}</span>
</div>
{recent.length === 0 ? (
<p className="mt-3 text-sm text-slate-500">{t('events.notifications.recentEmpty', 'Noch keine Historie vorhanden.')}</p>
) : (
<ul className="mt-3 space-y-3">
{recent.map((item) => (
<li key={item.id} className="rounded-xl border border-slate-100 bg-white/70 px-3 py-2">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-semibold text-slate-900">{item.title || t('events.notifications.untitled', 'Ohne Titel')}</p>
<p className="text-xs text-slate-500">
{formatRelativeDateTime(item.created_at)} · {getAudienceLabel(item.audience_scope, t)}
</p>
</div>
<Badge variant="outline" className="text-xs capitalize">
{getNotificationTypeLabel(item.type, t)}
</Badge>
</div>
</li>
))}
</ul>
)}
</div>
</div>
);
}
function StatPill({ icon, label, value, sublabel }: { icon: React.ReactNode; label: string; value: string | number; sublabel?: string | null }) {
return (
<div className="rounded-2xl border border-slate-200 bg-white/80 p-4">
<div className="flex items-center gap-2 text-slate-500">
<span className="rounded-full bg-slate-100 p-2 text-slate-600">{icon}</span>
<p className="text-xs font-semibold uppercase tracking-wide">{label}</p>
</div>
<p className="mt-2 text-2xl font-semibold text-slate-900">{value}</p>
{sublabel && <p className="text-xs text-slate-500">{sublabel}</p>}
</div>
);
}
function formatRelativeDateTime(value?: string | null): string {
if (!value) {
return '—';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '—';
}
return date.toLocaleString(undefined, {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
function getNotificationTypeLabel(type: string, t: ReturnType<typeof useTranslation>['t']): string {
switch (type) {
case 'broadcast':
return t('events.notifications.types.broadcast', 'Broadcast');
case 'upload_alert':
return t('events.notifications.types.upload', 'Upload-Status');
case 'support_tip':
return t('events.notifications.types.support', 'Support-Tipp');
case 'feedback_request':
return t('events.notifications.types.feedback', 'Feedback');
case 'achievement_major':
return t('events.notifications.types.achievement', 'Achievement');
case 'photo_activity':
return t('events.notifications.types.activity', 'Aktivität');
default:
return t('events.notifications.types.generic', 'System');
}
}
function getAudienceLabel(scope: string, t: ReturnType<typeof useTranslation>['t']): string {
if (scope === 'guest') {
return t('events.notifications.audienceGuest', 'Gezielte Gäste');
}
return t('events.notifications.audienceAll', 'Alle Gäste');
}
function InfoRow({ icon, label, value }: { icon: React.ReactNode; label: string; value: React.ReactNode }) {
return (
<div className="flex items-start gap-3 rounded-lg border border-slate-100 bg-white/70 px-3 py-2 text-sm text-slate-700">
<span className="mt-0.5 flex h-8 w-8 items-center justify-center rounded-full bg-slate-100 text-slate-600">{icon}</span>
<div className="space-y-1">
<p className="text-xs uppercase tracking-wide text-slate-500">{label}</p>
<p className="text-sm font-semibold text-slate-900">{value || '—'}</p>
</div>
</div>
);
}
function getStatusLabel(event: TenantEvent, t: ReturnType<typeof useTranslation>['t']): string {
if (event.status === 'published') {
return t('events.status.published', 'Veröffentlicht');
}
if (event.status === 'archived') {
return t('events.status.archived', 'Archiviert');
}
return t('events.status.draft', 'Entwurf');
}
function formatDate(value: string | null | undefined): string {
if (!value) return '—';
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '—';
}
return date.toLocaleDateString(undefined, { day: '2-digit', month: '2-digit', year: 'numeric' });
}
function resolveEventType(event: TenantEvent): string {
if (event.event_type?.name) {
if (typeof event.event_type.name === 'string') {
return event.event_type.name;
}
const translations = event.event_type.name as Record<string, string>;
return translations.de ?? translations.en ?? Object.values(translations)[0] ?? '—';
}
return '—';
}
function AlertList({ alerts }: { alerts: string[] }) {
if (!alerts.length) {
return null;
}
return (
<div className="space-y-2">
{alerts.map((alert, index) => (
<Alert key={`workspace-alert-${index}`} variant="default">
<AlertTitle>{alert}</AlertTitle>
</Alert>
))}
</div>
);
}
function CalendarIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 text-slate-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
);
}
function WorkspaceSkeleton() {
return (
<div className="space-y-6">
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.6fr)_minmax(0,0.6fr)]">
<SkeletonCard />
<SkeletonCard />
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{Array.from({ length: 4 }).map((_, index) => (
<SkeletonCard key={`metric-skeleton-${index}`} />
))}
</div>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.4fr)_minmax(0,0.8fr)]">
<SkeletonCard />
<SkeletonCard />
</div>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.4fr)_minmax(0,0.8fr)]">
<SkeletonCard />
<SkeletonCard />
</div>
<SkeletonCard />
</div>
);
}
function SkeletonCard() {
return <div className="h-40 animate-pulse rounded-2xl bg-gradient-to-r from-slate-100 via-white to-slate-100" />;
}