import React from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { AlertTriangle, ArrowLeft, Loader2, Save, Sparkles } from 'lucide-react'; import toast from 'react-hot-toast'; import { useQuery } from '@tanstack/react-query'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Checkbox } from '@/components/ui/checkbox'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { AdminLayout } from '../components/AdminLayout'; import { createEvent, getEvent, getTenantPackagesOverview, updateEvent, getPackages, getEventTypes, TenantEvent, } from '../api'; import { isAuthError } from '../auth/tokens'; import { isApiError } from '../lib/apiError'; import { buildLimitWarnings } from '../lib/limitWarnings'; import { ADMIN_BILLING_PATH, ADMIN_EVENT_VIEW_PATH, ADMIN_EVENTS_PATH } from '../constants'; interface EventFormState { name: string; slug: string; date: string; eventTypeId: number | null; package_id: number; isPublished: boolean; } type PackageHighlight = { label: string; value: string; }; const FEATURE_LABELS: Record = { basic_uploads: 'Uploads inklusive', unlimited_sharing: 'Unbegrenztes Teilen', no_watermark: 'Kein Wasserzeichen', custom_branding: 'Eigenes Branding', custom_tasks: 'Eigene Aufgaben', watermark_allowed: 'Wasserzeichen erlaubt', branding_allowed: 'Branding-Optionen', }; type EventPackageMeta = { id: number; name: string; purchasedAt: string | null; expiresAt: string | null; }; export default function EventFormPage() { const params = useParams<{ slug?: string }>(); const [searchParams] = useSearchParams(); const slugParam = params.slug ?? searchParams.get('slug') ?? undefined; const isEdit = Boolean(slugParam); const navigate = useNavigate(); const { t: tCommon } = useTranslation('common', { keyPrefix: 'errors' }); const { t: tLimits } = useTranslation('common', { keyPrefix: 'limits' }); const [form, setForm] = React.useState({ name: '', slug: '', date: '', eventTypeId: null, package_id: 0, isPublished: false, }); const [autoSlug, setAutoSlug] = React.useState(true); const [originalSlug, setOriginalSlug] = React.useState(null); const slugSuffixRef = React.useRef(null); const [saving, setSaving] = React.useState(false); const [error, setError] = React.useState(null); const [showUpgradeHint, setShowUpgradeHint] = React.useState(false); const [readOnlyPackageName, setReadOnlyPackageName] = React.useState(null); const [eventPackageMeta, setEventPackageMeta] = React.useState(null); const { data: packages, isLoading: packagesLoading } = useQuery({ queryKey: ['packages', 'endcustomer'], queryFn: () => getPackages('endcustomer'), }); const { data: eventTypes, isLoading: eventTypesLoading } = useQuery({ queryKey: ['tenant', 'event-types'], queryFn: getEventTypes, }); const { data: packageOverview, isLoading: overviewLoading } = useQuery({ queryKey: ['tenant', 'packages', 'overview'], queryFn: getTenantPackagesOverview, }); const activePackage = packageOverview?.activePackage ?? null; React.useEffect(() => { if (isEdit || !activePackage?.package_id) { return; } setForm((prev) => { if (prev.package_id === activePackage.package_id) { return prev; } return { ...prev, package_id: activePackage.package_id, }; }); setReadOnlyPackageName((prev) => prev ?? activePackage.package_name); }, [isEdit, activePackage]); const { data: loadedEvent, isLoading: eventLoading, error: eventLoadError, } = useQuery({ queryKey: ['tenant', 'events', slugParam], queryFn: () => getEvent(slugParam!), enabled: Boolean(isEdit && slugParam), staleTime: 60_000, }); React.useEffect(() => { if (isEdit) { return; } if (!eventTypes || eventTypes.length === 0) { return; } setForm((prev) => { if (prev.eventTypeId) { return prev; } return { ...prev, eventTypeId: eventTypes[0]!.id, }; }); }, [eventTypes, isEdit]); React.useEffect(() => { if (!isEdit || !loadedEvent) { return; } const name = normalizeName(loadedEvent.name); setForm((prev) => ({ ...prev, name, slug: loadedEvent.slug, date: loadedEvent.event_date ? loadedEvent.event_date.slice(0, 10) : '', eventTypeId: loadedEvent.event_type_id ?? prev.eventTypeId, isPublished: loadedEvent.status === 'published', package_id: loadedEvent.package?.id ? Number(loadedEvent.package.id) : prev.package_id, })); setOriginalSlug(loadedEvent.slug); setReadOnlyPackageName(loadedEvent.package?.name ?? null); setEventPackageMeta(loadedEvent.package ? { id: Number(loadedEvent.package.id), name: loadedEvent.package.name ?? (typeof loadedEvent.package === 'string' ? loadedEvent.package : ''), purchasedAt: loadedEvent.package.purchased_at ?? null, expiresAt: loadedEvent.package.expires_at ?? null, } : null); setAutoSlug(false); slugSuffixRef.current = null; }, [isEdit, loadedEvent]); React.useEffect(() => { if (!isEdit || !eventLoadError) { return; } if (!isAuthError(eventLoadError)) { setError('Event konnte nicht geladen werden.'); } }, [isEdit, eventLoadError]); const loading = isEdit ? eventLoading : false; const limitWarnings = React.useMemo(() => { if (!isEdit) { return []; } return buildLimitWarnings(loadedEvent?.limits, tLimits); }, [isEdit, loadedEvent?.limits, tLimits]); const shownToastRef = React.useRef>(new Set()); React.useEffect(() => { limitWarnings.forEach((warning) => { const key = `${warning.id}-${warning.message}`; if (shownToastRef.current.has(key)) { return; } shownToastRef.current.add(key); toast(warning.message, { icon: warning.tone === 'danger' ? '🚨' : '⚠️', id: key, }); }); }, [limitWarnings]); const limitScopeLabels = React.useMemo(() => ({ photos: tLimits('photosTitle'), guests: tLimits('guestsTitle'), gallery: tLimits('galleryTitle'), }), [tLimits]); function ensureSlugSuffix(): string { if (!slugSuffixRef.current) { slugSuffixRef.current = Math.random().toString(36).slice(2, 7); } return slugSuffixRef.current; } function buildAutoSlug(value: string): string { const base = slugify(value).replace(/^-+|-+$/g, ''); const suffix = ensureSlugSuffix(); const safeBase = base || 'event'; return `${safeBase}-${suffix}`; } function handleNameChange(value: string) { setForm((prev) => ({ ...prev, name: value })); if (autoSlug) { setForm((prev) => ({ ...prev, slug: buildAutoSlug(value) })); } } async function handleSubmit(event: React.FormEvent) { event.preventDefault(); const trimmedName = form.name.trim(); if (!trimmedName) { setError('Bitte gib einen Eventnamen ein.'); return; } let finalSlug = form.slug.trim(); if (!finalSlug || autoSlug) { finalSlug = buildAutoSlug(trimmedName); } if (!form.eventTypeId) { setError('Bitte wähle einen Event-Typ aus.'); return; } setSaving(true); setError(null); setShowUpgradeHint(false); const status: 'draft' | 'published' | 'archived' = form.isPublished ? 'published' : 'draft'; const packageIdForSubmit = form.package_id || activePackage?.package_id || null; const shouldIncludePackage = !isEdit && packageIdForSubmit && (!activePackage?.package_id || packageIdForSubmit !== activePackage.package_id); const payload = { name: trimmedName, slug: finalSlug, event_type_id: form.eventTypeId, event_date: form.date || undefined, status, ...(shouldIncludePackage && packageIdForSubmit ? { package_id: Number(packageIdForSubmit) } : {}), }; try { if (isEdit) { const targetSlug = originalSlug ?? slugParam!; const updated = await updateEvent(targetSlug, payload); setOriginalSlug(updated.slug); setShowUpgradeHint(false); setError(null); navigate(ADMIN_EVENT_VIEW_PATH(updated.slug)); } else { const { event: created } = await createEvent(payload); setShowUpgradeHint(false); setError(null); navigate(ADMIN_EVENT_VIEW_PATH(created.slug)); } } catch (err) { if (!isAuthError(err)) { if (isApiError(err)) { switch (err.code) { case 'event_limit_exceeded': { const limit = Number(err.meta?.limit ?? 0); const used = Number(err.meta?.used ?? 0); const remaining = Number(err.meta?.remaining ?? Math.max(0, limit - used)); const detail = limit > 0 ? tCommon('eventLimitDetails', { used, limit, remaining }) : ''; setError(`${tCommon('eventLimit')}${detail ? `\n${detail}` : ''}`); setShowUpgradeHint(true); break; } case 'event_credits_exhausted': { setError(tCommon('creditsExhausted')); setShowUpgradeHint(true); break; } default: { setError(err.message || tCommon('generic')); setShowUpgradeHint(false); } } } else { setError(tCommon('generic')); setShowUpgradeHint(false); } } } finally { setSaving(false); } } const effectivePackageId = form.package_id || activePackage?.package_id || null; const selectedPackage = React.useMemo(() => { if (!packages || !packages.length) { return null; } if (effectivePackageId) { return packages.find((pkg) => pkg.id === effectivePackageId) ?? null; } return null; }, [packages, effectivePackageId]); React.useEffect(() => { if (!readOnlyPackageName && selectedPackage?.name) { setReadOnlyPackageName(selectedPackage.name); } }, [readOnlyPackageName, selectedPackage]); const packageNameDisplay = readOnlyPackageName ?? selectedPackage?.name ?? ((overviewLoading || packagesLoading) ? 'Paket wird geladen…' : 'Kein aktives Paket gefunden'); const packagePriceLabel = selectedPackage?.price !== undefined && selectedPackage?.price !== null ? formatCurrency(selectedPackage.price) : null; const packageHighlights = React.useMemo(() => { const highlights: PackageHighlight[] = []; if (selectedPackage?.max_photos) { highlights.push({ label: 'Fotos', value: `${selectedPackage.max_photos.toLocaleString('de-DE')} Bilder`, }); } if (selectedPackage?.max_guests) { highlights.push({ label: 'Gäste', value: `${selectedPackage.max_guests.toLocaleString('de-DE')} Personen`, }); } if (selectedPackage?.gallery_days) { highlights.push({ label: 'Galerie', value: `${selectedPackage.gallery_days} Tage online`, }); } return highlights; }, [selectedPackage]); const featureTags = React.useMemo(() => { if (!selectedPackage?.features) { return []; } return Object.entries(selectedPackage.features) .filter(([, enabled]) => Boolean(enabled)) .map(([key]) => FEATURE_LABELS[key] ?? key.replace(/_/g, ' ')); }, [selectedPackage]); const packageExpiresLabel = formatDate(eventPackageMeta?.expiresAt ?? activePackage?.expires_at ?? null); const remainingEventsLabel = typeof activePackage?.remaining_events === 'number' ? `Noch ${activePackage.remaining_events} Event${activePackage.remaining_events === 1 ? '' : 's'} in deinem Paket` : null; const actions = ( ); return ( {error && ( Hinweis {error.split('\n').map((line, index) => ( {line} ))} {showUpgradeHint && (
)}
)} {limitWarnings.length > 0 && (
{limitWarnings.map((warning) => ( {limitScopeLabels[warning.scope]} {warning.message} ))}
)} Eventdetails Name, URL und Datum bestimmen das Auftreten deines Events im Gästeportal. {loading ? ( ) : (
handleNameChange(e.target.value)} autoFocus />

Die Kennung und Event-URL werden automatisch aus dem Namen generiert.

setForm((prev) => ({ ...prev, date: e.target.value }))} />
{!eventTypesLoading && (!eventTypes || eventTypes.length === 0) ? (

Keine Event-Typen verfĂĽgbar. Bitte lege einen Typ im Adminbereich an.

) : null}
{isEdit ? 'Gebuchtes Paket' : 'Aktives Paket'} {packagePriceLabel ? ( {packagePriceLabel} ) : null}
{packageNameDisplay} Du nutzt dieses Paket für dein Event. Upgrades und Add-ons folgen bald – bis dahin kannst du alle enthaltenen Leistungen voll ausschöpfen.
{packageExpiresLabel ? (

Galerie aktiv bis {packageExpiresLabel}

) : null} {remainingEventsLabel ? (

{remainingEventsLabel}

) : null}
{packageHighlights.length ? (
{packageHighlights.map((highlight) => (

{highlight.label}

{highlight.value}

))}
) : null} {featureTags.length ? (
{featureTags.map((feature) => ( {feature} ))}
) : (

{(packagesLoading || overviewLoading) ? 'Paketdetails werden geladen...' : 'FĂĽr dieses Paket sind aktuell keine besonderen Features hinterlegt.'}

)}
setForm((prev) => ({ ...prev, isPublished: Boolean(checked) }))} />

Aktiviere diese Option, wenn Gäste das Event direkt sehen sollen. Du kannst den Status später ändern.

)}
); } function FormSkeleton() { return (
{Array.from({ length: 4 }).map((_, index) => (
))}
); } function formatCurrency(value: number | null | undefined): string | null { if (value === null || value === undefined) { return null; } try { return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR', maximumFractionDigits: value % 1 === 0 ? 0 : 2, }).format(value); } catch { return `${value} €`; } } function formatDate(value: string | null | undefined): string | null { if (!value) { return null; } const date = new Date(value); if (Number.isNaN(date.getTime())) { return null; } try { return new Intl.DateTimeFormat('de-DE', { day: '2-digit', month: 'short', year: 'numeric', }).format(date); } catch { return date.toISOString().slice(0, 10); } } function slugify(value: string): string { return value .normalize('NFKD') .toLowerCase() .replace(/[\u0300-\u036f]/g, '') .replace(/[^a-z0-9]+/g, '-') .replace(/(^-|-$)+/g, '') .slice(0, 60); } function normalizeName(name: string | Record): string { if (typeof name === 'string') { return name; } return name.de ?? name.en ?? Object.values(name)[0] ?? ''; }