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

1550 lines
64 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 } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
ArrowLeft,
Bell,
Camera,
CheckCircle2,
Circle,
Clock3,
Loader2,
MessageSquare,
Printer,
QrCode,
PlugZap,
Smile,
Sparkles,
ShoppingCart,
Menu,
Users,
AlertTriangle,
} 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,
ADMIN_EVENT_BRANDING_PATH,
buildEngagementTabPath,
} from '../constants';
import { buildEventTabs } from '../lib/eventTabs';
import { formatEventDate } from '../lib/events';
import {
SectionCard,
SectionHeader,
} 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 { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet';
import { filterEmotionsByEventType } from '../lib/emotions';
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() {
const { slug: slugParam } = useParams<{ slug?: string }>();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
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 = t('events.workspace.detailSubtitle', 'Behalte Status, Aufgaben und QR-Codes deines Events im Blick.');
const currentTabKey = 'overview';
const eventTabs = React.useMemo(() => {
if (!event) return [];
const translateMenu = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const counts = {
photos: stats?.uploads_total ?? event.photo_count ?? undefined,
tasks: toolkitData?.tasks?.summary?.total ?? event.tasks_count ?? undefined,
};
return buildEventTabs(event, translateMenu, counts);
}, [event, stats?.uploads_total, toolkitData?.tasks?.summary?.total, t]);
const brandingAllowed = React.useMemo(() => {
const settings = (event?.settings ?? {}) as Record<string, unknown>;
return Boolean(settings.branding_allowed ?? true);
}, [event]);
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 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={currentTabKey}
>
{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">
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-3 rounded-3xl border border-slate-200 bg-white/85 p-4 shadow-sm dark:border-white/10 dark:bg-white/5">
<div className="space-y-2">
<p className="text-[10px] font-semibold uppercase tracking-[0.35em] text-rose-500 dark:text-rose-200">
{t('events.workspace.hero.badge', 'Event')}
</p>
<h1 className="text-xl font-semibold text-slate-900 dark:text-white">{resolveName(event.name)}</h1>
<p className="text-sm text-slate-600 dark:text-slate-300">
{t('events.workspace.hero.description', 'Konzentriere dich auf Aufgaben, Moderation und QR-Code für dieses Event.')}
</p>
<div className="flex flex-wrap gap-2 text-xs">
<Badge variant="secondary" className="gap-1 rounded-full bg-emerald-100 text-emerald-800 dark:bg-emerald-500/20 dark:text-emerald-100">
<Sparkles className="h-3.5 w-3.5" />
{getStatusLabel(event, t)}
</Badge>
<Badge variant="outline" className="gap-1 rounded-full border-slate-200 text-slate-700 dark:border-white/10 dark:text-white">
<CalendarIcon />
{formatDate(event.event_date)}
</Badge>
<Badge variant="outline" className="gap-1 rounded-full border-slate-200 text-slate-700 dark:border-white/10 dark:text-white">
<Circle className={`h-3 w-3 ${event.is_active ? 'text-emerald-500' : 'text-slate-400'}`} />
{t('events.workspace.hero.liveBadge', 'Live?')} {event.is_active ? t('events.workspace.activeYes', 'Ja') : t('events.workspace.activeNo', 'Nein')}
</Badge>
<Badge variant="outline" className="gap-1 rounded-full border-slate-200 text-slate-700 dark:border-white/10 dark:text-white">
<Smile className="h-3.5 w-3.5 text-rose-500" />
{resolveEventType(event)}
</Badge>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline" size="sm" 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>
<Button variant="outline" size="sm" 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>
<Button
variant="outline"
size="sm"
onClick={() => { void handleToggle(); }}
disabled={busy}
className="rounded-full border-slate-200"
>
{busy ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : event.is_active ? (
<CheckCircle2 className="mr-2 h-4 w-4 text-emerald-500" />
) : (
<Circle className="mr-2 h-4 w-4 text-slate-500" />
)}
{event.is_active ? t('events.workspace.actions.pause', 'Event pausieren') : t('events.workspace.actions.activate', 'Event aktivieren')}
</Button>
</div>
</div>
<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>
{brandingAllowed ? (
<BrandingMissionCard
event={event}
invites={toolkitData?.invites}
emotions={emotions}
onOpenBranding={() => navigate(ADMIN_EVENT_BRANDING_PATH(event.slug))}
onOpenCollections={() => navigate(buildEngagementTabPath('collections'))}
onOpenTasks={() => navigate(ADMIN_EVENT_TASKS_PATH(event.slug))}
onOpenEmotions={() => navigate(buildEngagementTabPath('emotions'))}
/>
) : null}
{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}
<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} />
<QuickActionsMenu slug={event.slug} navigate={navigate} />
</div>
</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';
}
type RecapContentProps = {
event: TenantEvent;
stats: EventStats;
busy: boolean;
onToggleEvent: () => void;
onExtendGallery: () => void;
};
function RecapContent({ event, stats, busy, onToggleEvent, onExtendGallery }: RecapContentProps) {
const { t } = useTranslation('management');
const navigate = useNavigate();
const galleryExpiresAt = event.package?.expires_at ?? event.limits?.gallery?.expires_at ?? null;
const galleryStatusLabel = event.is_active
? t('events.recap.galleryOpen', 'Galerie geöffnet')
: t('events.recap.galleryClosed', 'Galerie geschlossen');
const counts = {
photos: stats.uploads_total ?? stats.total ?? 0,
pending: stats.pending_photos ?? 0,
likes: stats.likes_total ?? stats.likes ?? 0,
};
return (
<div className="space-y-6">
<div className="grid gap-4 lg:grid-cols-2">
<div className="rounded-3xl border border-slate-200 bg-white/85 p-5 shadow-sm dark:border-white/10 dark:bg-white/5">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-emerald-500">
{t('events.recap.galleryTitle', 'Galerie-Status')}
</p>
<p className="mt-1 text-lg font-semibold text-slate-900 dark:text-white">{galleryStatusLabel}</p>
<p className="text-sm text-slate-600 dark:text-slate-300">
{t('events.recap.galleryCounts', '{{photos}} Fotos, {{pending}} offen, {{likes}} Likes', counts)}
</p>
</div>
<Badge variant={event.is_active ? 'default' : 'outline'} className="bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-100">
{event.is_active ? t('events.recap.open', 'Offen') : t('events.recap.closed', 'Geschlossen')}
</Badge>
</div>
<div className="mt-4 flex flex-wrap gap-2">
<Button size="sm" variant={event.is_active ? 'secondary' : 'default'} disabled={busy} onClick={onToggleEvent}>
{busy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Sparkles className="mr-2 h-4 w-4" />}
{event.is_active ? t('events.recap.closeGallery', 'Galerie schließen') : t('events.recap.openGallery', 'Galerie öffnen')}
</Button>
<Button size="sm" variant="outline" onClick={() => navigate(ADMIN_EVENT_PHOTOS_PATH(event.slug))}>
<Camera className="mr-2 h-4 w-4" />
{t('events.recap.moderate', 'Uploads ansehen')}
</Button>
</div>
{event.public_url ? (
<div className="mt-4 flex items-center gap-2 text-sm text-slate-600 dark:text-slate-300">
<span className="truncate" title={event.public_url}>{event.public_url}</span>
<Button size="icon" variant="ghost" className="h-8 w-8" onClick={() => navigator.clipboard.writeText(event.public_url!)}>
<QrCode className="h-4 w-4" />
</Button>
</div>
) : null}
</div>
<div className="rounded-3xl border border-slate-200 bg-white/85 p-5 shadow-sm dark:border-white/10 dark:bg-white/5">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-indigo-500">
{t('events.recap.exportTitle', 'Export & Backup')}
</p>
<p className="mt-1 text-lg font-semibold text-slate-900 dark:text-white">
{t('events.recap.exportCopy', 'Alle Assets sichern')}
</p>
<p className="text-sm text-slate-600 dark:text-slate-300">
{t('events.recap.exportHint', 'Zip/CSV Export und Backup anstoßen.')}
</p>
</div>
<Badge variant="outline" className="border-indigo-200 text-indigo-700 dark:border-indigo-800 dark:text-indigo-200">
{t('events.recap.backup', 'Backup')}
</Badge>
</div>
<div className="mt-4 flex flex-wrap gap-2">
<Button size="sm" onClick={() => navigate(ADMIN_EVENT_PHOTOS_PATH(event.slug))}>
<ArrowLeft className="mr-2 h-4 w-4 rotate-180" />
{t('events.recap.exportAll', 'Alles exportieren')}
</Button>
<Button size="sm" variant="outline" onClick={() => navigate(ADMIN_EVENT_PHOTOS_PATH(event.slug))}>
<Printer className="mr-2 h-4 w-4" />
{t('events.recap.exportHighlights', 'Highlights exportieren')}
</Button>
</div>
</div>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<div className="rounded-3xl border border-slate-200 bg-white/85 p-5 shadow-sm dark:border-white/10 dark:bg-white/5">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-amber-500">
{t('events.recap.retentionTitle', 'Aufbewahrung & Verlängerung')}
</p>
<p className="mt-1 text-lg font-semibold text-slate-900 dark:text-white">
{galleryExpiresAt
? t('events.recap.expiresAt', 'Läuft ab am {{date}}', { date: formatEventDate(galleryExpiresAt, undefined) })
: t('events.recap.noExpiry', 'Ablaufdatum nicht gesetzt')}
</p>
<p className="text-sm text-slate-600 dark:text-slate-300">
{t('events.recap.retentionHint', 'Verlängere die Galerie-Laufzeit mit einem Add-on. Verlängerungen addieren sich.')}
</p>
</div>
<Badge variant="outline" className="border-amber-200 text-amber-700 dark:border-amber-800 dark:text-amber-100">
{t('events.recap.expiry', 'Ablauf')}
</Badge>
</div>
<div className="mt-4 flex flex-wrap gap-2">
<Button size="sm" variant="default" onClick={onExtendGallery}>
<Clock3 className="mr-2 h-4 w-4" />
{t('events.recap.extend30', '+30 Tage verlängern')}
</Button>
<Button size="sm" variant="outline" onClick={() => navigate(ADMIN_EVENT_EDIT_PATH(event.slug))}>
{t('events.recap.archive', 'Archivieren/Löschen')}
</Button>
</div>
</div>
<div className="rounded-3xl border border-slate-200 bg-white/85 p-5 shadow-sm dark:border-white/10 dark:bg-white/5">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-rose-500">
{t('events.recap.commsTitle', 'Kommunikation')}
</p>
<p className="mt-1 text-lg font-semibold text-slate-900 dark:text-white">
{t('events.recap.commsCopy', 'Kein Gast-Newsletter aktiv')}
</p>
<p className="text-sm text-slate-600 dark:text-slate-300">
{t('events.recap.commsHint', 'Push wirkt vor allem live. Teile den Link manuell mit deinem Team oder auf Social Media.')}
</p>
</div>
<Badge variant="outline" className="border-rose-200 text-rose-700 dark:border-rose-800 dark:text-rose-100">
{t('events.recap.manual', 'Manuell')}
</Badge>
</div>
{event.public_url ? (
<div className="mt-4 flex items-center gap-2 rounded-2xl border border-dashed border-slate-200 bg-white/70 p-3 text-sm text-slate-700 dark:border-white/10 dark:bg-white/10 dark:text-slate-200">
<div className="flex-1 truncate" title={event.public_url}>{event.public_url}</div>
<Button size="sm" variant="ghost" onClick={() => navigator.clipboard.writeText(event.public_url!)}>
{t('events.recap.copyLink', 'Link kopieren')}
</Button>
</div>
) : null}
</div>
</div>
</div>
);
}
function QuickActionsMenu({ slug, navigate }: { slug: string; navigate: ReturnType<typeof useNavigate> }) {
const { t } = useTranslation('management');
const actions = [
{ key: 'photos', icon: <Camera className="h-4 w-4" />, label: t('events.quickActions.moderate', 'Fotos moderieren'), onClick: () => navigate(ADMIN_EVENT_PHOTOS_PATH(slug)) },
{ key: 'tasks', icon: <Sparkles className="h-4 w-4" />, label: t('events.quickActions.tasks', 'Aufgaben bearbeiten'), onClick: () => navigate(ADMIN_EVENT_TASKS_PATH(slug)) },
{ key: 'invites', icon: <QrCode className="h-4 w-4" />, label: t('events.quickActions.invites', 'Layouts & QR verwalten'), 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'), onClick: () => navigate(ADMIN_EVENT_MEMBERS_PATH(slug)) },
{ key: 'photobooth', icon: <PlugZap className="h-4 w-4" />, label: t('events.quickActions.photobooth', 'Photobooth anbinden'), 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'), onClick: () => navigate(`${ADMIN_EVENT_INVITES_PATH(slug)}?tab=export`) },
];
return (
<Sheet>
<SheetTrigger asChild>
<Button
size="icon"
className="fixed right-6 z-40 h-12 w-12 rounded-full bg-rose-500 text-white shadow-xl shadow-rose-300/50 hover:bg-rose-600 focus-visible:ring-2 focus-visible:ring-rose-300"
style={{ bottom: '90px' }}
aria-label={t('events.quickActions.badge', 'Schnellaktionen')}
>
<Menu className="h-5 w-5" />
</Button>
</SheetTrigger>
<SheetContent side="bottom" className="rounded-t-3xl border border-slate-200 bg-white/95 p-4 text-slate-900 dark:border-white/10 dark:bg-slate-950">
<SheetHeader>
<SheetTitle className="text-lg font-semibold">{t('events.quickActions.title', 'Starte in die Moderation')}</SheetTitle>
</SheetHeader>
<div className="mt-4 grid gap-2">
{actions.map((action) => (
<Button
key={action.key}
variant="ghost"
className="flex items-center justify-start gap-3 rounded-2xl border border-slate-200 bg-white/90 text-left text-sm font-semibold text-slate-800 hover:border-rose-200 hover:bg-rose-50 dark:border-white/10 dark:bg-white/5 dark:text-white"
onClick={action.onClick}
>
{action.icon}
{action.label}
</Button>
))}
</div>
</SheetContent>
</Sheet>
);
}
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', 'QR-Codes')}
title={t('events.invites.title', 'QR-Codes & Layouts')}
description={t('events.invites.subtitle', 'Behält aktive QR-Codes 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 QR-Codes 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', 'QR-Codes & Layouts 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 }) {
const { t } = useTranslation('management');
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 ? t('events.tasks.status.completed', 'Done') : t('events.tasks.status.open', 'Open')}
</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 QR-Codeslink, 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', 'QR-Code 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 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 {
const type = event.event_type;
if (!type) {
return '—';
}
if (type.name_translations && Object.keys(type.name_translations).length > 0) {
return type.name_translations.de ?? type.name_translations.en ?? Object.values(type.name_translations)[0] ?? type.name ?? '—';
}
if (typeof type.name === 'string' && type.name.trim().length > 0) {
return type.name;
}
if (type.name && typeof type.name === 'object') {
const translations = 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" />;
}