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

931 lines
42 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, Camera, Clock3, Loader2, MessageSquare, Printer, ShoppingCart, Sparkles } 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 { Switch } from '@/components/ui/switch';
import { Checkbox } from '@/components/ui/checkbox';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { AdminLayout } from '../components/AdminLayout';
import {
createEventAddonCheckout,
EventQrInvite,
EventStats,
getEvent,
getEventStats,
getEventQrInvites,
getAddonCatalog,
type EventAddonCatalogItem,
TenantEvent,
toggleEvent,
submitTenantFeedback,
} from '../api';
import { updateEvent } from '../api';
import { buildEventTabs } from '../lib/eventTabs';
import { formatEventDate } from '../lib/events';
import { isAuthError } from '../auth/tokens';
import { getApiErrorMessage } from '../lib/apiError';
import {
ADMIN_EVENT_EDIT_PATH,
ADMIN_EVENT_PHOTOS_PATH,
ADMIN_EVENTS_PATH,
} from '../constants';
export default function EventRecapPage() {
const { slug: slugParam } = useParams<{ slug?: string }>();
const navigate = useNavigate();
const { t } = useTranslation('management');
const [searchParams, setSearchParams] = useSearchParams();
const slug = slugParam ?? null;
const [event, setEvent] = React.useState<TenantEvent | null>(null);
const [stats, setStats] = React.useState<EventStats | null>(null);
const [loading, setLoading] = React.useState(true);
const [busy, setBusy] = React.useState(false);
const [settingsBusy, setSettingsBusy] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const [joinTokens, setJoinTokens] = React.useState<EventQrInvite[]>([]);
const [addonsCatalog, setAddonsCatalog] = React.useState<EventAddonCatalogItem[]>([]);
const [addonBusyKey, setAddonBusyKey] = React.useState<string | null>(null);
const loadEventData = React.useCallback(async () => {
if (!slug) {
setLoading(false);
setError(t('events.errors.missingSlug', 'Kein Event ausgewählt.'));
return;
}
setLoading(true);
setError(null);
try {
const [eventData, statsData, invites, addons] = await Promise.all([
getEvent(slug),
getEventStats(slug),
getEventQrInvites(slug),
getAddonCatalog(),
]);
setEvent(eventData);
setStats(statsData);
setJoinTokens(invites);
setAddonsCatalog(addons);
} catch (err) {
if (!isAuthError(err)) {
setError(getApiErrorMessage(err, t('events.errors.loadFailed', 'Event konnte nicht geladen werden.')));
}
} finally {
setLoading(false);
}
}, [slug, t]);
React.useEffect(() => {
void loadEventData();
}, [loadEventData]);
React.useEffect(() => {
if (!searchParams.get('addon_success')) {
return;
}
toast(t('events.success.addonApplied', 'Add-on angewendet. Limits aktualisieren sich in Kürze.'));
void loadEventData();
const next = new URLSearchParams(searchParams);
next.delete('addon_success');
setSearchParams(next, { replace: true });
}, [loadEventData, searchParams, setSearchParams, t]);
const handleToggleEvent = React.useCallback(async () => {
if (!slug) return;
setBusy(true);
setError(null);
try {
const updated = await toggleEvent(slug);
setEvent(updated);
} catch (err) {
if (!isAuthError(err)) {
setError(getApiErrorMessage(err, t('events.errors.toggleFailed', 'Status konnte nicht angepasst werden.')));
}
} finally {
setBusy(false);
}
}, [slug, t]);
const handleAddonCheckout = React.useCallback(async (addonKey: string) => {
if (!slug) return;
setAddonBusyKey(addonKey);
setError(null);
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) {
if (!isAuthError(err)) {
toast(getApiErrorMessage(err, t('events.errors.checkoutFailed', 'Add-on Checkout fehlgeschlagen.')));
}
} finally {
setAddonBusyKey(null);
}
}, [slug, t]);
const handleArchive = React.useCallback(async () => {
if (!slug || !event) return;
setArchiveBusy(true);
setError(null);
try {
const updated = await updateEvent(slug, { status: 'archived', is_active: false });
setEvent(updated);
setArchiveOpen(false);
toast.success(t('events.recap.archivedSuccess', 'Event archiviert. Galerie ist geschlossen.'));
} catch (err) {
if (!isAuthError(err)) {
setError(getApiErrorMessage(err, t('events.errors.archiveFailed', 'Archivierung fehlgeschlagen.')));
}
} finally {
setArchiveBusy(false);
}
}, [event, slug, t]);
const handleToggleSetting = React.useCallback(async (key: 'guest_downloads_enabled' | 'guest_sharing_enabled', value: boolean) => {
if (!slug || !event) return;
setSettingsBusy(true);
setError(null);
try {
const updated = await updateEvent(slug, {
settings: {
...(event.settings ?? {}),
[key]: value,
},
});
setEvent(updated);
} catch (err) {
if (!isAuthError(err)) {
setError(getApiErrorMessage(err, t('events.errors.toggleFailed', 'Einstellung konnte nicht gespeichert werden.')));
}
} finally {
setSettingsBusy(false);
}
}, [event, slug, t]);
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.')}
>
<Alert variant="destructive">
<AlertTitle>{t('events.errors.notFoundTitle', 'Event nicht gefunden')}</AlertTitle>
<AlertDescription>{t('events.errors.notFoundBody', 'Kehre zur Eventliste zurück und wähle dort ein Event aus.')}</AlertDescription>
</Alert>
</AdminLayout>
);
}
const eventName = event ? resolveName(event.name) : t('events.placeholders.untitled', 'Unbenanntes Event');
const activeInvite = joinTokens.find((token) => token.is_active);
const guestLinkRaw = event?.public_url ?? activeInvite?.url ?? (activeInvite?.token ? `/g/${activeInvite.token}` : null);
const guestLink = buildAbsoluteGuestLink(guestLinkRaw);
const guestQrCodeDataUrl = activeInvite?.qr_code_data_url ?? null;
const eventTabs = event
? buildEventTabs(event, (key, fallback) => t(key, { defaultValue: fallback }), {
photos: stats?.uploads_total ?? event.photo_count ?? undefined,
tasks: event.tasks_count ?? undefined,
})
: [];
return (
<AdminLayout
title={eventName}
subtitle={t('events.workspace.detailSubtitle', 'Behalte Status, Aufgaben und QR-Codes deines Events im Blick.')}
tabs={eventTabs}
currentTabKey="recap"
>
{error && (
<Alert variant="destructive">
<AlertTitle>{t('events.alerts.failedTitle', 'Aktion fehlgeschlagen')}</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{loading ? (
<WorkspaceSkeleton />
) : event && stats ? (
<RecapContent
event={event}
stats={stats}
busy={busy}
onToggleEvent={handleToggleEvent}
guestLink={guestLink}
guestQrCodeDataUrl={guestQrCodeDataUrl}
addonsCatalog={addonsCatalog}
addonBusyKey={addonBusyKey}
onCheckoutAddon={handleAddonCheckout}
onArchive={handleArchive}
onCopyLink={guestLink ? () => {
navigator.clipboard.writeText(guestLink);
toast.success(t('events.recap.copySuccess', 'Link kopiert'));
} : undefined}
onOpenPhotos={() => navigate(ADMIN_EVENT_PHOTOS_PATH(event.slug))}
onEditEvent={() => navigate(ADMIN_EVENT_EDIT_PATH(event.slug))}
onBack={() => navigate(ADMIN_EVENTS_PATH)}
settingsBusy={settingsBusy}
onToggleDownloads={(value) => handleToggleSetting('guest_downloads_enabled', value)}
onToggleSharing={(value) => handleToggleSetting('guest_sharing_enabled', value)}
/>
) : (
<Alert variant="destructive">
<AlertTitle>{t('events.errors.notFoundTitle', 'Event nicht gefunden')}</AlertTitle>
<AlertDescription>{t('events.errors.notFoundBody', 'Kehre zur Eventliste zurück und wähle dort ein Event aus.')}</AlertDescription>
</Alert>
)}
</AdminLayout>
);
}
type RecapContentProps = {
event: TenantEvent;
stats: EventStats;
busy: boolean;
onToggleEvent: () => void;
guestLink: string | null;
guestQrCodeDataUrl: string | null;
addonsCatalog: EventAddonCatalogItem[];
addonBusyKey: string | null;
onCheckoutAddon: (addonKey: string) => void;
onArchive: () => void;
onCopyLink?: () => void;
onOpenPhotos: () => void;
onEditEvent: () => void;
onBack: () => void;
settingsBusy: boolean;
onToggleDownloads: (value: boolean) => void;
onToggleSharing: (value: boolean) => void;
};
function RecapContent({
event,
stats,
busy,
onToggleEvent,
guestLink,
guestQrCodeDataUrl,
addonsCatalog,
addonBusyKey,
onCheckoutAddon,
onArchive,
onCopyLink,
onOpenPhotos,
onEditEvent,
onBack,
settingsBusy,
onToggleDownloads,
onToggleSharing,
}: RecapContentProps) {
const { t } = useTranslation('management');
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,
};
const [sentiment, setSentiment] = React.useState<'positive' | 'neutral' | 'negative' | null>(null);
const [feedbackMessage, setFeedbackMessage] = React.useState('');
const [feedbackBusy, setFeedbackBusy] = React.useState(false);
const [feedbackSubmitted, setFeedbackSubmitted] = React.useState(false);
const [feedbackError, setFeedbackError] = React.useState<string | undefined>(undefined);
const [archiveOpen, setArchiveOpen] = React.useState(false);
const [archiveBusy, setArchiveBusy] = React.useState(false);
const [archiveConfirmed, setArchiveConfirmed] = React.useState(false);
const [feedbackOpen, setFeedbackOpen] = React.useState(false);
const [feedbackBestArea, setFeedbackBestArea] = React.useState<string | null>(null);
const [feedbackNeedsSupport, setFeedbackNeedsSupport] = React.useState(false);
const galleryAddons = React.useMemo(
() => addonsCatalog.filter((addon) => addon.key.includes('gallery') || addon.key.includes('boost')),
[addonsCatalog],
);
const addonsToShow = galleryAddons.length ? galleryAddons : addonsCatalog;
const defaultAddon = addonsToShow[0] ?? null;
const describeAddon = React.useCallback((addon: EventAddonCatalogItem): string | null => {
const increments = addon.increments ?? {};
const photos = (increments as Record<string, number | undefined>).photos ?? (increments as Record<string, number | undefined>).extra_photos;
const guests = (increments as Record<string, number | undefined>).guests ?? (increments as Record<string, number | undefined>).extra_guests;
const galleryDays = (increments as Record<string, number | undefined>).gallery_days
?? (increments as Record<string, number | undefined>).extra_gallery_days;
const parts: string[] = [];
if (typeof photos === 'number' && photos > 0) {
parts.push(t('events.sections.addons.summary.photos', `+${photos} Fotos`, { count: photos.toLocaleString() }));
}
if (typeof guests === 'number' && guests > 0) {
parts.push(t('events.sections.addons.summary.guests', `+${guests} Gäste`, { count: guests.toLocaleString() }));
}
if (typeof galleryDays === 'number' && galleryDays > 0) {
parts.push(t('events.sections.addons.summary.gallery', `+${galleryDays} Tage`, { count: galleryDays }));
}
return parts.length ? parts.join(' · ') : null;
}, [t]);
const copy = {
positive: t('events.feedback.positive', 'War super'),
neutral: t('events.feedback.neutral', 'In Ordnung'),
negative: t('events.feedback.negative', 'Brauch(t)e Unterstützung'),
};
const bestAreaOptions = [
{ key: 'uploads', label: t('events.feedback.best.uploads', 'Uploads & Geschwindigkeit') },
{ key: 'invites', label: t('events.feedback.best.invites', 'QR-Einladungen & Layouts') },
{ key: 'moderation', label: t('events.feedback.best.moderation', 'Moderation & Export') },
{ key: 'experience', label: t('events.feedback.best.experience', 'Allgemeine App-Erfahrung') },
];
const handleQrDownload = React.useCallback(() => {
if (!guestQrCodeDataUrl) return;
const link = document.createElement('a');
link.href = guestQrCodeDataUrl;
link.download = 'guest-gallery-qr.png';
link.click();
}, [guestQrCodeDataUrl]);
const handleQrShare = React.useCallback(async () => {
if (!guestLink) return;
if (navigator.share) {
try {
await navigator.share({
title: resolveName(event.name),
text: t('events.recap.shareGuests', 'Gäste-Galerie teilen'),
url: guestLink,
});
return;
} catch {
// Ignore share cancellation and fall back to copy.
}
}
try {
await navigator.clipboard.writeText(guestLink);
toast.success(t('events.recap.copySuccess', 'Link kopiert'));
} catch {
toast.error(t('events.recap.copyError', 'Link konnte nicht geteilt werden.'));
}
}, [event.name, guestLink, t]);
const handleFeedbackSubmit = React.useCallback(async () => {
if (feedbackBusy) return;
setFeedbackBusy(true);
setFeedbackError(undefined);
try {
await submitTenantFeedback({
category: 'event_workspace_after_event',
event_slug: event.slug,
sentiment: sentiment ?? undefined,
message: feedbackMessage.trim() ? feedbackMessage.trim() : undefined,
metadata: {
best_area: feedbackBestArea,
needs_support: feedbackNeedsSupport,
event_name: resolveName(event.name),
guest_link: guestLink,
},
});
setFeedbackSubmitted(true);
setFeedbackOpen(false);
setFeedbackMessage('');
setFeedbackNeedsSupport(false);
toast.success(t('events.feedback.submitted', 'Danke!'));
} catch (err) {
setFeedbackError(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 {
setFeedbackBusy(false);
}
}, [event.slug, event.name, feedbackBestArea, feedbackBusy, feedbackMessage, feedbackNeedsSupport, feedbackSubmitted, guestLink, sentiment, t]);
return (
<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.recap.badge', 'Nachbereitung')}
</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.recap.subtitle', 'Abschluss, Export und Galerie-Laufzeit verwalten.')}
</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" />
{event.status === 'published' ? t('events.status.published', 'Veröffentlicht') : t('events.status.draft', 'Entwurf')}
</Badge>
<Badge variant="outline" className="gap-1 rounded-full border-slate-200 text-slate-700 dark:border-white/10 dark:text-white">
<Clock3 className="h-3.5 w-3.5" />
{event.event_date ? formatEventDate(event.event_date, undefined) : t('events.workspace.noDate', 'Kein Datum')}
</Badge>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline" size="sm" onClick={onBack} 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={onEditEvent} 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={onToggleEvent}
disabled={busy}
className="rounded-full border-slate-200"
>
{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>
</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-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={onOpenPhotos}>
<Camera className="mr-2 h-4 w-4" />
{t('events.recap.moderate', 'Uploads ansehen')}
</Button>
</div>
<div className="mt-4 space-y-3 rounded-2xl border border-dashed border-emerald-200 bg-emerald-50/60 p-3 text-sm text-emerald-900 dark:border-emerald-900/40 dark:bg-emerald-900/20 dark:text-emerald-50">
<div className="flex flex-wrap items-center gap-2 sm:flex-nowrap">
<div className="min-w-0 flex-1">
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-emerald-600 dark:text-emerald-200">
{t('events.recap.shareLink', 'Gäste-Link')}
</p>
{guestLink ? (
<span className="block truncate text-emerald-900" title={guestLink}>{guestLink}</span>
) : (
<p className="text-xs text-emerald-800/80 dark:text-emerald-100">
{t('events.recap.noPublicUrl', 'Kein Gäste-Link gesetzt. Lege den öffentlichen Link im Event-Setup fest.')}
</p>
)}
</div>
{guestLink && onCopyLink ? (
<Button size="sm" variant="secondary" className="rounded-full bg-emerald-600 text-white hover:bg-emerald-700" onClick={onCopyLink}>
{t('events.recap.copyLink', 'Link kopieren')}
</Button>
) : null}
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="flex items-center justify-between rounded-xl border border-emerald-100/70 bg-white/80 px-3 py-2 text-sm text-emerald-900 dark:border-emerald-900/40 dark:bg-white/5 dark:text-emerald-50">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-emerald-500">{t('events.recap.allowDownloads', 'Downloads erlauben')}</p>
<p className="text-[11px] text-emerald-600 dark:text-emerald-200">{t('events.recap.allowDownloadsHint', 'Gäste dürfen Fotos speichern')}</p>
</div>
<Switch
checked={Boolean((event.settings as any)?.guest_downloads_enabled ?? true)}
onCheckedChange={(checked) => onToggleDownloads(Boolean(checked))}
disabled={settingsBusy}
/>
</div>
<div className="flex items-center justify-between rounded-xl border border-emerald-100/70 bg-white/80 px-3 py-2 text-sm text-emerald-900 dark:border-emerald-900/40 dark:bg-white/5 dark:text-emerald-50">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-emerald-500">{t('events.recap.allowSharing', 'Teilen erlauben')}</p>
<p className="text-[11px] text-emerald-600 dark:text-emerald-200">{t('events.recap.allowSharingHint', 'Gäste dürfen Links teilen')}</p>
</div>
<Switch
checked={Boolean((event.settings as any)?.guest_sharing_enabled ?? true)}
onCheckedChange={(checked) => onToggleSharing(Boolean(checked))}
disabled={settingsBusy}
/>
</div>
</div>
{guestQrCodeDataUrl ? (
<div className="mt-2 grid gap-3 rounded-2xl border border-emerald-100/80 bg-white/90 p-3 text-emerald-900 shadow-sm dark:border-emerald-900/40 dark:bg-white/10 dark:text-emerald-50 sm:grid-cols-[auto,1fr]">
<div className="flex items-center justify-center rounded-xl border border-emerald-100/70 bg-white/70 p-2 dark:border-emerald-900/50 dark:bg-emerald-900/30">
<img src={guestQrCodeDataUrl} alt={t('events.recap.qrAlt', 'QR-Code zur Gäste-Galerie')} className="h-28 w-28 rounded-lg" />
</div>
<div className="flex min-w-0 flex-col gap-2">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-emerald-500">{t('events.recap.qrTitle', 'QR-Code teilen')}</p>
{guestLink ? (
<p className="truncate text-sm text-emerald-800 dark:text-emerald-100" title={guestLink}>{guestLink}</p>
) : null}
</div>
<div className="flex flex-wrap gap-2">
<Button size="sm" variant="secondary" className="bg-emerald-600 text-white hover:bg-emerald-700" onClick={handleQrDownload}>
{t('events.recap.qrDownload', 'QR-Code herunterladen')}
</Button>
<Button size="sm" variant="outline" onClick={handleQrShare}>
{t('events.recap.qrShare', 'Link/QR teilen')}
</Button>
</div>
</div>
</div>
) : null}
</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-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={onOpenPhotos}>
<Printer className="mr-2 h-4 w-4" />
{t('events.recap.downloadAll', 'Alles herunterladen')}
</Button>
<Button size="sm" variant="outline" onClick={onOpenPhotos}>
<Printer className="mr-2 h-4 w-4" />
{t('events.recap.downloadHighlights', 'Highlights herunterladen')}
</Button>
</div>
<p className="mt-2 text-xs text-slate-500 dark:text-slate-400">
{t('events.recap.highlightsHint', '“Highlights” = als Highlight markierte Fotos in der Galerie.')}
</p>
</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', 'Verlängerung / Archivierung')}
</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"
disabled={!defaultAddon || addonBusyKey === defaultAddon?.key}
onClick={() => defaultAddon && onCheckoutAddon(defaultAddon.key)}
>
{addonBusyKey === defaultAddon?.key ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Clock3 className="mr-2 h-4 w-4" />}
{defaultAddon?.label ?? t('events.actions.extendGallery', 'Galerie verlängern')}
</Button>
<Button size="sm" variant="outline" onClick={() => setArchiveOpen(true)}>
{t('events.recap.archive', 'Archivieren/Löschen')}
</Button>
</div>
{addonsToShow.length ? (
<div className="mt-4 space-y-3 rounded-2xl border border-amber-100/80 bg-white/80 p-3 text-sm text-slate-700 shadow-sm dark:border-amber-900/40 dark:bg-white/10 dark:text-slate-200">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-amber-600 dark:text-amber-200">
{t('events.recap.extendOptions', 'Alle Add-ons für dieses Event')}
</p>
<div className="space-y-2">
{addonsToShow.map((addon) => (
<div
key={addon.key}
className="flex flex-col gap-2 rounded-xl border border-amber-100/60 bg-white/80 p-3 shadow-sm dark:border-amber-900/40 dark:bg-amber-900/20 sm:flex-row sm:items-center sm:justify-between"
>
<div className="min-w-0 space-y-1">
<p className="truncate font-semibold text-slate-900 dark:text-white" title={addon.label}>
{addon.label}
</p>
<p className="text-xs text-slate-500 dark:text-slate-300">
{describeAddon(addon) ?? t('events.recap.extendHint', 'Laufzeitverlängerungen addieren sich. Checkout öffnet in einem neuen Tab.')}
</p>
</div>
<Button
size="sm"
variant="outline"
disabled={!addon.price_id || addonBusyKey === addon.key}
onClick={() => onCheckoutAddon(addon.key)}
>
{addonBusyKey === addon.key ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <ShoppingCart className="mr-2 h-4 w-4" />}
{addon.price_id ? t('addons.buyNow', 'Jetzt freischalten') : t('events.recap.priceMissing', 'Preis nicht verknüpft')}
</Button>
</div>
))}
</div>
<p className="text-[11px] text-slate-500 dark:text-slate-400">
{t('events.recap.extendHint', 'Laufzeitverlängerungen addieren sich. Checkout öffnet in einem neuen Tab.')}
</p>
</div>
) : (
<p className="mt-4 text-xs text-slate-500 dark:text-slate-300">
{t('events.recap.noAddons', 'Aktuell keine Add-ons verfügbar.')}
</p>
)}
</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 className="space-y-1">
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-rose-500">
{t('events.feedback.badge', 'Feedback')}
</p>
<p className="text-lg font-semibold text-slate-900 dark:text-white">
{t('events.feedback.afterEventTitle', 'Event beendet kurzes Feedback?')}
</p>
<p className="text-sm text-slate-600 dark:text-slate-300">
{t('events.feedback.afterEventCopy', 'Hat alles geklappt? Deine Antwort hilft uns für kommende Events.')}<br />
<span className="text-[11px] text-slate-500">{t('events.feedback.privacyHint', 'Nur Admin-Feedback, keine Gastdaten')}</span>
</p>
</div>
<Badge variant="outline" className="border-rose-200 text-rose-700 dark:border-rose-800 dark:text-rose-100">
{t('events.feedback.badgeShort', 'Feedback')}
</Badge>
</div>
<div className="mt-4 flex flex-wrap gap-3 text-sm text-slate-600 dark:text-slate-300">
<Badge variant="secondary" className="rounded-full bg-rose-100 text-rose-700 dark:bg-rose-500/20 dark:text-rose-50">
{resolveName(event.name)}
</Badge>
{event.event_date ? (
<Badge variant="outline" className="rounded-full border-slate-200 text-slate-700 dark:border-white/10 dark:text-white">
{formatEventDate(event.event_date, undefined)}
</Badge>
) : null}
</div>
{feedbackSubmitted ? (
<div className="mt-4 rounded-2xl border border-emerald-200/60 bg-emerald-50/80 p-4 text-emerald-900 shadow-sm dark:border-emerald-900/40 dark:bg-emerald-900/20 dark:text-emerald-50">
<p className="font-semibold">{t('events.feedback.submitted', 'Danke!')}</p>
<p className="text-sm">{t('events.feedback.afterEventThanks', 'Dein Feedback ist angekommen. Wir melden uns, falls Rückfragen bestehen.')}</p>
<div className="mt-3 flex flex-wrap gap-2">
<Button size="sm" variant="outline" onClick={() => { setFeedbackSubmitted(false); setFeedbackOpen(true); }}>
{t('events.feedback.sendAnother', 'Weiteres Feedback senden')}
</Button>
<Button size="sm" variant="secondary" className="bg-rose-600 text-white hover:bg-rose-700" onClick={() => { setFeedbackNeedsSupport(true); setFeedbackSubmitted(false); setFeedbackOpen(true); }}>
{t('events.feedback.supportFollowup', 'Support anfragen')}
</Button>
</div>
</div>
) : (
<div className="mt-4 flex flex-wrap items-center gap-3">
<Button size="sm" onClick={() => setFeedbackOpen(true)} disabled={feedbackBusy}>
<MessageSquare className="mr-2 h-4 w-4" />
{t('events.feedback.cta', 'Feedback geben')}
</Button>
<div className="flex flex-wrap gap-2 text-xs text-slate-500">
<span>{t('events.feedback.quickSentiment', 'Stimmung auswählbar (positiv/neutral/Support).')}</span>
</div>
</div>
)}
{feedbackError ? (
<Alert variant="destructive" className="mt-4">
<AlertTitle>{t('events.feedback.errorTitle', 'Feedback konnte nicht gesendet werden.')}</AlertTitle>
<AlertDescription>{feedbackError}</AlertDescription>
</Alert>
) : null}
</div>
</div>
<Dialog open={feedbackOpen} onOpenChange={(open) => { setFeedbackOpen(open); setFeedbackError(undefined); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('events.feedback.dialogTitle', 'Kurzes After-Event Feedback')}</DialogTitle>
<DialogDescription>
{t('events.feedback.dialogCopy', 'Wähle eine Stimmung, was am besten lief und optional, was wir verbessern sollen.')}
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">{t('events.feedback.sentiment', 'Stimmung')}</p>
<div className="mt-2 flex flex-wrap gap-2">
{(Object.keys(copy) as Array<'positive' | 'neutral' | 'negative'>).map((key) => (
<Button
key={key}
type="button"
size="sm"
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>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">{t('events.feedback.bestQuestion', 'Was lief am besten?')}</p>
<div className="mt-2 grid gap-2 sm:grid-cols-2">
{bestAreaOptions.map((option) => (
<Button
key={option.key}
type="button"
variant={feedbackBestArea === option.key ? 'secondary' : 'outline'}
className={feedbackBestArea === option.key ? 'border-slate-900 bg-slate-900 text-white' : 'border-slate-200 text-slate-700'}
onClick={() => setFeedbackBestArea(option.key)}
>
{option.label}
</Button>
))}
</div>
</div>
<div className="space-y-1">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">{t('events.feedback.improve', 'Was sollen wir verbessern?')}</p>
<textarea
value={feedbackMessage}
onChange={(event) => setFeedbackMessage(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-lg border border-slate-200 bg-white/90 p-3 text-sm text-slate-700 outline-none focus:border-slate-400 focus:ring-1 focus:ring-slate-300 dark:border-white/10 dark:bg-white/5 dark:text-slate-100"
/>
</div>
<div className="flex items-start gap-2 rounded-lg border border-slate-200 bg-slate-50/70 p-3 text-xs text-slate-700 dark:border-white/10 dark:bg-white/5 dark:text-slate-100">
<Checkbox
id="needs-support"
checked={feedbackNeedsSupport}
onCheckedChange={(checked) => setFeedbackNeedsSupport(Boolean(checked))}
className="mt-0.5"
/>
<Label htmlFor="needs-support" className="cursor-pointer text-sm leading-5">
{t('events.feedback.supportHelp', 'Ich hätte gern ein kurzes Follow-up (Support).')}
</Label>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setFeedbackOpen(false)} disabled={feedbackBusy}>
{t('common.cancel', 'Abbrechen')}
</Button>
<Button onClick={() => { void handleFeedbackSubmit(); }} disabled={feedbackBusy}>
{feedbackBusy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <MessageSquare className="mr-2 h-4 w-4" />}
{t('events.feedback.submit', 'Feedback senden')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={archiveOpen} onOpenChange={(open) => {
setArchiveOpen(open);
if (!open) {
setArchiveConfirmed(false);
}
}}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('events.recap.archiveTitle', 'Event archivieren')}</DialogTitle>
<DialogDescription>
{t('events.recap.archiveDesc', 'Das Archivieren schließt die Galerie, deaktiviert Gäste-Links und stoppt neue Uploads. Exporte solltest du vorher abschließen.')}
</DialogDescription>
</DialogHeader>
<div className="space-y-2 rounded-lg border border-amber-200/60 bg-amber-50/70 p-3 text-sm text-amber-900 dark:border-amber-900/40 dark:bg-amber-900/30 dark:text-amber-50">
<p className="font-semibold">{t('events.recap.archiveImpact', 'Was passiert?')}</p>
<ul className="list-disc space-y-1 pl-4 text-amber-900 dark:text-amber-50">
<li>{t('events.recap.archiveImpactClose', 'Gäste-Zugriff wird beendet, Uploads/Downloads werden deaktiviert.')}</li>
<li>{t('events.recap.archiveImpactLinks', 'Öffentliche Links und QR-Codes werden ungültig, bestehende Sessions laufen aus.')}</li>
<li>{t('events.recap.archiveImpactData', 'Daten bleiben intern für Compliance & Support sichtbar, können aber auf Anfrage gelöscht werden (DSGVO).')}</li>
</ul>
</div>
<div className="flex items-start gap-3 rounded-lg border border-slate-200 bg-slate-50/70 p-3 text-sm text-slate-800 dark:border-white/10 dark:bg-white/5 dark:text-slate-100">
<Checkbox
id="archive-confirm"
checked={archiveConfirmed}
onCheckedChange={(checked) => setArchiveConfirmed(Boolean(checked))}
className="mt-0.5"
/>
<Label htmlFor="archive-confirm" className="cursor-pointer text-sm leading-5">
{t('events.recap.archiveConfirm', 'Ich habe Exporte abgeschlossen und möchte die Galerie jetzt archivieren.')}
</Label>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setArchiveOpen(false)} disabled={archiveBusy}>
{t('common.cancel', 'Abbrechen')}
</Button>
<Button variant="destructive" onClick={onArchive} disabled={!archiveConfirmed || archiveBusy}>
{archiveBusy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
{t('events.recap.archiveConfirmCta', 'Archivierung starten')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
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 buildAbsoluteGuestLink(link: string | null): string | null {
if (!link) return null;
try {
const base = typeof window !== 'undefined' ? window.location.origin : undefined;
return base ? new URL(link, base).toString() : new URL(link).toString();
} catch {
return link;
}
}
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={`recap-metric-skeleton-${index}`} />
))}
</div>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.4fr)_minmax(0,0.8fr)]">
<SkeletonCard />
<SkeletonCard />
</div>
</div>
);
}
function SkeletonCard() {
return <div className="h-40 animate-pulse rounded-2xl bg-gradient-to-r from-slate-100 via-white to-slate-100" />;
}