feat: extend event toolkit and polish guest pwa

This commit is contained in:
Codex Agent
2025-10-28 18:28:22 +01:00
parent f29067f570
commit a7bbf230fd
45 changed files with 3809 additions and 351 deletions

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { ArrowLeft, Camera, Copy, Download, Heart, Loader2, RefreshCw, Share2, Sparkles } from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
@@ -8,17 +9,19 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { AdminLayout } from '../components/AdminLayout';
import {
createInviteLink,
EventJoinToken,
EventJoinTokenLayout,
createQrInvite,
EventQrInvite,
EventQrInviteLayout,
EventStats as TenantEventStats,
getEvent,
getEventJoinTokens,
getEventQrInvites,
getEventStats,
TenantEvent,
toggleEvent,
revokeEventJoinToken,
revokeEventQrInvite,
updateEventQrInvite,
} from '../api';
import QrInviteCustomizationDialog, { QrLayoutCustomization } from './components/QrInviteCustomizationDialog';
import { isAuthError } from '../auth/tokens';
import {
ADMIN_EVENTS_PATH,
@@ -26,12 +29,13 @@ import {
ADMIN_EVENT_PHOTOS_PATH,
ADMIN_EVENT_MEMBERS_PATH,
ADMIN_EVENT_TASKS_PATH,
ADMIN_EVENT_TOOLKIT_PATH,
} from '../constants';
interface State {
event: TenantEvent | null;
stats: TenantEventStats | null;
tokens: EventJoinToken[];
invites: EventQrInvite[];
inviteLink: string | null;
error: string | null;
loading: boolean;
@@ -47,14 +51,16 @@ export default function EventDetailPage() {
const [state, setState] = React.useState<State>({
event: null,
stats: null,
tokens: [],
invites: [],
inviteLink: null,
error: null,
loading: true,
busy: false,
});
const [creatingToken, setCreatingToken] = React.useState(false);
const [creatingInvite, setCreatingInvite] = React.useState(false);
const [revokingId, setRevokingId] = React.useState<number | null>(null);
const [customizingInvite, setCustomizingInvite] = React.useState<EventQrInvite | null>(null);
const [customizerSaving, setCustomizerSaving] = React.useState(false);
const load = React.useCallback(async () => {
if (!slug) {
@@ -64,22 +70,22 @@ export default function EventDetailPage() {
setState((prev) => ({ ...prev, loading: true, error: null }));
try {
const [eventData, statsData, joinTokens] = await Promise.all([
const [eventData, statsData, qrInvites] = await Promise.all([
getEvent(slug),
getEventStats(slug),
getEventJoinTokens(slug),
getEventQrInvites(slug),
]);
setState((prev) => ({
...prev,
event: eventData,
stats: statsData,
tokens: joinTokens,
invites: qrInvites,
loading: false,
inviteLink: prev.inviteLink,
}));
} catch (err) {
if (isAuthError(err)) return;
setState((prev) => ({ ...prev, error: 'Event konnte nicht geladen werden.', loading: false, tokens: [] }));
setState((prev) => ({ ...prev, error: 'Event konnte nicht geladen werden.', loading: false, invites: [] }));
}
}, [slug]);
@@ -108,58 +114,131 @@ export default function EventDetailPage() {
}
async function handleInvite() {
if (!slug || creatingToken) return;
setCreatingToken(true);
if (!slug || creatingInvite) return;
setCreatingInvite(true);
setState((prev) => ({ ...prev, error: null }));
try {
const token = await createInviteLink(slug);
const invite = await createQrInvite(slug);
setState((prev) => ({
...prev,
inviteLink: token.url,
tokens: [token, ...prev.tokens.filter((t) => t.id !== token.id)],
inviteLink: invite.url,
invites: [invite, ...prev.invites.filter((existing) => existing.id !== invite.id)],
}));
try {
await navigator.clipboard.writeText(token.url);
await navigator.clipboard.writeText(invite.url);
} catch {
// clipboard may be unavailable, ignore silently
}
} catch (err) {
if (!isAuthError(err)) {
setState((prev) => ({ ...prev, error: 'Einladungslink konnte nicht erzeugt werden.' }));
setState((prev) => ({ ...prev, error: 'QR-Einladung konnte nicht erzeugt werden.' }));
}
}
setCreatingToken(false);
setCreatingInvite(false);
}
async function handleCopy(token: EventJoinToken) {
async function handleCopy(invite: EventQrInvite) {
try {
await navigator.clipboard.writeText(token.url);
setState((prev) => ({ ...prev, inviteLink: token.url }));
await navigator.clipboard.writeText(invite.url);
setState((prev) => ({ ...prev, inviteLink: invite.url }));
} catch (err) {
console.warn('Clipboard copy failed', err);
}
}
async function handleRevoke(token: EventJoinToken) {
if (!slug || token.revoked_at) return;
setRevokingId(token.id);
async function handleRevoke(invite: EventQrInvite) {
if (!slug || invite.revoked_at) return;
setRevokingId(invite.id);
setState((prev) => ({ ...prev, error: null }));
try {
const updated = await revokeEventJoinToken(slug, token.id);
const updated = await revokeEventQrInvite(slug, invite.id);
setState((prev) => ({
...prev,
tokens: prev.tokens.map((existing) => (existing.id === updated.id ? updated : existing)),
invites: prev.invites.map((existing) => (existing.id === updated.id ? updated : existing)),
}));
} catch (err) {
if (!isAuthError(err)) {
setState((prev) => ({ ...prev, error: 'Einladung konnte nicht deaktiviert werden.' }));
setState((prev) => ({ ...prev, error: 'QR-Einladung konnte nicht deaktiviert werden.' }));
}
} finally {
setRevokingId(null);
}
}
const { event, stats, tokens, inviteLink, error, loading, busy } = state;
function openCustomizer(invite: EventQrInvite) {
setState((prev) => ({ ...prev, error: null }));
setCustomizingInvite(invite);
}
function closeCustomizer() {
if (customizerSaving) {
return;
}
setCustomizingInvite(null);
}
async function handleApplyCustomization(customization: QrLayoutCustomization) {
if (!slug || !customizingInvite) {
return;
}
setCustomizerSaving(true);
setState((prev) => ({ ...prev, error: null }));
try {
const updated = await updateEventQrInvite(slug, customizingInvite.id, {
metadata: {
layout_customization: customization,
},
});
setState((prev) => ({
...prev,
invites: prev.invites.map((invite) => (invite.id === updated.id ? updated : invite)),
}));
setCustomizerSaving(false);
setCustomizingInvite(null);
} catch (err) {
if (!isAuthError(err)) {
setState((prev) => ({ ...prev, error: 'QR-Einladung konnte nicht gespeichert werden.' }));
}
setCustomizerSaving(false);
}
}
async function handleResetCustomization() {
if (!slug || !customizingInvite) {
return;
}
setCustomizerSaving(true);
setState((prev) => ({ ...prev, error: null }));
try {
const updated = await updateEventQrInvite(slug, customizingInvite.id, {
metadata: {
layout_customization: null,
},
});
setState((prev) => ({
...prev,
invites: prev.invites.map((invite) => (invite.id === updated.id ? updated : invite)),
}));
setCustomizerSaving(false);
setCustomizingInvite(null);
} catch (err) {
if (!isAuthError(err)) {
setState((prev) => ({ ...prev, error: 'Anpassungen konnten nicht zurückgesetzt werden.' }));
}
setCustomizerSaving(false);
}
}
const { event, stats, invites, inviteLink, error, loading, busy } = state;
const eventDisplayName = event ? renderName(event.name) : '';
const currentCustomization = React.useMemo(() => {
if (!customizingInvite) {
return null;
}
const metadata = customizingInvite.metadata as Record<string, unknown> | undefined | null;
const raw = metadata?.layout_customization;
return raw && typeof raw === 'object' ? (raw as QrLayoutCustomization) : null;
}, [customizingInvite]);
const actions = (
<>
@@ -193,6 +272,13 @@ export default function EventDetailPage() {
>
Tasks
</Button>
<Button
variant="outline"
onClick={() => navigate(ADMIN_EVENT_TOOLKIT_PATH(event.slug))}
className="border-emerald-200 text-emerald-600 hover:bg-emerald-50"
>
Event-Day Toolkit
</Button>
</>
)}
</>
@@ -261,33 +347,33 @@ export default function EventDetailPage() {
</CardContent>
</Card>
<Card id="join-invites" className="border-0 bg-white/90 shadow-xl shadow-amber-100/60">
<Card id="qr-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" /> Einladungslinks &amp; QR-Layouts
<Share2 className="h-5 w-5 text-amber-500" /> QR-Einladungen &amp; Drucklayouts
</CardTitle>
<CardDescription className="text-sm text-slate-600">
Teile Gaesteeinladungen als Link oder drucke sie als fertige Layouts mit QR-Code - ganz ohne technisches
Vokabular.
Erzeuge QR-Codes für eure Gäste und ladet direkt passende A4-Vorlagen inklusive Branding und Anleitungen
zum Ausdrucken herunter.
</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 eine Vorlage aus, um Gaeste sicher ins Event zu leiten. Einladungen
kannst du jederzeit erneuern oder deaktivieren.
Drucke die Layouts aus, platziere sie am Eventort oder teile den QR-Link digital. Du kannst Einladungen
jederzeit erneuern oder deaktivieren.
</p>
{tokens.length > 0 && (
{invites.length > 0 && (
<p className="flex items-center gap-2 text-[11px] uppercase tracking-wide text-amber-600">
Aktive Einladungen: {tokens.filter((token) => token.is_active && !token.revoked_at).length} · Gesamt:{' '}
{tokens.length}
Aktive QR-Einladungen: {invites.filter((invite) => invite.is_active && !invite.revoked_at).length} · Gesamt:{' '}
{invites.length}
</p>
)}
</div>
<Button onClick={handleInvite} disabled={creatingToken} className="w-full">
{creatingToken ? <Loader2 className="h-4 w-4 animate-spin" /> : <Share2 className="h-4 w-4" />}
Einladung erstellen
<Button onClick={handleInvite} disabled={creatingInvite} className="w-full">
{creatingInvite ? <Loader2 className="h-4 w-4 animate-spin" /> : <Share2 className="h-4 w-4" />}
QR-Einladung erstellen
</Button>
{inviteLink && (
@@ -297,20 +383,22 @@ export default function EventDetailPage() {
)}
<div className="space-y-3">
{tokens.length > 0 ? (
tokens.map((token) => (
{invites.length > 0 ? (
invites.map((invite) => (
<InvitationCard
key={token.id}
token={token}
onCopy={() => handleCopy(token)}
onRevoke={() => handleRevoke(token)}
revoking={revokingId === token.id}
key={invite.id}
invite={invite}
onCopy={() => handleCopy(invite)}
onRevoke={() => handleRevoke(invite)}
revoking={revokingId === invite.id}
onCustomize={() => openCustomizer(invite)}
eventName={eventDisplayName}
/>
))
) : (
<div className="rounded-lg border border-slate-200 bg-white/80 p-4 text-xs text-slate-500">
Es gibt noch keine Einladungslinks. Erstelle jetzt den ersten Link, um QR-Layouts mit QR-Code
herunterzuladen und zu teilen.
Es gibt noch keine QR-Einladungen. Erstelle jetzt eine Einladung, um Layouts mit QR-Code zu generieren
und zu teilen.
</div>
)}
</div>
@@ -340,6 +428,18 @@ export default function EventDetailPage() {
<AlertDescription>Bitte pruefe den Slug und versuche es erneut.</AlertDescription>
</Alert>
)}
<QrInviteCustomizationDialog
open={Boolean(customizingInvite)}
onClose={closeCustomizer}
onSubmit={handleApplyCustomization}
onReset={handleResetCustomization}
saving={customizerSaving}
inviteUrl={customizingInvite?.url ?? ''}
eventName={eventDisplayName}
layouts={customizingInvite?.layouts ?? []}
initialCustomization={currentCustomization}
/>
</AdminLayout>
);
}
@@ -373,21 +473,29 @@ function StatChip({ label, value }: { label: string; value: string | number }) {
}
function InvitationCard({
token,
invite,
onCopy,
onRevoke,
revoking,
onCustomize,
eventName,
}: {
token: EventJoinToken;
invite: EventQrInvite;
onCopy: () => void;
onRevoke: () => void;
revoking: boolean;
onCustomize: () => void;
eventName: string;
}) {
const status = getTokenStatus(token);
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 { t } = useTranslation('management');
const status = getInviteStatus(invite);
const layouts = Array.isArray(invite.layouts) ? invite.layouts : [];
const usageLabel = invite.usage_limit ? `${invite.usage_count} / ${invite.usage_limit}` : `${invite.usage_count}`;
const metadata = (invite.metadata ?? {}) as Record<string, unknown>;
const isAutoGenerated = Boolean(metadata.auto_generated);
const customization = (metadata.layout_customization ?? null) as QrLayoutCustomization | null;
const preferredLayoutId = customization?.layout_id ?? (layouts[0]?.id ?? null);
const hasCustomization = customization ? Object.keys(customization).length > 0 : false;
const statusClassname =
status === 'Aktiv'
@@ -401,17 +509,22 @@ function InvitationCard({
<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="text-sm font-semibold text-slate-900">{invite.label?.trim() || `Einladung #${invite.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}
{hasCustomization ? (
<span className="rounded-full bg-emerald-100 px-2 py-0.5 text-[11px] font-medium text-emerald-700">
{t('tasks.customizer.badge', 'Angepasst')}
</span>
) : null}
</div>
<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}
{invite.url}
</span>
<Button
variant="outline"
@@ -425,19 +538,28 @@ function InvitationCard({
</div>
<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}
{invite.expires_at ? <span>Gültig bis {formatDateTime(invite.expires_at)}</span> : null}
{invite.created_at ? <span>Erstellt am {formatDateTime(invite.created_at)}</span> : null}
</div>
</div>
<div className="flex flex-wrap gap-2">
{token.layouts_url ? (
<Button
variant={hasCustomization ? 'default' : 'outline'}
size="sm"
onClick={onCustomize}
className={hasCustomization ? 'bg-amber-500 text-white hover:bg-amber-500/90 border-amber-200' : 'border-amber-200 text-amber-700 hover:bg-amber-100'}
>
<Sparkles className="mr-1 h-3 w-3" />
{t('tasks.customizer.actionLabel', 'Layout anpassen')}
</Button>
{invite.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">
<a href={invite.layouts_url} target="_blank" rel="noreferrer">
<Download className="mr-1 h-3 w-3" />
Layout-Übersicht
</a>
@@ -447,7 +569,7 @@ function InvitationCard({
variant="ghost"
size="sm"
onClick={onRevoke}
disabled={revoking || token.revoked_at !== null || !token.is_active}
disabled={revoking || invite.revoked_at !== null || !invite.is_active}
className="text-slate-600 hover:bg-slate-100 disabled:opacity-50"
>
{revoking ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Deaktivieren'}
@@ -458,10 +580,16 @@ function InvitationCard({
{layouts.length > 0 ? (
<div className="grid gap-3 sm:grid-cols-2">
{layouts.map((layout) => (
<LayoutPreviewCard key={layout.id} layout={layout} />
<LayoutPreviewCard
key={layout.id}
layout={layout}
customization={layout.id === preferredLayoutId ? customization : null}
selected={layout.id === preferredLayoutId}
eventName={eventName}
/>
))}
</div>
) : token.layouts_url ? (
) : invite.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>
@@ -470,38 +598,63 @@ function InvitationCard({
);
}
function LayoutPreviewCard({ layout }: { layout: EventJoinTokenLayout }) {
const gradient = layout.preview?.background_gradient;
function LayoutPreviewCard({
layout,
customization,
selected,
eventName,
}: {
layout: EventQrInviteLayout;
customization: QrLayoutCustomization | null;
selected: boolean;
eventName: string;
}) {
const gradient = customization?.background_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(', ')})`,
backgroundImage: `linear-gradient(${gradient?.angle ?? customization?.background_gradient?.angle ?? 135}deg, ${stops.join(', ')})`,
}
: {
backgroundColor: layout.preview?.background ?? '#F8FAFC',
backgroundColor: customization?.background_color ?? layout.preview?.background ?? '#F8FAFC',
};
const textColor = layout.preview?.text ?? '#0F172A';
const textColor = customization?.text_color ?? layout.preview?.text ?? '#0F172A';
const badgeColor = customization?.badge_color ?? customization?.accent_color ?? layout.preview?.accent ?? '#0EA5E9';
const formats = Array.isArray(layout.formats) ? layout.formats : [];
const headline = customization?.headline ?? layout.name ?? eventName;
const subtitle = customization?.subtitle ?? layout.subtitle ?? '';
const description = customization?.description ?? layout.description ?? '';
const instructions = customization?.instructions ?? [];
return (
<div className="overflow-hidden rounded-xl border border-amber-100 bg-white shadow-sm">
<div
className={`overflow-hidden rounded-xl border bg-white shadow-sm ${selected ? 'border-amber-300 ring-2 ring-amber-200' : 'border-amber-100'}`}
>
<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">
<span
className="w-fit rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide"
style={{ backgroundColor: badgeColor, color: '#ffffff' }}
>
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 className="text-sm font-semibold leading-tight">{headline}</div>
{subtitle ? <div className="text-[11px] opacity-80">{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}
{description ? <p className="text-xs text-slate-600">{description}</p> : null}
{instructions.length > 0 ? (
<ul className="space-y-1 text-[11px] text-slate-500">
{instructions.slice(0, 3).map((item, index) => (
<li key={`${layout.id}-instruction-${index}`}> {item}</li>
))}
</ul>
) : null}
<div className="flex flex-wrap gap-2">
{formats.map((format) => {
const key = String(format ?? '').toLowerCase();
@@ -557,15 +710,15 @@ function formatDateTime(iso: string | null): string {
});
}
function getTokenStatus(token: EventJoinToken): 'Aktiv' | 'Deaktiviert' | 'Abgelaufen' {
if (token.revoked_at) return 'Deaktiviert';
if (token.expires_at) {
const expiry = new Date(token.expires_at);
function getInviteStatus(invite: EventQrInvite): 'Aktiv' | 'Deaktiviert' | 'Abgelaufen' {
if (invite.revoked_at) return 'Deaktiviert';
if (invite.expires_at) {
const expiry = new Date(invite.expires_at);
if (!Number.isNaN(expiry.getTime()) && expiry.getTime() < Date.now()) {
return 'Abgelaufen';
}
}
return token.is_active ? 'Aktiv' : 'Deaktiviert';
return invite.is_active ? 'Aktiv' : 'Deaktiviert';
}
function renderName(name: TenantEvent['name']): string {