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

@@ -1,6 +1,6 @@
import React from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { ArrowLeft, Camera, Download, Heart, Loader2, RefreshCw, Share2, Sparkles } from 'lucide-react';
import { ArrowLeft, Camera, Copy, Download, Heart, Loader2, RefreshCw, Share2, Sparkles } from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
@@ -10,6 +10,7 @@ import { AdminLayout } from '../components/AdminLayout';
import {
createInviteLink,
EventJoinToken,
EventJoinTokenLayout,
EventStats as TenantEventStats,
getEvent,
getEventJoinTokens,
@@ -151,7 +152,7 @@ export default function EventDetailPage() {
}));
} catch (err) {
if (!isAuthError(err)) {
setState((prev) => ({ ...prev, error: 'Token konnte nicht deaktiviert werden.' }));
setState((prev) => ({ ...prev, error: 'Einladung konnte nicht deaktiviert werden.' }));
}
} finally {
setRevokingId(null);
@@ -263,22 +264,22 @@ export default function EventDetailPage() {
<Card id="join-invites" className="border-0 bg-white/90 shadow-xl shadow-amber-100/60">
<CardHeader className="space-y-2">
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
<Share2 className="h-5 w-5 text-amber-500" /> Einladungen & Drucklayouts
<Share2 className="h-5 w-5 text-amber-500" /> Einladungslinks &amp; QR-Layouts
</CardTitle>
<CardDescription className="text-sm text-slate-600">
Verwalte Join-Tokens fuer dein Event. Jede Einladung enthaelt einen eindeutigen Token, QR-Code und
downloadbare PDF/SVG-Layouts.
Teile Gaesteeinladungen als Link oder drucke sie als fertige Layouts mit QR-Code - ganz ohne technisches
Vokabular.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4 text-sm text-slate-700">
<div className="space-y-2 rounded-xl border border-amber-100 bg-amber-50/70 p-3 text-xs text-amber-800">
<p>
Teile den generierten Link oder drucke die Layouts aus, um Gaeste sicher ins Event zu leiten. Tokens lassen
sich jederzeit rotieren oder deaktivieren.
Teile den generierten Link oder drucke eine Vorlage aus, um Gaeste sicher ins Event zu leiten. Einladungen
kannst du jederzeit erneuern oder deaktivieren.
</p>
{tokens.length > 0 && (
<p className="flex items-center gap-2 text-[11px] uppercase tracking-wide text-amber-600">
Aktive Tokens: {tokens.filter((token) => token.is_active && !token.revoked_at).length} · Gesamt:{' '}
Aktive Einladungen: {tokens.filter((token) => token.is_active && !token.revoked_at).length} · Gesamt:{' '}
{tokens.length}
</p>
)}
@@ -286,7 +287,7 @@ export default function EventDetailPage() {
<Button onClick={handleInvite} disabled={creatingToken} className="w-full">
{creatingToken ? <Loader2 className="h-4 w-4 animate-spin" /> : <Share2 className="h-4 w-4" />}
Join-Token erzeugen
Einladung erstellen
</Button>
{inviteLink && (
@@ -298,7 +299,7 @@ export default function EventDetailPage() {
<div className="space-y-3">
{tokens.length > 0 ? (
tokens.map((token) => (
<JoinTokenRow
<InvitationCard
key={token.id}
token={token}
onCopy={() => handleCopy(token)}
@@ -308,8 +309,8 @@ export default function EventDetailPage() {
))
) : (
<div className="rounded-lg border border-slate-200 bg-white/80 p-4 text-xs text-slate-500">
Noch keine Tokens vorhanden. Erzeuge jetzt den ersten Token, um QR-Codes und Drucklayouts
herunterzuladen.
Es gibt noch keine Einladungslinks. Erstelle jetzt den ersten Link, um QR-Layouts mit QR-Code
herunterzuladen und zu teilen.
</div>
)}
</div>
@@ -371,7 +372,7 @@ function StatChip({ label, value }: { label: string; value: string | number }) {
);
}
function JoinTokenRow({
function InvitationCard({
token,
onCopy,
onRevoke,
@@ -383,121 +384,150 @@ function JoinTokenRow({
revoking: boolean;
}) {
const status = getTokenStatus(token);
const availableLayouts = Array.isArray(token.layouts) ? token.layouts : [];
const layouts = Array.isArray(token.layouts) ? token.layouts : [];
const usageLabel = token.usage_limit ? `${token.usage_count} / ${token.usage_limit}` : `${token.usage_count}`;
const metadata = (token.metadata ?? {}) as Record<string, unknown>;
const isAutoGenerated = Boolean(metadata.auto_generated);
const statusClassname =
status === 'Aktiv'
? 'bg-emerald-100 text-emerald-700'
: status === 'Abgelaufen'
? 'bg-orange-100 text-orange-700'
: 'bg-slate-200 text-slate-700';
return (
<div className="flex flex-col gap-3 rounded-xl border border-amber-100 bg-amber-50/60 p-3">
<div className="space-y-3">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-slate-800">{token.label || `Einladung #${token.id}`}</span>
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
status === 'Aktiv'
? 'bg-emerald-100 text-emerald-700'
: status === 'Abgelaufen'
? 'bg-orange-100 text-orange-700'
: 'bg-slate-200 text-slate-700'
}`}
>
{status}
</span>
</div>
<p className="break-all font-mono text-xs text-slate-600">{token.url}</p>
<div className="flex flex-wrap gap-3 text-xs text-slate-500">
<span>
Nutzung: {token.usage_count}
{token.usage_limit ? ` / ${token.usage_limit}` : ''}
</span>
{token.expires_at && <span>Gültig bis {formatDateTime(token.expires_at)}</span>}
{token.created_at && <span>Erstellt {formatDateTime(token.created_at)}</span>}
</div>
{availableLayouts.length > 0 && (
<div className="space-y-3 rounded-xl border border-amber-100 bg-white/80 p-3">
<div className="text-xs font-semibold uppercase tracking-wide text-amber-600">Drucklayouts</div>
<div className="grid gap-3 sm:grid-cols-2">
{availableLayouts.map((layout) => {
const formatEntries = Array.isArray(layout.formats)
? layout.formats
.map((format) => {
const normalized = String(format ?? '').toLowerCase();
const href =
layout.download_urls?.[normalized] ??
layout.download_urls?.[String(format ?? '')] ??
null;
return {
format: normalized,
label: String(format ?? '').toUpperCase(),
href,
};
})
.filter((entry) => Boolean(entry.href))
: [];
if (formatEntries.length === 0) {
return null;
}
return (
<div key={layout.id} className="flex flex-col gap-2 rounded-lg border border-amber-200 bg-white p-3 shadow-sm">
<div>
<div className="text-sm font-semibold text-slate-800">{layout.name}</div>
{layout.subtitle && <div className="text-xs text-slate-500">{layout.subtitle}</div>}
</div>
<div className="flex flex-wrap gap-2">
{formatEntries.map((entry) => (
<Button
asChild
key={`${layout.id}-${entry.format}`}
size="sm"
variant="outline"
className="border-amber-200 text-amber-700 hover:bg-amber-100"
>
<a href={entry.href as string} target="_blank" rel="noreferrer">
<Download className="mr-1 h-3 w-3" />
{entry.label}
</a>
</Button>
))}
</div>
</div>
);
})}
</div>
<div className="flex flex-col gap-4 rounded-2xl border border-amber-100 bg-white/90 p-4 shadow-md shadow-amber-100/40">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold text-slate-900">{token.label?.trim() || `Einladung #${token.id}`}</span>
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${statusClassname}`}>{status}</span>
{isAutoGenerated ? (
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-medium text-amber-700">
Standard
</span>
) : null}
</div>
)}
{!availableLayouts.length && token.layouts_url && (
<div className="rounded-xl border border-amber-100 bg-white/70 p-3 text-xs text-slate-600">
Drucklayouts stehen für diesen Token bereit. Öffne den Layout-Link, um PDF- oder SVG-Versionen zu laden.
<div className="flex flex-wrap items-center gap-2">
<span className="break-all rounded-md border border-slate-200 bg-slate-50 px-2 py-1 font-mono text-xs text-slate-700">
{token.url}
</span>
<Button
variant="outline"
size="sm"
onClick={onCopy}
className="border-amber-200 text-amber-700 hover:bg-amber-100"
>
<Copy className="mr-1 h-3 w-3" />
Link kopieren
</Button>
</div>
)}
</div>
<div className="flex flex-wrap gap-2 md:items-center md:justify-start">
{token.layouts_url && (
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-slate-500">
<span>Nutzung: {usageLabel}</span>
{token.expires_at ? <span>Gültig bis {formatDateTime(token.expires_at)}</span> : null}
{token.created_at ? <span>Erstellt am {formatDateTime(token.created_at)}</span> : null}
</div>
</div>
<div className="flex flex-wrap gap-2">
{token.layouts_url ? (
<Button
asChild
size="sm"
variant="outline"
className="border-amber-200 text-amber-700 hover:bg-amber-100"
>
<a href={token.layouts_url} target="_blank" rel="noreferrer">
<Download className="mr-1 h-3 w-3" />
Layout-Übersicht
</a>
</Button>
) : null}
<Button
asChild
variant="ghost"
size="sm"
variant="outline"
className="border-amber-200 text-amber-700 hover:bg-amber-100"
onClick={onRevoke}
disabled={revoking || token.revoked_at !== null || !token.is_active}
className="text-slate-600 hover:bg-slate-100 disabled:opacity-50"
>
<a href={token.layouts_url} target="_blank" rel="noreferrer">
<Download className="h-3 w-3" />
<span className="ml-1">Layouts</span>
</a>
{revoking ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Deaktivieren'}
</Button>
)}
<Button variant="outline" size="sm" onClick={onCopy} className="border-amber-200 text-amber-700 hover:bg-amber-100">
Kopieren
</Button>
<Button
variant="ghost"
size="sm"
onClick={onRevoke}
disabled={revoking || token.revoked_at !== null || !token.is_active}
className="text-slate-600 hover:bg-slate-100 disabled:opacity-50"
>
{revoking ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Deaktivieren'}
</Button>
</div>
</div>
{layouts.length > 0 ? (
<div className="grid gap-3 sm:grid-cols-2">
{layouts.map((layout) => (
<LayoutPreviewCard key={layout.id} layout={layout} />
))}
</div>
) : token.layouts_url ? (
<div className="rounded-xl border border-amber-100 bg-amber-50/60 p-3 text-xs text-amber-800">
Für diese Einladung stehen Layouts bereit. Öffne die Übersicht, um PDF- oder SVG-Versionen zu laden.
</div>
) : null}
</div>
);
}
function LayoutPreviewCard({ layout }: { layout: EventJoinTokenLayout }) {
const gradient = layout.preview?.background_gradient;
const stops = Array.isArray(gradient?.stops) ? gradient?.stops ?? [] : [];
const gradientStyle = stops.length
? {
backgroundImage: `linear-gradient(${gradient?.angle ?? 135}deg, ${stops.join(', ')})`,
}
: {
backgroundColor: layout.preview?.background ?? '#F8FAFC',
};
const textColor = layout.preview?.text ?? '#0F172A';
const formats = Array.isArray(layout.formats) ? layout.formats : [];
return (
<div className="overflow-hidden rounded-xl border border-amber-100 bg-white shadow-sm">
<div className="relative h-28">
<div className="absolute inset-0" style={gradientStyle} />
<div className="absolute inset-0 flex flex-col justify-between p-3 text-xs" style={{ color: textColor }}>
<span className="w-fit rounded-full bg-white/30 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide">
QR-Layout
</span>
<div>
<div className="text-sm font-semibold leading-tight">{layout.name}</div>
{layout.subtitle ? (
<div className="text-[11px] opacity-80">{layout.subtitle}</div>
) : null}
</div>
</div>
</div>
<div className="space-y-3 p-3">
{layout.description ? <p className="text-xs text-slate-600">{layout.description}</p> : null}
<div className="flex flex-wrap gap-2">
{formats.map((format) => {
const key = String(format ?? '').toLowerCase();
const href = layout.download_urls?.[key] ?? layout.download_urls?.[String(format ?? '')] ?? null;
if (!href) {
return null;
}
const label = String(format ?? '').toUpperCase() || 'PDF';
return (
<Button
asChild
key={`${layout.id}-${label}`}
size="sm"
variant="outline"
className="border-amber-200 text-amber-700 hover:bg-amber-100"
>
<a href={href} target="_blank" rel="noreferrer">
<Download className="mr-1 h-3 w-3" />
{label}
</a>
</Button>
);
})}
</div>
</div>
</div>
);
@@ -547,4 +577,3 @@ function renderName(name: TenantEvent['name']): string {
}
return 'Unbenanntes Event';
}