605 lines
21 KiB
TypeScript
605 lines
21 KiB
TypeScript
import React from 'react';
|
||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||
import { ArrowLeft, Loader2, Save, Sparkles } from 'lucide-react';
|
||
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 } from '../api';
|
||
import { isAuthError } from '../auth/tokens';
|
||
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 [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 [loading, setLoading] = React.useState(isEdit);
|
||
const [saving, setSaving] = React.useState(false);
|
||
const [error, setError] = React.useState<string | null>(null);
|
||
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 { 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]);
|
||
|
||
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(() => {
|
||
let cancelled = false;
|
||
if (!isEdit || !slugParam) {
|
||
setLoading(false);
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}
|
||
|
||
(async () => {
|
||
try {
|
||
const event = await getEvent(slugParam);
|
||
if (cancelled) return;
|
||
const name = normalizeName(event.name);
|
||
setForm((prev) => ({
|
||
...prev,
|
||
name,
|
||
slug: event.slug,
|
||
date: event.event_date ? event.event_date.slice(0, 10) : '',
|
||
eventTypeId: event.event_type_id ?? prev.eventTypeId,
|
||
isPublished: event.status === 'published',
|
||
package_id: event.package?.id ? Number(event.package.id) : prev.package_id,
|
||
}));
|
||
setOriginalSlug(event.slug);
|
||
setReadOnlyPackageName(event.package?.name ?? null);
|
||
setEventPackageMeta(event.package
|
||
? {
|
||
id: Number(event.package.id),
|
||
name: event.package.name ?? (typeof event.package === 'string' ? event.package : ''),
|
||
purchasedAt: event.package.purchased_at ?? null,
|
||
expiresAt: event.package.expires_at ?? null,
|
||
}
|
||
: null);
|
||
setAutoSlug(false);
|
||
} catch (err) {
|
||
if (!isAuthError(err)) {
|
||
setError('Event konnte nicht geladen werden.');
|
||
}
|
||
} finally {
|
||
if (!cancelled) {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
})();
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [isEdit, slugParam]);
|
||
|
||
function handleNameChange(value: string) {
|
||
setForm((prev) => ({ ...prev, name: value }));
|
||
if (autoSlug) {
|
||
setForm((prev) => ({ ...prev, slug: slugify(value) }));
|
||
}
|
||
}
|
||
|
||
function handleSlugChange(value: string) {
|
||
setAutoSlug(false);
|
||
setForm((prev) => ({ ...prev, slug: slugify(value) }));
|
||
}
|
||
|
||
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||
event.preventDefault();
|
||
const trimmedName = form.name.trim();
|
||
const trimmedSlug = form.slug.trim();
|
||
|
||
if (!trimmedName) {
|
||
setError('Bitte gib einen Eventnamen ein.');
|
||
return;
|
||
}
|
||
|
||
if (!trimmedSlug) {
|
||
setError('Bitte wähle einen Slug für die Event-URL.');
|
||
return;
|
||
}
|
||
|
||
if (!form.eventTypeId) {
|
||
setError('Bitte wähle einen Event-Typ aus.');
|
||
return;
|
||
}
|
||
|
||
setSaving(true);
|
||
setError(null);
|
||
|
||
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: trimmedSlug,
|
||
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);
|
||
navigate(ADMIN_EVENT_VIEW_PATH(updated.slug));
|
||
} else {
|
||
const { event: created } = await createEvent(payload);
|
||
navigate(ADMIN_EVENT_VIEW_PATH(created.slug));
|
||
}
|
||
} catch (err) {
|
||
if (!isAuthError(err)) {
|
||
setError('Speichern fehlgeschlagen. Bitte prüfe deine Eingaben.');
|
||
}
|
||
} 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 [];
|
||
}
|
||
|
||
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 = (
|
||
<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>{error}</AlertDescription>
|
||
</Alert>
|
||
)}
|
||
|
||
<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
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="event-slug">Slug / interne Kennung</Label>
|
||
<Input
|
||
id="event-slug"
|
||
placeholder="sommerfest-2025"
|
||
value={form.slug}
|
||
onChange={(e) => handleSlugChange(e.target.value)}
|
||
/>
|
||
<p className="text-xs text-slate-500">
|
||
Diese Kennung wird intern verwendet. Gäste betreten dein Event ausschließlich über ihre
|
||
Einladungslinks und die dazugehörigen QR-Layouts.
|
||
</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 || !eventTypes?.length}
|
||
>
|
||
<SelectTrigger id="event-type">
|
||
<SelectValue
|
||
placeholder={eventTypesLoading ? 'Event-Typ wird geladen…' : 'Event-Typ auswählen'}
|
||
/>
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{eventTypes?.map((eventType) => (
|
||
<SelectItem key={eventType.id} value={String(eventType.id)}>
|
||
{eventType.icon ? `${eventType.icon} ${eventType.name}` : eventType.name}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
{!eventTypesLoading && (!eventTypes || eventTypes.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 className="sm:col-span-2">
|
||
<Card className="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="flex flex-wrap items-center justify-between gap-3">
|
||
<Badge className="bg-white/25 text-white backdrop-blur">
|
||
{isEdit ? 'Gebuchtes Paket' : 'Aktives Paket'}
|
||
</Badge>
|
||
{packagePriceLabel ? (
|
||
<span className="text-sm font-semibold uppercase tracking-widest text-white/90">
|
||
{packagePriceLabel}
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
<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>
|
||
</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>
|
||
</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] ?? '';
|
||
}
|