Files
fotospiel-app/resources/js/admin/pages/EventFormPage.tsx
2025-10-31 20:19:09 +01:00

616 lines
21 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.
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,
TenantEvent,
} 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 slugSuffixRef = React.useRef<string | null>(null);
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]);
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;
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);
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);
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
/>
<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 || !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] ?? '';
}