928 lines
38 KiB
TypeScript
928 lines
38 KiB
TypeScript
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',
|
||
});
|
||
}
|