- Brand/Theming: Marketing-Farb- und Typographievariablen in `resources/css/app.css` eingeführt, AdminLayout, Dashboardkarten und Onboarding-Komponenten entsprechend angepasst; Dokumentation (`docs/todo/tenant-admin-onboarding-fusion.md`, `docs/changes/...`) aktualisiert. - Checkout & Payments: Checkout-, PayPal-Controller und Tests für integrierte Stripe/PayPal-Flows sowie Paket-Billing-Abläufe überarbeitet; neue PayPal SDK-Factory und Admin-API-Helper (`resources/js/admin/api.ts`) schaffen Grundlage für Billing/Members/Tasks-Seiten. - DX & Tests: Neue Playwright/E2E-Struktur (docs/testing/e2e.md, `tests/e2e/tenant-onboarding-flow.test.ts`, Utilities), E2E-Tenant-Seeder und zusätzliche Übersetzungen/Factories zur Unterstützung der neuen Flows. - Marketing-Kommunikation: Automatische Kontakt-Bestätigungsmail (`ContactConfirmation` + Blade-Template) implementiert; Guest-PWA unter `/event` erreichbar. - Nebensitzung: Blogsystem gefixt und umfassenden BlogPostSeeder für Beispielinhalte angelegt.
334 lines
12 KiB
TypeScript
334 lines
12 KiB
TypeScript
import React from 'react';
|
|
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
|
import { ArrowLeft, Loader2, Save, Sparkles, Package as PackageIcon } 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 / 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 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) }))}>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Waehlen Sie ein Package" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{packagesLoading ? (
|
|
<SelectItem value="">Laden...</SelectItem>
|
|
) : (
|
|
packages?.map((pkg) => (
|
|
<SelectItem key={pkg.id} value={pkg.id.toString()}>
|
|
{pkg.name} - {pkg.price} EUR ({pkg.max_photos} Fotos)
|
|
</SelectItem>
|
|
))
|
|
)}
|
|
</SelectContent>
|
|
</Select>
|
|
<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] ?? '';
|
|
}
|