Files
fotospiel-app/resources/js/admin/pages/EventFormPage.tsx

717 lines
24 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 { 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 { 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 [];
}
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 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 || !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] ?? '';
}