748 lines
26 KiB
TypeScript
748 lines
26 KiB
TypeScript
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 { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';
|
||
|
||
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<string, string> = {
|
||
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<EventFormState>({
|
||
name: '',
|
||
slug: '',
|
||
date: '',
|
||
eventTypeId: null,
|
||
package_id: 0,
|
||
isPublished: false,
|
||
});
|
||
const [autoSlug, setAutoSlug] = React.useState(true);
|
||
const [originalSlug, setOriginalSlug] = React.useState<string | null>(null);
|
||
const slugSuffixRef = React.useRef<string | null>(null);
|
||
const [saving, setSaving] = React.useState(false);
|
||
const [error, setError] = React.useState<string | null>(null);
|
||
const [showUpgradeHint, setShowUpgradeHint] = React.useState(false);
|
||
const [readOnlyPackageName, setReadOnlyPackageName] = React.useState<string | null>(null);
|
||
const [eventPackageMeta, setEventPackageMeta] = React.useState<EventPackageMeta | null>(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 sortedEventTypes = React.useMemo(() => {
|
||
if (!eventTypes) {
|
||
return [];
|
||
}
|
||
return [...eventTypes].sort((a, b) => {
|
||
const aName = (a.name as string) ?? '';
|
||
const bName = (b.name as string) ?? '';
|
||
return aName.localeCompare(bName, undefined, { sensitivity: 'base' });
|
||
});
|
||
}, [eventTypes]);
|
||
|
||
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<TenantEvent>({
|
||
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<Set<string>>(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<HTMLFormElement>) {
|
||
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<PackageHighlight[]>(() => {
|
||
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 [];
|
||
}
|
||
|
||
const normalizeLabel = (key: string) => FEATURE_LABELS[key] ?? key.replace(/_/g, ' ');
|
||
|
||
const raw = selectedPackage.features as unknown;
|
||
if (Array.isArray(raw)) {
|
||
return raw.filter(Boolean).map((key) => normalizeLabel(String(key)));
|
||
}
|
||
|
||
if (typeof raw === 'object' && raw !== null) {
|
||
return Object.entries(raw)
|
||
.filter(([, enabled]) => Boolean(enabled))
|
||
.map(([key]) => normalizeLabel(key));
|
||
}
|
||
|
||
return [];
|
||
}, [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 = (
|
||
<Button
|
||
variant="outline"
|
||
onClick={() => navigate(ADMIN_EVENTS_PATH)}
|
||
className="border-pink-200 text-pink-600 hover:bg-pink-50"
|
||
>
|
||
<ArrowLeft className="h-4 w-4" /> Zurück zur Liste
|
||
</Button>
|
||
);
|
||
|
||
return (
|
||
<AdminLayout
|
||
title={isEdit ? 'Event bearbeiten' : 'Neues Event erstellen'}
|
||
subtitle="Fülle die wichtigsten Angaben aus und teile dein Event mit Gästen."
|
||
actions={actions}
|
||
>
|
||
{error && (
|
||
<Alert variant="destructive">
|
||
<AlertTitle>Hinweis</AlertTitle>
|
||
<AlertDescription className="flex flex-col gap-2">
|
||
{error.split('\n').map((line, index) => (
|
||
<span key={index}>{line}</span>
|
||
))}
|
||
{showUpgradeHint && (
|
||
<div>
|
||
<Button size="sm" variant="outline" onClick={() => navigate(ADMIN_BILLING_PATH)}>
|
||
{tCommon('goToBilling')}
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</AlertDescription>
|
||
</Alert>
|
||
)}
|
||
|
||
{limitWarnings.length > 0 && (
|
||
<div className="mb-6 space-y-2">
|
||
{limitWarnings.map((warning) => (
|
||
<Alert
|
||
key={warning.id}
|
||
variant={warning.tone === 'danger' ? 'destructive' : 'default'}
|
||
className={warning.tone === 'warning' ? 'border-amber-400/50 bg-amber-50 text-amber-900' : undefined}
|
||
>
|
||
<AlertTitle className="flex items-center gap-2 text-sm font-semibold">
|
||
<AlertTriangle className="h-4 w-4" />
|
||
{limitScopeLabels[warning.scope]}
|
||
</AlertTitle>
|
||
<AlertDescription className="text-sm">
|
||
{warning.message}
|
||
</AlertDescription>
|
||
</Alert>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
<Card className="border-0 bg-white/85 shadow-xl shadow-fuchsia-100/60">
|
||
<CardHeader>
|
||
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
|
||
<Sparkles className="h-5 w-5 text-pink-500" /> Eventdetails
|
||
</CardTitle>
|
||
<CardDescription className="text-sm text-slate-600">
|
||
Name, URL und Datum bestimmen das Auftreten deines Events im Gästeportal.
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
{loading ? (
|
||
<FormSkeleton />
|
||
) : (
|
||
<form className="space-y-6" onSubmit={handleSubmit}>
|
||
<div className="grid gap-4 sm:grid-cols-2">
|
||
<div className="space-y-2 sm:col-span-2">
|
||
<Label htmlFor="event-name">Eventname</Label>
|
||
<Input
|
||
id="event-name"
|
||
placeholder="z. B. Sommerfest 2025"
|
||
value={form.name}
|
||
onChange={(e) => handleNameChange(e.target.value)}
|
||
autoFocus
|
||
/>
|
||
<p className="text-xs text-slate-500">
|
||
Die Kennung und Event-URL werden automatisch aus dem Namen generiert.
|
||
</p>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="event-date">Datum</Label>
|
||
<Input
|
||
id="event-date"
|
||
type="date"
|
||
value={form.date}
|
||
onChange={(e) => setForm((prev) => ({ ...prev, date: e.target.value }))}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="event-type">Event-Typ</Label>
|
||
<Select
|
||
value={form.eventTypeId ? String(form.eventTypeId) : undefined}
|
||
onValueChange={(value) => setForm((prev) => ({ ...prev, eventTypeId: Number(value) }))}
|
||
disabled={eventTypesLoading || !sortedEventTypes.length}
|
||
>
|
||
<SelectTrigger id="event-type">
|
||
<SelectValue
|
||
placeholder={eventTypesLoading ? 'Event-Typ wird geladen…' : 'Event-Typ auswählen'}
|
||
/>
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{sortedEventTypes.map((eventType) => (
|
||
<SelectItem key={eventType.id} value={String(eventType.id)}>
|
||
{eventType.name}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
{!eventTypesLoading && (!sortedEventTypes || sortedEventTypes.length === 0) ? (
|
||
<p className="text-xs text-amber-600">
|
||
Keine Event-Typen verfügbar. Bitte lege einen Typ im Adminbereich an.
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-start gap-3 rounded-xl bg-pink-50/60 p-4">
|
||
<Checkbox
|
||
id="event-published"
|
||
checked={form.isPublished}
|
||
onCheckedChange={(checked) => setForm((prev) => ({ ...prev, isPublished: Boolean(checked) }))}
|
||
/>
|
||
<div>
|
||
<Label htmlFor="event-published" className="text-sm font-medium text-slate-800">
|
||
Event sofort veroeffentlichen
|
||
</Label>
|
||
<p className="text-xs text-slate-600">
|
||
Aktiviere diese Option, wenn Gäste das Event direkt sehen sollen. Du kannst den Status später ändern.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex flex-wrap gap-3">
|
||
<Button
|
||
type="submit"
|
||
disabled={saving || !form.name.trim() || !form.slug.trim() || !form.eventTypeId}
|
||
className="bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 text-white shadow-lg shadow-pink-500/20"
|
||
>
|
||
{saving ? (
|
||
<>
|
||
<Loader2 className="h-4 w-4 animate-spin" /> Speichert
|
||
</>
|
||
) : (
|
||
<>
|
||
<Save className="h-4 w-4" /> Speichern
|
||
</>
|
||
)}
|
||
</Button>
|
||
<Button variant="ghost" type="button" onClick={() => navigate(-1)}>
|
||
Abbrechen
|
||
</Button>
|
||
</div>
|
||
<div className="sm:col-span-2 mt-6">
|
||
<Accordion type="single" collapsible defaultValue="package">
|
||
<AccordionItem value="package" className="border-0">
|
||
<AccordionTrigger className="rounded-2xl bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 px-4 py-3 text-left text-white shadow-md shadow-pink-500/20 hover:no-underline">
|
||
<div className="flex flex-wrap items-center gap-3">
|
||
<Badge className="bg-white/25 text-white backdrop-blur">
|
||
{isEdit ? 'Gebuchtes Paket' : 'Aktives Paket'}
|
||
</Badge>
|
||
<span className="font-semibold">{packageNameDisplay}</span>
|
||
{packagePriceLabel ? (
|
||
<span className="text-xs font-semibold uppercase tracking-widest text-white/90">
|
||
{packagePriceLabel}
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
</AccordionTrigger>
|
||
<AccordionContent>
|
||
<Card className="mt-3 border-0 bg-gradient-to-br from-pink-500 via-fuchsia-500 to-purple-500 text-white shadow-xl shadow-pink-500/25">
|
||
<CardHeader className="space-y-4">
|
||
<div className="space-y-2">
|
||
<CardTitle className="text-2xl font-semibold tracking-tight text-white">
|
||
{packageNameDisplay}
|
||
</CardTitle>
|
||
<CardDescription className="text-sm text-pink-50">
|
||
Du nutzt dieses Paket für dein Event. Upgrades und Add-ons folgen bald – bis dahin kannst du alle enthaltenen Leistungen voll ausschöpfen.
|
||
</CardDescription>
|
||
</div>
|
||
{packageExpiresLabel ? (
|
||
<p className="text-xs font-medium uppercase tracking-wide text-white/80">
|
||
Galerie aktiv bis {packageExpiresLabel}
|
||
</p>
|
||
) : null}
|
||
{remainingEventsLabel ? (
|
||
<p className="text-xs text-white/80">{remainingEventsLabel}</p>
|
||
) : null}
|
||
</CardHeader>
|
||
<CardContent className="space-y-5 pb-6">
|
||
{packageHighlights.length ? (
|
||
<div className="grid gap-3 sm:grid-cols-3">
|
||
{packageHighlights.map((highlight) => (
|
||
<div key={`${highlight.label}-${highlight.value}`} className="rounded-xl bg-white/15 px-4 py-3 text-sm">
|
||
<p className="text-white/70">{highlight.label}</p>
|
||
<p className="font-semibold text-white">{highlight.value}</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
{featureTags.length ? (
|
||
<div className="flex flex-wrap gap-2">
|
||
{featureTags.map((feature) => (
|
||
<Badge key={feature} variant="secondary" className="bg-white/15 text-white backdrop-blur">
|
||
{feature}
|
||
</Badge>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<p className="text-sm text-white/75">
|
||
{(packagesLoading || overviewLoading)
|
||
? 'Paketdetails werden geladen...'
|
||
: 'Für dieses Paket sind aktuell keine besonderen Features hinterlegt.'}
|
||
</p>
|
||
)}
|
||
<div className="flex flex-wrap gap-2">
|
||
<Button
|
||
type="button"
|
||
variant="secondary"
|
||
className="bg-white/20 text-white hover:bg-white/30"
|
||
onClick={() => navigate(ADMIN_BILLING_PATH)}
|
||
>
|
||
Abrechnung öffnen
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
className="text-white/70 hover:bg-white/10"
|
||
disabled
|
||
>
|
||
Upgrade-Optionen demnächst
|
||
</Button>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</AccordionContent>
|
||
</AccordionItem>
|
||
</Accordion>
|
||
</div>
|
||
</form>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
</AdminLayout>
|
||
);
|
||
}
|
||
|
||
function FormSkeleton() {
|
||
return (
|
||
<div className="space-y-4">
|
||
{Array.from({ length: 4 }).map((_, index) => (
|
||
<div key={index} className="h-12 animate-pulse rounded-lg bg-gradient-to-r from-white/40 via-white/60 to-white/40" />
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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, string>): string {
|
||
if (typeof name === 'string') {
|
||
return name;
|
||
}
|
||
return name.de ?? name.en ?? Object.values(name)[0] ?? '';
|
||
}
|