fixed event join token handling in the event admin. created new seeders with new tenants and package purchases. added new playwright test scenarios.

This commit is contained in:
Codex Agent
2025-10-26 14:44:47 +01:00
parent 6290a3a448
commit ecf5a23b28
59 changed files with 3900 additions and 691 deletions

View File

@@ -4,27 +4,50 @@ import { ArrowLeft, Loader2, Save, Sparkles } from 'lucide-react';
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 { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { AdminLayout } from '../components/AdminLayout';
import { createEvent, getEvent, updateEvent, getPackages } from '../api';
import { createEvent, getEvent, getTenantPackagesOverview, updateEvent, getPackages, getEventTypes } from '../api';
import { isAuthError } from '../auth/tokens';
import { ADMIN_EVENT_VIEW_PATH, ADMIN_EVENTS_PATH } from '../constants';
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();
@@ -36,7 +59,8 @@ export default function EventFormPage() {
name: '',
slug: '',
date: '',
package_id: 1, // Default Free package
eventTypeId: null,
package_id: 0,
isPublished: false,
});
const [autoSlug, setAutoSlug] = React.useState(true);
@@ -44,12 +68,65 @@ export default function EventFormPage() {
const [loading, setLoading] = React.useState(isEdit);
const [saving, setSaving] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
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]);
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(() => {
let cancelled = false;
if (!isEdit || !slugParam) {
@@ -69,9 +146,20 @@ export default function EventFormPage() {
name,
slug: event.slug,
date: event.event_date ? event.event_date.slice(0, 10) : '',
eventTypeId: event.event_type_id ?? prev.eventTypeId,
isPublished: event.status === 'published',
package_id: event.package?.id ? Number(event.package.id) : prev.package_id,
}));
setOriginalSlug(event.slug);
setReadOnlyPackageName(event.package?.name ?? null);
setEventPackageMeta(event.package
? {
id: Number(event.package.id),
name: event.package.name ?? (typeof event.package === 'string' ? event.package : ''),
purchasedAt: event.package.purchased_at ?? null,
expiresAt: event.package.expires_at ?? null,
}
: null);
setAutoSlug(false);
} catch (err) {
if (!isAuthError(err)) {
@@ -116,17 +204,30 @@ export default function EventFormPage() {
return;
}
if (!form.eventTypeId) {
setError('Bitte waehle einen Event-Typ aus.');
return;
}
setSaving(true);
setError(null);
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: trimmedSlug,
package_id: form.package_id,
date: form.date || undefined,
event_type_id: form.eventTypeId,
event_date: form.date || undefined,
status,
...(shouldIncludePackage && packageIdForSubmit
? { package_id: Number(packageIdForSubmit) }
: {}),
};
try {
@@ -148,6 +249,77 @@ export default function EventFormPage() {
}
}
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"
@@ -205,8 +377,8 @@ export default function EventFormPage() {
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.
Diese Kennung wird intern verwendet. Gaeste betreten dein Event ausschliesslich ueber ihre
Einladungslinks und die dazugehoerigen QR-Layouts.
</p>
</div>
<div className="space-y-2">
@@ -219,56 +391,107 @@ export default function EventFormPage() {
/>
</div>
<div className="space-y-2">
<Label htmlFor="package_id">Package</Label>
<Label htmlFor="event-type">Event-Typ</Label>
<Select
value={form.package_id.toString()}
onValueChange={(value) => setForm((prev) => ({ ...prev, package_id: parseInt(value, 10) }))}
disabled={packagesLoading || !packages?.length}
value={form.eventTypeId ? String(form.eventTypeId) : undefined}
onValueChange={(value) => setForm((prev) => ({ ...prev, eventTypeId: Number(value) }))}
disabled={eventTypesLoading || !eventTypes?.length}
>
<SelectTrigger>
<SelectValue placeholder={packagesLoading ? 'Pakete werden geladen...' : 'Waehlen Sie ein Package'} />
<SelectTrigger id="event-type">
<SelectValue
placeholder={eventTypesLoading ? 'Event-Typ wird geladen…' : 'Event-Typ auswaehlen'}
/>
</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}
<SelectContent>
{eventTypes?.map((eventType) => (
<SelectItem key={eventType.id} value={String(eventType.id)}>
{eventType.icon ? `${eventType.icon} ${eventType.name}` : eventType.name}
</SelectItem>
))}
</SelectContent>
</Select>
{packagesLoading ? (
<p className="text-xs text-slate-500">Pakete werden geladen...</p>
{!eventTypesLoading && (!eventTypes || eventTypes.length === 0) ? (
<p className="text-xs text-amber-600">
Keine Event-Typen verfuegbar. Bitte lege einen Typ im Adminbereich an.
</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>
<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>
</DialogContent>
</Dialog>
<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>
@@ -291,7 +514,7 @@ export default function EventFormPage() {
<div className="flex flex-wrap gap-3">
<Button
type="submit"
disabled={saving || !form.name.trim() || !form.slug.trim()}
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 ? (
@@ -326,6 +549,43 @@ function FormSkeleton() {
);
}
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')