feat(tenant-admin): refresh event management experience
This commit is contained in:
@@ -1,57 +1,273 @@
|
||||
import React from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { createEvent, updateEvent } from '../api';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { ArrowLeft, Loader2, Save, Sparkles } from 'lucide-react';
|
||||
|
||||
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 { AdminLayout } from '../components/AdminLayout';
|
||||
import { createEvent, getEvent, updateEvent } from '../api';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
|
||||
interface EventFormState {
|
||||
name: string;
|
||||
slug: string;
|
||||
date: string;
|
||||
isPublished: boolean;
|
||||
}
|
||||
|
||||
export default function EventFormPage() {
|
||||
const [sp] = useSearchParams();
|
||||
const id = sp.get('id');
|
||||
const nav = useNavigate();
|
||||
const [name, setName] = React.useState('');
|
||||
const [slug, setSlug] = React.useState('');
|
||||
const [date, setDate] = React.useState('');
|
||||
const [active, setActive] = React.useState(true);
|
||||
const [searchParams] = useSearchParams();
|
||||
const slugParam = searchParams.get('slug');
|
||||
const isEdit = Boolean(slugParam);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [form, setForm] = React.useState<EventFormState>({
|
||||
name: '',
|
||||
slug: '',
|
||||
date: '',
|
||||
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 isEdit = !!id;
|
||||
|
||||
async function save() {
|
||||
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({
|
||||
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 payload = {
|
||||
name: trimmedName,
|
||||
slug: trimmedSlug,
|
||||
date: form.date || undefined,
|
||||
status: form.isPublished ? 'published' : 'draft',
|
||||
};
|
||||
|
||||
try {
|
||||
if (isEdit) {
|
||||
await updateEvent(Number(id), { name, slug, date, is_active: active });
|
||||
const targetSlug = originalSlug ?? slugParam!;
|
||||
const updated = await updateEvent(targetSlug, payload);
|
||||
setOriginalSlug(updated.slug);
|
||||
navigate(`/admin/events/view?slug=${encodeURIComponent(updated.slug)}`);
|
||||
} else {
|
||||
await createEvent({ name, slug, date, is_active: active });
|
||||
const { event: created } = await createEvent(payload);
|
||||
navigate(`/admin/events/view?slug=${encodeURIComponent(created.slug)}`);
|
||||
}
|
||||
nav('/admin/events');
|
||||
} catch (e) {
|
||||
if (!isAuthError(e)) {
|
||||
setError('Speichern fehlgeschlagen');
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setError('Speichern fehlgeschlagen. Bitte pruefe deine Eingaben.');
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const actions = (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => navigate('/admin/events')}
|
||||
className="border-pink-200 text-pink-600 hover:bg-pink-50"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" /> Zurueck zur Liste
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-md space-y-3 p-4">
|
||||
<h1 className="text-lg font-semibold">{isEdit ? 'Event bearbeiten' : 'Neues Event'}</h1>
|
||||
{error && <div className="rounded border border-red-300 bg-red-50 p-2 text-sm text-red-700">{error}</div>}
|
||||
<Input placeholder="Name" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
<Input placeholder="Slug" value={slug} onChange={(e) => setSlug(e.target.value)} />
|
||||
<Input placeholder="Datum" type="date" value={date} onChange={(e) => setDate(e.target.value)} />
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={active} onChange={(e) => setActive(e.target.checked)} /> Aktiv
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={save} disabled={saving || !name || !slug}>
|
||||
{saving ? 'Speichern <20>' : 'Speichern'}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => nav(-1)}>Abbrechen</Button>
|
||||
</div>
|
||||
<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 / URL-Endung</Label>
|
||||
<Input
|
||||
id="event-slug"
|
||||
placeholder="sommerfest-2025"
|
||||
value={form.slug}
|
||||
onChange={(e) => handleSlugChange(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-slate-500">Das Event ist spaeter unter /e/{form.slug || 'dein-event'} erreichbar.</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>
|
||||
|
||||
<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] ?? '';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user