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:
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user