517 lines
19 KiB
TypeScript
517 lines
19 KiB
TypeScript
import React from 'react';
|
|
import { useNavigate, useParams } from 'react-router-dom';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { ArrowLeft, Copy, Loader2, QrCode, RefreshCw, Share2, Sparkles, X } from 'lucide-react';
|
|
|
|
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 { AdminLayout } from '../components/AdminLayout';
|
|
import {
|
|
createQrInvite,
|
|
EventQrInvite,
|
|
getEvent,
|
|
getEventQrInvites,
|
|
revokeEventQrInvite,
|
|
TenantEvent,
|
|
updateEventQrInvite,
|
|
EventQrInviteLayout,
|
|
} from '../api';
|
|
import { isAuthError } from '../auth/tokens';
|
|
import {
|
|
ADMIN_EVENTS_PATH,
|
|
ADMIN_EVENT_VIEW_PATH,
|
|
ADMIN_EVENT_TOOLKIT_PATH,
|
|
ADMIN_EVENT_PHOTOS_PATH,
|
|
} from '../constants';
|
|
import { InviteLayoutCustomizerPanel, QrLayoutCustomization } from './components/InviteLayoutCustomizerPanel';
|
|
|
|
interface PageState {
|
|
event: TenantEvent | null;
|
|
invites: EventQrInvite[];
|
|
loading: boolean;
|
|
error: string | null;
|
|
}
|
|
|
|
export default function EventInvitesPage(): JSX.Element {
|
|
const { slug } = useParams<{ slug?: string }>();
|
|
const navigate = useNavigate();
|
|
const { t } = useTranslation('management');
|
|
|
|
const [state, setState] = React.useState<PageState>({ event: null, invites: [], loading: true, error: null });
|
|
const [creatingInvite, setCreatingInvite] = React.useState(false);
|
|
const [revokingId, setRevokingId] = React.useState<number | null>(null);
|
|
const [selectedInviteId, setSelectedInviteId] = React.useState<number | null>(null);
|
|
const [copiedInviteId, setCopiedInviteId] = React.useState<number | null>(null);
|
|
const [customizerSaving, setCustomizerSaving] = React.useState(false);
|
|
const [customizerResetting, setCustomizerResetting] = React.useState(false);
|
|
|
|
const load = React.useCallback(async () => {
|
|
if (!slug) {
|
|
setState({ event: null, invites: [], loading: false, error: 'Kein Event-Slug angegeben.' });
|
|
return;
|
|
}
|
|
|
|
setState((prev) => ({ ...prev, loading: true, error: null }));
|
|
try {
|
|
const [eventData, invitesData] = await Promise.all([getEvent(slug), getEventQrInvites(slug)]);
|
|
setState({ event: eventData, invites: invitesData, loading: false, error: null });
|
|
setSelectedInviteId((current) => current ?? invitesData[0]?.id ?? null);
|
|
} catch (error) {
|
|
if (!isAuthError(error)) {
|
|
setState({ event: null, invites: [], loading: false, error: 'QR-Einladungen konnten nicht geladen werden.' });
|
|
}
|
|
}
|
|
}, [slug]);
|
|
|
|
React.useEffect(() => {
|
|
void load();
|
|
}, [load]);
|
|
|
|
const event = state.event;
|
|
const eventName = event ? renderEventName(event.name) : t('toolkit.titleFallback', 'Event');
|
|
|
|
const selectedInvite = React.useMemo(
|
|
() => state.invites.find((invite) => invite.id === selectedInviteId) ?? null,
|
|
[state.invites, selectedInviteId]
|
|
);
|
|
|
|
React.useEffect(() => {
|
|
if (state.invites.length === 0) {
|
|
setSelectedInviteId(null);
|
|
return;
|
|
}
|
|
setSelectedInviteId((current) => {
|
|
if (current && state.invites.some((invite) => invite.id === current)) {
|
|
return current;
|
|
}
|
|
return state.invites[0]?.id ?? null;
|
|
});
|
|
}, [state.invites]);
|
|
|
|
const currentCustomization = React.useMemo(() => {
|
|
if (!selectedInvite) {
|
|
return null;
|
|
}
|
|
const metadata = selectedInvite.metadata as Record<string, unknown> | undefined | null;
|
|
const raw = metadata?.layout_customization;
|
|
return raw && typeof raw === 'object' ? (raw as QrLayoutCustomization) : null;
|
|
}, [selectedInvite]);
|
|
|
|
React.useEffect(() => {
|
|
if (selectedInvite) {
|
|
console.debug('[Invites] Selected invite', selectedInvite.id, selectedInvite.layouts, selectedInvite.layouts_url);
|
|
}
|
|
}, [selectedInvite]);
|
|
|
|
const inviteCountSummary = React.useMemo(() => {
|
|
const active = state.invites.filter((invite) => invite.is_active && !invite.revoked_at).length;
|
|
const total = state.invites.length;
|
|
return { active, total };
|
|
}, [state.invites]);
|
|
|
|
async function handleCreateInvite() {
|
|
if (!slug || creatingInvite) {
|
|
return;
|
|
}
|
|
|
|
setCreatingInvite(true);
|
|
setState((prev) => ({ ...prev, error: null }));
|
|
try {
|
|
const invite = await createQrInvite(slug);
|
|
setState((prev) => ({
|
|
...prev,
|
|
invites: [invite, ...prev.invites.filter((existing) => existing.id !== invite.id)],
|
|
}));
|
|
setSelectedInviteId(invite.id);
|
|
try {
|
|
await navigator.clipboard.writeText(invite.url);
|
|
setCopiedInviteId(invite.id);
|
|
} catch {
|
|
// ignore clipboard failures
|
|
}
|
|
} catch (error) {
|
|
if (!isAuthError(error)) {
|
|
setState((prev) => ({ ...prev, error: 'QR-Einladung konnte nicht erstellt werden.' }));
|
|
}
|
|
} finally {
|
|
setCreatingInvite(false);
|
|
}
|
|
}
|
|
|
|
async function handleCopy(invite: EventQrInvite) {
|
|
try {
|
|
await navigator.clipboard.writeText(invite.url);
|
|
setCopiedInviteId(invite.id);
|
|
} catch (error) {
|
|
console.warn('[Invites] Clipboard copy failed', error);
|
|
}
|
|
}
|
|
|
|
React.useEffect(() => {
|
|
if (!copiedInviteId) return;
|
|
const timeout = setTimeout(() => setCopiedInviteId(null), 3000);
|
|
return () => clearTimeout(timeout);
|
|
}, [copiedInviteId]);
|
|
|
|
async function handleRevoke(invite: EventQrInvite) {
|
|
if (!slug || invite.revoked_at) {
|
|
return;
|
|
}
|
|
|
|
setRevokingId(invite.id);
|
|
setState((prev) => ({ ...prev, error: null }));
|
|
try {
|
|
const updated = await revokeEventQrInvite(slug, invite.id);
|
|
setState((prev) => ({
|
|
...prev,
|
|
invites: prev.invites.map((existing) => (existing.id === updated.id ? updated : existing)),
|
|
}));
|
|
if (selectedInviteId === invite.id && !updated.is_active) {
|
|
setSelectedInviteId((prevId) => (prevId === updated.id ? null : prevId));
|
|
}
|
|
} catch (error) {
|
|
if (!isAuthError(error)) {
|
|
setState((prev) => ({ ...prev, error: 'QR-Einladung konnte nicht deaktiviert werden.' }));
|
|
}
|
|
} finally {
|
|
setRevokingId(null);
|
|
}
|
|
}
|
|
|
|
async function handleSaveCustomization(customization: QrLayoutCustomization) {
|
|
if (!slug || !selectedInvite) {
|
|
return;
|
|
}
|
|
|
|
setCustomizerSaving(true);
|
|
setState((prev) => ({ ...prev, error: null }));
|
|
try {
|
|
const updated = await updateEventQrInvite(slug, selectedInvite.id, {
|
|
metadata: { layout_customization: customization },
|
|
});
|
|
setState((prev) => ({
|
|
...prev,
|
|
invites: prev.invites.map((invite) => (invite.id === updated.id ? updated : invite)),
|
|
}));
|
|
} catch (error) {
|
|
if (!isAuthError(error)) {
|
|
setState((prev) => ({ ...prev, error: 'Anpassung konnte nicht gespeichert werden.' }));
|
|
}
|
|
} finally {
|
|
setCustomizerSaving(false);
|
|
}
|
|
}
|
|
|
|
async function handleResetCustomization() {
|
|
if (!slug || !selectedInvite) {
|
|
return;
|
|
}
|
|
|
|
setCustomizerResetting(true);
|
|
setState((prev) => ({ ...prev, error: null }));
|
|
try {
|
|
const updated = await updateEventQrInvite(slug, selectedInvite.id, {
|
|
metadata: { layout_customization: null },
|
|
});
|
|
setState((prev) => ({
|
|
...prev,
|
|
invites: prev.invites.map((invite) => (invite.id === updated.id ? updated : invite)),
|
|
}));
|
|
} catch (error) {
|
|
if (!isAuthError(error)) {
|
|
setState((prev) => ({ ...prev, error: 'Anpassungen konnten nicht zurückgesetzt werden.' }));
|
|
}
|
|
} finally {
|
|
setCustomizerResetting(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" />
|
|
{t('invites.actions.backToList', 'Zurück zur Übersicht')}
|
|
</Button>
|
|
{slug ? (
|
|
<>
|
|
<Button variant="outline" onClick={() => navigate(ADMIN_EVENT_VIEW_PATH(slug))} className="border-slate-200 text-slate-600 hover:bg-slate-50">
|
|
{t('invites.actions.backToEvent', 'Event öffnen')}
|
|
</Button>
|
|
<Button variant="outline" onClick={() => navigate(ADMIN_EVENT_PHOTOS_PATH(slug))} className="border-sky-200 text-sky-700 hover:bg-sky-50">
|
|
{t('toolkit.actions.moderate', 'Fotos moderieren')}
|
|
</Button>
|
|
<Button variant="outline" onClick={() => navigate(ADMIN_EVENT_TOOLKIT_PATH(slug))} className="border-emerald-200 text-emerald-600 hover:bg-emerald-50">
|
|
{t('toolkit.actions.backToEvent', 'Event-Day Toolkit')}
|
|
</Button>
|
|
</>
|
|
) : null}
|
|
</>
|
|
);
|
|
|
|
return (
|
|
<AdminLayout
|
|
title={eventName}
|
|
subtitle={t('invites.subtitle', 'Manage QR-Einladungen, Drucklayouts und Branding für deine Gäste.')}
|
|
actions={actions}
|
|
>
|
|
{state.error ? (
|
|
<Alert variant="destructive">
|
|
<AlertTitle>{t('invites.errorTitle', 'Aktion fehlgeschlagen')}</AlertTitle>
|
|
<AlertDescription>{state.error}</AlertDescription>
|
|
</Alert>
|
|
) : null}
|
|
|
|
<Card className="border-0 bg-white/90 shadow-xl shadow-amber-100/60">
|
|
<CardHeader className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
|
<div className="space-y-2">
|
|
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
|
|
<QrCode className="h-5 w-5 text-amber-500" />
|
|
{t('invites.cardTitle', 'QR-Einladungen & Layouts')}
|
|
</CardTitle>
|
|
<CardDescription className="text-sm text-slate-600">
|
|
{t('invites.cardDescription', 'Erzeuge Einladungen, passe Layouts an und stelle druckfertige Vorlagen bereit.')}
|
|
</CardDescription>
|
|
<div className="rounded-lg border border-amber-100 bg-amber-50/70 px-3 py-2 text-xs text-amber-700">
|
|
{t('invites.summary.active', 'Aktive Einladungen')}: {inviteCountSummary.active} ·{' '}
|
|
{t('invites.summary.total', 'Gesamt')}: {inviteCountSummary.total}
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<Button variant="outline" onClick={() => void load()} disabled={state.loading} className="border-amber-200 text-amber-700 hover:bg-amber-100">
|
|
{state.loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
|
|
{t('invites.actions.refresh', 'Aktualisieren')}
|
|
</Button>
|
|
<Button
|
|
onClick={handleCreateInvite}
|
|
disabled={creatingInvite}
|
|
className="bg-gradient-to-r from-amber-500 via-orange-500 to-rose-500 text-white shadow-lg shadow-amber-500/20"
|
|
>
|
|
{creatingInvite ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Share2 className="mr-2 h-4 w-4" />}
|
|
{t('invites.actions.create', 'Neue Einladung erstellen')}
|
|
</Button>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
{state.loading ? (
|
|
<InviteSkeleton />
|
|
) : state.invites.length === 0 ? (
|
|
<EmptyState onCreate={handleCreateInvite} />
|
|
) : (
|
|
<div className="grid gap-3">
|
|
{state.invites.map((invite) => (
|
|
<InviteListCard
|
|
key={invite.id}
|
|
invite={invite}
|
|
onSelect={() => setSelectedInviteId(invite.id)}
|
|
onCopy={() => handleCopy(invite)}
|
|
onRevoke={() => handleRevoke(invite)}
|
|
selected={invite.id === selectedInvite?.id}
|
|
revoking={revokingId === invite.id}
|
|
copied={copiedInviteId === invite.id}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<InviteLayoutCustomizerPanel
|
|
invite={selectedInvite ?? null}
|
|
eventName={eventName}
|
|
saving={customizerSaving}
|
|
resetting={customizerResetting}
|
|
onSave={handleSaveCustomization}
|
|
onReset={handleResetCustomization}
|
|
initialCustomization={currentCustomization}
|
|
/>
|
|
</AdminLayout>
|
|
);
|
|
}
|
|
|
|
function InviteListCard({
|
|
invite,
|
|
selected,
|
|
onSelect,
|
|
onCopy,
|
|
onRevoke,
|
|
revoking,
|
|
copied,
|
|
}: {
|
|
invite: EventQrInvite;
|
|
selected: boolean;
|
|
onSelect: () => void;
|
|
onCopy: () => void;
|
|
onRevoke: () => void;
|
|
revoking: boolean;
|
|
copied: boolean;
|
|
}) {
|
|
const { t } = useTranslation('management');
|
|
const status = getInviteStatus(invite);
|
|
const metadata = (invite.metadata ?? {}) as Record<string, unknown>;
|
|
const customization = (metadata.layout_customization ?? null) as QrLayoutCustomization | null;
|
|
const preferredLayoutId = customization?.layout_id ?? invite.layouts[0]?.id ?? null;
|
|
const isAutoGenerated = Boolean(metadata.auto_generated);
|
|
const usageLabel = invite.usage_limit ? `${invite.usage_count} / ${invite.usage_limit}` : `${invite.usage_count}`;
|
|
|
|
const layoutsById = React.useMemo(() => {
|
|
const map = new Map<string, EventQrInviteLayout>();
|
|
invite.layouts.forEach((layout) => map.set(layout.id, layout));
|
|
return map;
|
|
}, [invite.layouts]);
|
|
|
|
const layoutName = preferredLayoutId ? layoutsById.get(preferredLayoutId)?.name ?? invite.layouts[0]?.name ?? '' : '';
|
|
|
|
return (
|
|
<div
|
|
role="button"
|
|
tabIndex={0}
|
|
onClick={onSelect}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Enter' || event.key === ' ') {
|
|
event.preventDefault();
|
|
onSelect();
|
|
}
|
|
}}
|
|
className={`flex flex-col gap-3 rounded-2xl border p-4 transition-shadow ${selected ? 'border-amber-400 bg-amber-50/70 shadow-lg shadow-amber-200/30' : 'border-slate-200 bg-white/80 hover:border-amber-200'}`}
|
|
>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<span className="text-sm font-semibold text-slate-900">{invite.label?.trim() || `Einladung #${invite.id}`}</span>
|
|
<Badge variant="outline" className={statusBadgeClass(status)}>
|
|
{status}
|
|
</Badge>
|
|
{isAutoGenerated ? (
|
|
<Badge variant="secondary" className="bg-slate-200 text-slate-700">{t('invites.labels.standard', 'Standard')}</Badge>
|
|
) : null}
|
|
{customization ? (
|
|
<Badge className="bg-emerald-500/15 text-emerald-700">{t('tasks.customizer.badge', 'Angepasst')}</Badge>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<span className="break-all rounded-lg border border-slate-200 bg-slate-50 px-2 py-1 font-mono text-xs text-slate-700">
|
|
{invite.url}
|
|
</span>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
onCopy();
|
|
}}
|
|
className={copied ? 'border-emerald-300 text-emerald-700 hover:bg-emerald-50' : 'border-amber-200 text-amber-700 hover:bg-amber-100'}
|
|
>
|
|
<Copy className="mr-1 h-3 w-3" />
|
|
{copied ? t('invites.actions.copied', 'Kopiert!') : t('invites.actions.copy', 'Link kopieren')}
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="grid gap-1 text-xs text-slate-500 sm:grid-cols-2">
|
|
<span>
|
|
{t('invites.labels.usage', 'Nutzung')}: {usageLabel}
|
|
</span>
|
|
<span>
|
|
{t('invites.labels.layout', 'Layout')}: {layoutName || t('invites.labels.layoutFallback', 'Standard')}
|
|
</span>
|
|
{invite.expires_at ? <span>{t('invites.labels.validUntil', 'Gültig bis')} {formatDateTime(invite.expires_at)}</span> : null}
|
|
{invite.created_at ? <span>{t('invites.labels.createdAt', 'Erstellt am')} {formatDateTime(invite.created_at)}</span> : null}
|
|
</div>
|
|
|
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
{selected ? (
|
|
<Badge variant="outline" className="border-amber-300 bg-amber-100/70 text-amber-700">
|
|
{t('invites.labels.selected', 'Aktuell ausgewählt')}
|
|
</Badge>
|
|
) : (
|
|
<div className="text-xs text-slate-500">{t('invites.labels.tapToEdit', 'Zum Anpassen auswählen')}</div>
|
|
)}
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
onRevoke();
|
|
}}
|
|
disabled={revoking || invite.revoked_at !== null || !invite.is_active}
|
|
className="text-slate-500 hover:text-rose-500 disabled:opacity-50"
|
|
>
|
|
{revoking ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : <X className="mr-1 h-4 w-4" />}
|
|
{t('invites.actions.deactivate', 'Deaktivieren')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function InviteSkeleton() {
|
|
return (
|
|
<div className="space-y-3">
|
|
{Array.from({ length: 3 }).map((_, index) => (
|
|
<div key={`invite-skeleton-${index}`} className="h-32 animate-pulse rounded-2xl bg-gradient-to-r from-white/40 via-white/60 to-white/40" />
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function EmptyState({ onCreate }: { onCreate: () => void }) {
|
|
const { t } = useTranslation('management');
|
|
return (
|
|
<div className="flex flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-slate-200 bg-slate-50/60 p-10 text-center">
|
|
<h3 className="text-base font-semibold text-slate-800">{t('invites.empty.title', 'Noch keine Einladungen')}</h3>
|
|
<p className="text-sm text-slate-500">{t('invites.empty.copy', 'Erstelle eine Einladung, um druckfertige QR-Layouts zu erhalten.')}</p>
|
|
<Button onClick={onCreate} className="bg-gradient-to-r from-amber-500 via-orange-500 to-rose-500 text-white">
|
|
<Share2 className="mr-1 h-4 w-4" />
|
|
{t('invites.actions.create', 'Neue Einladung erstellen')}
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function renderEventName(name: TenantEvent['name']): string {
|
|
if (typeof name === 'string') {
|
|
return name;
|
|
}
|
|
if (name && typeof name === 'object') {
|
|
return name.de ?? name.en ?? Object.values(name)[0] ?? 'Event';
|
|
}
|
|
return 'Event';
|
|
}
|
|
|
|
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 invite.is_active ? 'Aktiv' : 'Deaktiviert';
|
|
}
|
|
|
|
function statusBadgeClass(status: string): string {
|
|
if (status === 'Aktiv') {
|
|
return 'bg-emerald-100 text-emerald-700 border-emerald-200';
|
|
}
|
|
if (status === 'Abgelaufen') {
|
|
return 'bg-orange-100 text-orange-700 border-orange-200';
|
|
}
|
|
return 'bg-slate-200 text-slate-700 border-slate-300';
|
|
}
|
|
|
|
function formatDateTime(iso: string | null): string {
|
|
if (!iso) return 'unbekannt';
|
|
const date = new Date(iso);
|
|
if (Number.isNaN(date.getTime())) {
|
|
return 'unbekannt';
|
|
}
|
|
return date.toLocaleString('de-DE', {
|
|
day: '2-digit',
|
|
month: '2-digit',
|
|
year: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
});
|
|
}
|