Files
fotospiel-app/resources/js/admin/pages/EventFormPage.tsx
2025-11-26 14:41:39 +01:00

765 lines
27 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 { 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 { getApiErrorMessage, 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: tErrors } = useTranslation('common', { keyPrefix: 'errors' });
const { t: tForm } = useTranslation('management', { keyPrefix: 'eventForm' });
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 || !loadedEvent || !eventTypes || eventTypes.length === 0) {
return;
}
if (loadedEvent.event_type_id) {
return;
}
setForm((prev) => ({
...prev,
eventTypeId: prev.eventTypeId ?? eventTypes[0]!.id,
}));
}, [eventTypes, 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(tForm('errors.nameRequired', 'Bitte gib einen Eventnamen ein.'));
return;
}
let finalSlug = form.slug.trim();
if (!finalSlug || autoSlug) {
finalSlug = buildAutoSlug(trimmedName);
}
if (!form.eventTypeId) {
setError(tForm('errors.typeRequired', '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,
...(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 ? tErrors('eventLimitDetails', { used, limit, remaining }) : '';
setError(`${tErrors('eventLimit')}${detail ? `\n${detail}` : ''}`);
setShowUpgradeHint(true);
break;
}
case 'event_credits_exhausted': {
setError(tErrors('creditsExhausted'));
setShowUpgradeHint(true);
break;
}
default: {
const metaErrors = Array.isArray(err.meta?.errors) ? err.meta.errors.filter(Boolean).join('\n') : null;
setError(metaErrors || err.message || tErrors('generic'));
setShowUpgradeHint(false);
}
}
} else {
setError(getApiErrorMessage(err, tErrors('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" /> {tForm('actions.backToList', 'Zurück zur Liste')}
</Button>
);
return (
<AdminLayout
title={isEdit ? tForm('titles.edit', 'Event bearbeiten') : tForm('titles.create', 'Neues Event erstellen')}
subtitle={tForm('subtitle', 'Fülle die wichtigsten Angaben aus und teile dein Event mit Gästen.')}
actions={actions}
>
{error && (
<Alert variant="destructive">
<AlertTitle>{tForm('errors.notice', '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)}>
{tErrors('goToBilling', 'Zum Billing')}
</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" /> {tForm('sections.details.title', 'Eventdetails')}
</CardTitle>
<CardDescription className="text-sm text-slate-600">
{tForm('sections.details.description', '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">{tForm('fields.name.label', 'Eventname')}</Label>
<Input
id="event-name"
placeholder={tForm('fields.name.placeholder', 'z. B. Sommerfest 2025')}
value={form.name}
onChange={(e) => handleNameChange(e.target.value)}
autoFocus
/>
<p className="text-xs text-slate-500">
{tForm('fields.name.help', 'Die Kennung und Event-URL werden automatisch aus dem Namen generiert.')}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="event-date">{tForm('fields.date.label', '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">{tForm('fields.type.label', '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
? tForm('fields.type.loading', 'Event-Typ wird geladen…')
: tForm('fields.type.placeholder', '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">
{tForm('fields.type.empty', '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">
{tForm('fields.publish.label', 'Event sofort veröffentlichen')}
</Label>
<p className="text-xs text-slate-600">
{tForm('fields.publish.help', '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" /> {tForm('actions.saving', 'Speichert')}
</>
) : (
<>
<Save className="h-4 w-4" /> {tForm('actions.save', 'Speichern')}
</>
)}
</Button>
<Button variant="ghost" type="button" onClick={() => navigate(-1)}>
{tForm('actions.cancel', '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] ?? '';
}