Files
fotospiel-app/resources/js/admin/pages/EventInvitesPage.tsx

928 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { ArrowLeft, Copy, Download, Loader2, Printer, QrCode, RefreshCw, Share2, 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import { AdminLayout } from '../components/AdminLayout';
import {
createQrInvite,
EventQrInvite,
getEvent,
getEventQrInvites,
revokeEventQrInvite,
TenantEvent,
updateEventQrInvite,
EventQrInviteLayout,
} from '../api';
import { authorizedFetch, 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;
}
type TabKey = 'layout' | 'export' | 'links';
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 [designerMode, setDesignerMode] = React.useState<'standard' | 'advanced'>('standard');
const [searchParams, setSearchParams] = useSearchParams();
const tabParam = searchParams.get('tab');
const initialTab = tabParam === 'export' || tabParam === 'links' ? (tabParam as TabKey) : 'layout';
const [activeTab, setActiveTab] = React.useState<TabKey>(initialTab);
const [exportDownloadBusy, setExportDownloadBusy] = React.useState<string | null>(null);
const [exportPrintBusy, setExportPrintBusy] = React.useState<string | null>(null);
const [exportError, setExportError] = React.useState<string | null>(null);
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]);
React.useEffect(() => {
const param = searchParams.get('tab');
const nextTab = param === 'export' || param === 'links' ? (param as TabKey) : 'layout';
setActiveTab((current) => (current === nextTab ? current : nextTab));
}, [searchParams]);
const handleTabChange = React.useCallback(
(value: string) => {
const nextTab = value === 'export' || value === 'links' ? (value as TabKey) : 'layout';
setActiveTab(nextTab);
const nextParams = new URLSearchParams(searchParams);
if (nextTab === 'layout') {
nextParams.delete('tab');
} else {
nextParams.set('tab', nextTab);
}
setSearchParams(nextParams, { replace: true });
},
[searchParams, setSearchParams]
);
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(() => {
setExportError(null);
setExportDownloadBusy(null);
setExportPrintBusy(null);
}, [selectedInvite?.id]);
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 (currentCustomization?.mode === 'advanced') {
setDesignerMode('advanced');
} else if (designerMode !== 'standard' && currentCustomization) {
setDesignerMode('standard');
}
}, [currentCustomization?.mode]);
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 handleExportDownload = React.useCallback(
async (layout: EventQrInviteLayout, format: string, rawUrl?: string | null) => {
if (!selectedInvite) {
return;
}
const normalizedFormat = format.toLowerCase();
const sourceUrl = rawUrl ?? layout.download_urls?.[normalizedFormat];
if (!sourceUrl) {
setExportError(t('invites.customizer.errors.downloadFailed', 'Download fehlgeschlagen. Bitte versuche es erneut.'));
return;
}
const busyKey = `${layout.id}-${normalizedFormat}`;
setExportDownloadBusy(busyKey);
setExportError(null);
try {
const response = await authorizedFetch(resolveInternalUrl(sourceUrl), {
headers: {
Accept: normalizedFormat === 'pdf' ? 'application/pdf' : 'image/svg+xml',
},
});
if (!response.ok) {
throw new Error(`Unexpected status ${response.status}`);
}
const blob = await response.blob();
const objectUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
const filenameStem = `${selectedInvite.token || 'invite'}-${layout.id}`;
link.href = objectUrl;
link.download = `${filenameStem}.${normalizedFormat}`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
} catch (error) {
console.error('[Invites] Export download failed', error);
setExportError(
isAuthError(error)
? t('invites.customizer.errors.auth', 'Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.')
: t('invites.customizer.errors.downloadFailed', 'Download fehlgeschlagen. Bitte versuche es erneut.'),
);
} finally {
setExportDownloadBusy(null);
}
},
[selectedInvite, t]
);
const handleExportPrint = React.useCallback(
async (layout: EventQrInviteLayout) => {
if (!selectedInvite) {
return;
}
const rawUrl = layout.download_urls?.pdf ?? layout.download_urls?.a4 ?? null;
if (!rawUrl) {
setExportError(t('invites.labels.noPrintSource', 'Keine druckbare Version verfügbar.'));
return;
}
setExportPrintBusy(layout.id);
setExportError(null);
try {
const response = await authorizedFetch(resolveInternalUrl(rawUrl), {
headers: { Accept: 'application/pdf' },
});
if (!response.ok) {
throw new Error(`Unexpected status ${response.status}`);
}
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
const printWindow = window.open(blobUrl, '_blank', 'noopener,noreferrer');
if (!printWindow) {
throw new Error('window-blocked');
}
printWindow.onload = () => {
try {
printWindow.focus();
printWindow.print();
} catch (printError) {
console.error('[Invites] Export print window failed', printError);
}
};
setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000);
} catch (error) {
console.error('[Invites] Export print failed', error);
setExportError(
isAuthError(error)
? t('invites.customizer.errors.auth', 'Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.')
: t('invites.customizer.errors.printFailed', 'Druck konnte nicht gestartet werden.'),
);
} finally {
setExportPrintBusy(null);
}
},
[selectedInvite, t]
);
const actions = (
<div className="flex flex-wrap items-center gap-1 text-sm text-muted-foreground">
<Button
size="sm"
variant="ghost"
onClick={() => navigate(ADMIN_EVENTS_PATH)}
className="hover:text-foreground"
>
<ArrowLeft className="mr-1 h-3.5 w-3.5" />
{t('invites.actions.backToList', 'Zurück')}
</Button>
{slug ? (
<>
<Button
size="sm"
variant="ghost"
onClick={() => navigate(ADMIN_EVENT_VIEW_PATH(slug))}
className="hover:text-foreground"
>
{t('invites.actions.backToEvent', 'Event öffnen')}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => navigate(ADMIN_EVENT_PHOTOS_PATH(slug))}
className="hover:text-foreground"
>
{t('toolkit.actions.moderate', 'Fotos moderieren')}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => navigate(ADMIN_EVENT_TOOLKIT_PATH(slug))}
className="hover:text-foreground"
>
{t('toolkit.actions.backToEvent', 'Event-Day Toolkit')}
</Button>
</>
) : null}
</div>
);
return (
<AdminLayout
title={eventName}
subtitle={t('invites.subtitle', 'Manage QR-Einladungen, Drucklayouts und Branding für deine Gäste.')}
actions={actions}
>
<Tabs value={activeTab} onValueChange={handleTabChange} className="space-y-6">
<TabsList className="grid w-full max-w-2xl grid-cols-3 gap-1 rounded-full border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-strong)] p-1 text-sm">
<TabsTrigger value="layout" className="rounded-full px-4 py-1.5 data-[state=active]:bg-primary data-[state=active]:text-primary-foreground">
{t('invites.tabs.layout', 'Layout anpassen')}
</TabsTrigger>
<TabsTrigger value="export" className="rounded-full px-4 py-1.5 data-[state=active]:bg-primary data-[state=active]:text-primary-foreground">
{t('invites.tabs.export', 'Drucken & Export')}
</TabsTrigger>
<TabsTrigger value="links" className="rounded-full px-4 py-1.5 data-[state=active]:bg-primary data-[state=active]:text-primary-foreground">
{t('invites.tabs.links', 'QR-Codes verwalten')}
</TabsTrigger>
</TabsList>
{state.error ? (
<Alert variant="destructive">
<AlertTitle>{t('invites.errorTitle', 'Aktion fehlgeschlagen')}</AlertTitle>
<AlertDescription>{state.error}</AlertDescription>
</Alert>
) : null}
<TabsContent value="layout" className="space-y-6 focus-visible:outline-hidden">
<section className="rounded-3xl border border-[var(--tenant-border-strong)] bg-gradient-to-br from-[var(--tenant-surface-muted)] via-[var(--tenant-surface)] to-[var(--tenant-surface-strong)] p-6 shadow-xl shadow-primary/10 backdrop-blur-sm transition-colors">
<div className="mb-6 flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
<div className="space-y-1">
<h2 className="text-lg font-semibold text-foreground">{t('invites.designer.heading', 'Einladungslayout anpassen')}</h2>
<p className="text-sm text-muted-foreground">
{t('invites.designer.subheading', 'Standardlayouts sind direkt startklar. Für individuelle Gestaltung kannst du in den freien Editor wechseln.')}
</p>
</div>
<ToggleGroup
type="single"
value={designerMode}
onValueChange={(value) => value && setDesignerMode(value as 'standard' | 'advanced')}
className="self-start rounded-full border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-strong)] p-1 text-sm"
>
<ToggleGroupItem value="standard" className="rounded-full px-4 py-1.5 data-[state=on]:bg-primary data-[state=on]:text-primary-foreground">
{t('invites.designer.mode.standard', 'Standard-Layoutraster')}
</ToggleGroupItem>
<ToggleGroupItem value="advanced" className="rounded-full px-4 py-1.5 data-[state=on]:bg-primary data-[state=on]:text-primary-foreground">
{t('invites.designer.mode.advanced', 'Freier Editor (Beta)')}
</ToggleGroupItem>
</ToggleGroup>
</div>
{state.loading ? (
<InviteCustomizerSkeleton />
) : designerMode === 'standard' ? (
<InviteLayoutCustomizerPanel
invite={selectedInvite ?? null}
eventName={eventName}
saving={customizerSaving}
resetting={customizerResetting}
onSave={handleSaveCustomization}
onReset={handleResetCustomization}
initialCustomization={currentCustomization}
/>
) : (
<AdvancedDesignerPlaceholder onBack={() => setDesignerMode('standard')} />
)}
</section>
</TabsContent>
<TabsContent value="export" className="space-y-6 focus-visible:outline-hidden">
<Card className="border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface)] shadow-xl shadow-primary/10 backdrop-blur-sm transition-colors">
<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-lg text-foreground">
<Printer className="h-5 w-5 text-primary" />
{t('invites.export.title', 'Drucken & Export')}
</CardTitle>
<CardDescription className="text-sm text-muted-foreground">
{t('invites.export.description', 'Lade druckfertige Dateien herunter oder starte direkt einen Testdruck.')}
</CardDescription>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:gap-3">
<Select
value={selectedInvite ? String(selectedInvite.id) : ''}
onValueChange={(value) => setSelectedInviteId(Number(value))}
disabled={state.invites.length === 0}
>
<SelectTrigger className="h-9 w-full min-w-[200px] sm:w-60">
<SelectValue placeholder={t('invites.export.selectPlaceholder', 'Einladung auswählen')} />
</SelectTrigger>
<SelectContent>
{state.invites.map((invite) => (
<SelectItem key={invite.id} value={String(invite.id)}>
{invite.label || invite.token}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="sm"
variant="outline"
onClick={() => void load()}
disabled={state.loading}
>
{state.loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
{t('invites.actions.refresh', 'Aktualisieren')}
</Button>
</div>
</CardHeader>
<CardContent className="space-y-6">
{exportError ? (
<Alert variant="destructive">
<AlertTitle>{t('invites.export.errorTitle', 'Download fehlgeschlagen')}</AlertTitle>
<AlertDescription>{exportError}</AlertDescription>
</Alert>
) : null}
{selectedInvite ? (
selectedInvite.layouts.length ? (
<div className="grid gap-4 md:grid-cols-2">
{selectedInvite.layouts.map((layout) => {
const printBusy = exportPrintBusy === layout.id;
return (
<div
key={layout.id}
className="flex flex-col gap-4 rounded-2xl border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-strong)] p-5 shadow-sm transition-colors"
>
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<h3 className="text-base font-semibold text-foreground">{layout.name || t('invites.customizer.layoutFallback', 'Layout')}</h3>
{layout.subtitle ? (
<p className="text-xs text-muted-foreground">{layout.subtitle}</p>
) : null}
</div>
{layout.formats?.length ? (
<Badge className="bg-amber-500/15 text-amber-700">
{layout.formats.map((format) => String(format).toUpperCase()).join(' · ')}
</Badge>
) : null}
</div>
{layout.description ? (
<p className="text-sm leading-relaxed text-muted-foreground">{layout.description}</p>
) : null}
<div className="flex flex-wrap gap-2">
<Button
type="button"
size="sm"
variant="outline"
onClick={() => void handleExportPrint(layout)}
disabled={printBusy || Boolean(exportDownloadBusy)}
>
{printBusy ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : <Printer className="mr-1 h-4 w-4" />}
{t('invites.export.actions.print', 'Direkt drucken')}
</Button>
{layout.formats?.map((format) => {
const key = String(format ?? '').toLowerCase();
const url = layout.download_urls?.[key];
if (!url) return null;
const busyKey = `${layout.id}-${key}`;
const isBusy = exportDownloadBusy === busyKey;
return (
<Button
key={`${layout.id}-${key}`}
type="button"
size="sm"
variant="outline"
disabled={(!!exportDownloadBusy && !isBusy) || printBusy}
onClick={() => void handleExportDownload(layout, key, url)}
>
{isBusy ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : <Download className="mr-1 h-4 w-4" />}
{key.toUpperCase()}
</Button>
);
})}
</div>
</div>
);
})}
</div>
) : (
<div className="rounded-2xl border border-dashed border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-muted)] p-8 text-sm text-[var(--tenant-foreground-soft)]">
{t('invites.export.noLayouts', 'Für diese Einladung sind aktuell keine Layouts verfügbar.')}
</div>
)
) : (
<div className="rounded-2xl border border-dashed border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-muted)] p-8 text-sm text-[var(--tenant-foreground-soft)]">
{t('invites.export.noInviteSelected', 'Wähle zunächst eine Einladung aus, um Downloads zu starten.')}
</div>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="links" className="space-y-6 focus-visible:outline-hidden">
<Card className="border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface)] shadow-xl shadow-primary/10 backdrop-blur-sm transition-colors">
<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-lg text-foreground">
<QrCode className="h-5 w-5 text-primary" />
{t('invites.cardTitle', 'QR-Einladungen & Layouts')}
</CardTitle>
<CardDescription className="text-sm text-muted-foreground">
{t('invites.cardDescription', 'Erzeuge Einladungen, passe Layouts an und stelle druckfertige Vorlagen bereit.')}
</CardDescription>
<div className="inline-flex items-center gap-2 rounded-full border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-muted)] px-3 py-1 text-xs text-[var(--tenant-foreground-soft)]">
<span>{t('invites.summary.active', 'Aktive Einladungen')}: {inviteCountSummary.active}</span>
<span className="text-primary"></span>
<span>{t('invites.summary.total', 'Gesamt')}: {inviteCountSummary.total}</span>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
variant="outline"
onClick={() => void load()}
disabled={state.loading}
>
{state.loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
{t('invites.actions.refresh', 'Aktualisieren')}
</Button>
<Button
size="sm"
onClick={handleCreateInvite}
disabled={creatingInvite}
className="bg-primary text-primary-foreground shadow-lg shadow-primary/20 hover:bg-primary/90"
>
{creatingInvite ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : <Share2 className="mr-1 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>
</TabsContent>
</Tabs>
</AdminLayout>
);
}
function resolveInternalUrl(rawUrl: string): string {
try {
const parsed = new URL(rawUrl, window.location.origin);
if (parsed.origin === window.location.origin) {
return parsed.pathname + parsed.search;
}
} catch (error) {
console.warn('[Invites] Unable to resolve download url', error);
}
return rawUrl;
}
function InviteCustomizerSkeleton(): JSX.Element {
return (
<div className="space-y-6">
<div className="h-8 w-56 animate-pulse rounded-full bg-white/70" />
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.05fr)_minmax(0,1fr)]">
<div className="space-y-4">
{Array.from({ length: 3 }).map((_, index) => (
<div key={`customizer-skeleton-${index}`} className="h-40 animate-pulse rounded-2xl bg-white/70" />
))}
</div>
<div className="h-[420px] animate-pulse rounded-3xl bg-white/70" />
</div>
</div>
);
}
function AdvancedDesignerPlaceholder({ onBack }: { onBack: () => void }): JSX.Element {
return (
<div className="space-y-6 rounded-2xl border border-dashed border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-strong)]/80 p-10 text-sm text-muted-foreground transition-colors">
<div className="space-y-2">
<h3 className="text-xl font-semibold text-foreground">Freier Editor bald verfügbar</h3>
<p>
Wir arbeiten gerade an einem drag-&-drop-Designer, mit dem du Elemente wie QR-Code, Texte und Logos frei platzieren
kannst. In der Zwischenzeit kannst du unsere optimierten Standardlayouts mit vergrößertem QR-Code nutzen.
</p>
<p>
Wenn du Vorschläge für zusätzliche Layouts oder Funktionen hast, schreib uns gern über den Support wir sammeln Feedback
für die nächste Ausbaustufe.
</p>
</div>
<div className="flex flex-wrap items-center gap-3">
<Button onClick={onBack} className="bg-primary text-primary-foreground hover:bg-primary/90">
Zurück zum Standard-Layout
</Button>
</div>
</div>
);
}
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-[var(--tenant-border-strong)] bg-[var(--tenant-surface)] shadow-lg shadow-primary/20' : 'border-border bg-[var(--tenant-surface)] hover:border-[var(--tenant-border-strong)]'}`}
>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold text-foreground">{invite.label?.trim() || `Einladung #${invite.id}`}</span>
<Badge variant="outline" className={statusBadgeClass(status)}>
{status}
</Badge>
{isAutoGenerated ? (
<Badge variant="secondary" className="bg-muted text-muted-foreground">{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>
{invite.qr_code_data_url ? (
<img
src={invite.qr_code_data_url}
alt={t('invites.labels.qrAlt', 'QR-Code Vorschau')}
className="h-16 w-16 rounded-lg border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-strong)] p-2 shadow-sm"
/>
) : null}
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="break-all rounded-lg border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-strong)] px-2 py-1 font-mono text-xs text-muted-foreground">
{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-muted-foreground 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-muted-foreground">{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-muted-foreground hover:text-destructive 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-[var(--tenant-border-strong)] bg-[var(--tenant-surface-muted)] p-10 text-center transition-colors">
<h3 className="text-base font-semibold text-foreground">{t('invites.empty.title', 'Noch keine Einladungen')}</h3>
<p className="text-sm text-muted-foreground">{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 dark:bg-emerald-500/20 dark:text-emerald-200 dark:border-emerald-500/40';
}
if (status === 'Abgelaufen') {
return 'bg-orange-100 text-orange-700 border-orange-200 dark:bg-orange-500/20 dark:text-orange-200 dark:border-orange-500/40';
}
return 'bg-slate-200 text-slate-700 border-slate-300 dark:bg-slate-600/40 dark:text-slate-200 dark:border-slate-500/40';
}
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',
});
}