Files
fotospiel-app/resources/js/admin/pages/EventFormPage.tsx
Codex Agent 6290a3a448 Fix tenant event form package selector so it no longer renders empty-value options, handles loading/empty
states, and pulls data from the authenticated /api/v1/tenant/packages endpoint.
    (resources/js/admin/pages/EventFormPage.tsx, resources/js/admin/api.ts)
  - Harden tenant-admin auth flow: prevent PKCE state loss, scope out StrictMode double-processing, add SPA
    routes for /event-admin/login and /event-admin/logout, and tighten token/session clearing semantics (resources/js/admin/auth/{context,tokens}.tsx, resources/js/admin/pages/{AuthCallbackPage,LogoutPage}.tsx,
    resources/js/admin/router.tsx, routes/web.php)
2025-10-19 23:00:47 +02:00

345 lines
12 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 { 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 { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { AdminLayout } from '../components/AdminLayout';
import { createEvent, getEvent, updateEvent, getPackages } from '../api';
import { isAuthError } from '../auth/tokens';
import { ADMIN_EVENT_VIEW_PATH, ADMIN_EVENTS_PATH } from '../constants';
interface EventFormState {
name: string;
slug: string;
date: string;
package_id: number;
isPublished: boolean;
}
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: '',
package_id: 1, // Default Free package
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 { data: packages, isLoading: packagesLoading } = useQuery({
queryKey: ['packages', 'endcustomer'],
queryFn: () => getPackages('endcustomer'),
});
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) : '',
isPublished: event.status === 'published',
}));
setOriginalSlug(event.slug);
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 waehle einen Slug fuer die Event-URL.');
return;
}
setSaving(true);
setError(null);
const status: 'draft' | 'published' | 'archived' = form.isPublished ? 'published' : 'draft';
const payload = {
name: trimmedName,
slug: trimmedSlug,
package_id: form.package_id,
date: form.date || undefined,
status,
};
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 pruefe deine Eingaben.');
}
} finally {
setSaving(false);
}
}
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" /> Zurueck zur Liste
</Button>
);
return (
<AdminLayout
title={isEdit ? 'Event bearbeiten' : 'Neues Event erstellen'}
subtitle="Fuelle die wichtigsten Angaben aus und teile dein Event mit Gaesten."
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 Gaesteportal.
</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. Gaeste erhalten Zugriff ausschliesslich ueber Join-Tokens und deren
QR-/Layout-Downloads.
</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="package_id">Package</Label>
<Select
value={form.package_id.toString()}
onValueChange={(value) => setForm((prev) => ({ ...prev, package_id: parseInt(value, 10) }))}
disabled={packagesLoading || !packages?.length}
>
<SelectTrigger>
<SelectValue placeholder={packagesLoading ? 'Pakete werden geladen...' : 'Waehlen Sie ein Package'} />
</SelectTrigger>
{packages?.length ? (
<SelectContent>
{packages.map((pkg) => (
<SelectItem key={pkg.id} value={pkg.id.toString()}>
{pkg.name} - {pkg.price} EUR ({pkg.max_photos} Fotos)
</SelectItem>
))}
</SelectContent>
) : null}
</Select>
{packagesLoading ? (
<p className="text-xs text-slate-500">Pakete werden geladen...</p>
) : null}
{!packagesLoading && (!packages || packages.length === 0) ? (
<p className="text-xs text-red-500">Keine Pakete verfuegbar. Bitte pruefen Sie Ihre Einstellungen.</p>
) : null}
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="sm">Package-Details</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Package auswaehlen</DialogTitle>
<DialogDescription>Waehlen Sie das Package fuer Ihr Event. Hoehere Packages bieten mehr Limits und Features.</DialogDescription>
</DialogHeader>
<div className="space-y-2">
{packages?.map((pkg) => (
<div key={pkg.id} className="p-4 border rounded">
<h3 className="font-semibold">{pkg.name}</h3>
<p>{pkg.price} EUR</p>
<ul className="text-sm">
<li>Max Fotos: {pkg.max_photos}</li>
<li>Max Gaeste: {pkg.max_guests}</li>
<li>Galerie: {pkg.gallery_days} Tage</li>
<li>Features: {Object.keys(pkg.features).filter(k => pkg.features[k]).join(', ')}</li>
</ul>
</div>
))}
</div>
</DialogContent>
</Dialog>
</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 Gaeste das Event direkt sehen sollen. Du kannst den Status spaeter aendern.
</p>
</div>
</div>
<div className="flex flex-wrap gap-3">
<Button
type="submit"
disabled={saving || !form.name.trim() || !form.slug.trim()}
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 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] ?? '';
}