QR-Codes-UI zu Einladungen umgebaut mit PDF-Export und Druckanzeige + Customizer

This commit is contained in:
Codex Agent
2025-10-30 07:12:27 +01:00
parent d781448914
commit 06df61f706
20 changed files with 1724 additions and 537 deletions

View File

@@ -13,6 +13,7 @@ export type EventQrInviteLayout = {
background_gradient: { angle: number; stops: string[] } | null;
accent: string | null;
text: string | null;
qr_size_px?: number | null;
};
formats: string[];
download_urls: Record<string, string>;
@@ -257,6 +258,7 @@ export type EventQrInvite = {
token: string;
url: string;
label: string | null;
qr_code_data_url: string | null;
usage_limit: number | null;
usage_count: number;
expires_at: string | null;
@@ -678,6 +680,7 @@ function normalizeQrInvite(raw: JsonValue): EventQrInvite {
background_gradient: layout.preview?.background_gradient ?? null,
accent: layout.preview?.accent ?? null,
text: layout.preview?.text ?? null,
qr_size_px: layout.preview?.qr_size_px ?? layout.qr?.size_px ?? null,
},
formats,
download_urls: (layout.download_urls ?? {}) as Record<string, string>,
@@ -699,6 +702,10 @@ function normalizeQrInvite(raw: JsonValue): EventQrInvite {
metadata: (raw.metadata ?? {}) as Record<string, unknown>,
layouts,
layouts_url: typeof raw.layouts_url === 'string' ? raw.layouts_url : null,
qr_code_data_url:
typeof raw.qr_code_data_url === 'string' && raw.qr_code_data_url.length > 0
? String(raw.qr_code_data_url)
: null,
};
}

View File

@@ -318,6 +318,11 @@
"cardTitle": "QR-Einladungen & Layouts",
"cardDescription": "Erzeuge Einladungen, passe Layouts an und stelle druckfertige Vorlagen bereit.",
"subtitle": "Manage QR-Einladungen, Drucklayouts und Branding für deine Gäste.",
"tabs": {
"layout": "QR-Code-Layout anpassen",
"export": "Drucken & Export",
"links": "QR-Codes verwalten"
},
"summary": {
"active": "Aktive Einladungen",
"total": "Gesamt"
@@ -337,13 +342,26 @@
"layoutFallback": "Standard",
"selected": "Aktuell ausgewählt",
"tapToEdit": "Zum Anpassen auswählen",
"noPrintSource": "Keine druckbare Version verfügbar."
"noPrintSource": "Keine druckbare Version verfügbar.",
"standard": "Standard-Link",
"qrAlt": "QR-Code Vorschau"
},
"empty": {
"title": "Noch keine Einladungen",
"copy": "Erstelle eine Einladung, um druckfertige QR-Layouts zu erhalten."
},
"errorTitle": "Aktion fehlgeschlagen",
"export": {
"title": "Drucken & Export",
"description": "Lade druckfertige Dateien herunter oder starte direkt einen Testdruck.",
"selectPlaceholder": "Einladung auswählen",
"noInviteSelected": "Wähle zunächst eine Einladung aus, um Downloads zu starten.",
"noLayouts": "Für diese Einladung sind aktuell keine Layouts verfügbar.",
"actions": {
"print": "Direkt drucken"
},
"errorTitle": "Download fehlgeschlagen"
},
"customizer": {
"heading": "Layout anpassen",
"copy": "Verleihe der Einladung euren Ton: Texte, Farben und Logo lassen sich live bearbeiten.",
@@ -361,7 +379,7 @@
"text": "Texte",
"instructions": "Schritt-für-Schritt",
"instructionsHint": "Helft euren Gästen mit klaren Aufgaben. Maximal fünf Punkte.",
"branding": "Branding"
"branding": "Farbgebung"
},
"fields": {
"headline": "Überschrift",
@@ -381,7 +399,13 @@
},
"preview": {
"title": "Live-Vorschau",
"subtitle": "So sieht dein Layout beim Export aus."
"subtitle": "So sieht dein Layout beim Export aus.",
"mobileOpen": "Vorschau anzeigen",
"mobileTitle": "Einladungsvorschau",
"mobileHint": "Öffnet eine Vorschau in einem Overlay",
"readyForGuests": "Bereit für Gäste",
"instructions": "Dieser Link führt Gäste direkt zur Galerie und funktioniert zusammen mit dem QR-Code auf dem Ausdruck.",
"qrAlt": "QR-Code der Einladung"
},
"placeholderTitle": "Kein Layout verfügbar",
"placeholderCopy": "Erstelle eine Einladung, damit du Texte, Farben und Drucklayouts bearbeiten kannst.",

View File

@@ -318,6 +318,11 @@
"cardTitle": "QR invites & layouts",
"cardDescription": "Create invite links, customise layouts, and prepare print-ready PDFs.",
"subtitle": "Manage invite links, layouts, and branding for your guests.",
"tabs": {
"layout": "Customise layout",
"export": "Print & export",
"links": "Manage invites"
},
"summary": {
"active": "Active invites",
"total": "Total"
@@ -337,13 +342,26 @@
"layoutFallback": "Default",
"selected": "Currently selected",
"tapToEdit": "Select to edit",
"noPrintSource": "No printable version available."
"noPrintSource": "No printable version available.",
"standard": "Default link",
"qrAlt": "QR preview"
},
"empty": {
"title": "No invites yet",
"copy": "Create an invite to generate ready-to-print QR layouts."
},
"errorTitle": "Action failed",
"export": {
"title": "Print & export",
"description": "Download print-ready files or launch a test print right away.",
"selectPlaceholder": "Select invite",
"noInviteSelected": "Select an invite first to start downloads.",
"noLayouts": "There are currently no layouts available for this invite.",
"actions": {
"print": "Print now"
},
"errorTitle": "Download failed"
},
"customizer": {
"heading": "Customise layout",
"copy": "Make the invite your own adjust copy, colours, and logos in real time.",
@@ -361,7 +379,7 @@
"text": "Text",
"instructions": "Step-by-step",
"instructionsHint": "Guide guests with clear steps. Maximum of five.",
"branding": "Branding"
"branding": "Colors"
},
"fields": {
"headline": "Headline",
@@ -381,7 +399,13 @@
},
"preview": {
"title": "Live preview",
"subtitle": "See the export-ready version instantly."
"subtitle": "See the export-ready version instantly.",
"mobileOpen": "Show preview",
"mobileTitle": "Invite preview",
"mobileHint": "Opens a preview overlay",
"readyForGuests": "Ready for guests",
"instructions": "This link takes guests directly to the gallery and works together with the printed QR code.",
"qrAlt": "Invite QR code"
},
"placeholderTitle": "No layout available",
"placeholderCopy": "Create an invite first to customise copy, colours, and print layouts.",

View File

@@ -13,7 +13,15 @@ import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { AdminLayout } from '../components/AdminLayout';
import { createEvent, getEvent, getTenantPackagesOverview, updateEvent, getPackages, getEventTypes } from '../api';
import {
createEvent,
getEvent,
getTenantPackagesOverview,
updateEvent,
getPackages,
getEventTypes,
TenantEvent,
} from '../api';
import { isAuthError } from '../auth/tokens';
import { ADMIN_BILLING_PATH, ADMIN_EVENT_VIEW_PATH, ADMIN_EVENTS_PATH } from '../constants';
@@ -65,7 +73,6 @@ export default function EventFormPage() {
});
const [autoSlug, setAutoSlug] = React.useState(true);
const [originalSlug, setOriginalSlug] = React.useState<string | null>(null);
const [loading, setLoading] = React.useState(isEdit);
const [saving, setSaving] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const [readOnlyPackageName, setReadOnlyPackageName] = React.useState<string | null>(null);
@@ -107,6 +114,17 @@ export default function EventFormPage() {
setReadOnlyPackageName((prev) => prev ?? activePackage.package_name);
}, [isEdit, activePackage]);
const {
data: loadedEvent,
isLoading: eventLoading,
error: eventLoadError,
} = useQuery<TenantEvent>({
queryKey: ['tenant', 'events', slugParam],
queryFn: () => getEvent(slugParam!),
enabled: Boolean(isEdit && slugParam),
staleTime: 60_000,
});
React.useEffect(() => {
if (isEdit) {
return;
@@ -128,54 +146,45 @@ export default function EventFormPage() {
}, [eventTypes, isEdit]);
React.useEffect(() => {
let cancelled = false;
if (!isEdit || !slugParam) {
setLoading(false);
return () => {
cancelled = true;
};
if (!isEdit || !loadedEvent) {
return;
}
(async () => {
try {
const event = await getEvent(slugParam);
if (cancelled) return;
const name = normalizeName(event.name);
setForm((prev) => ({
...prev,
name,
slug: event.slug,
date: event.event_date ? event.event_date.slice(0, 10) : '',
eventTypeId: event.event_type_id ?? prev.eventTypeId,
isPublished: event.status === 'published',
package_id: event.package?.id ? Number(event.package.id) : prev.package_id,
}));
setOriginalSlug(event.slug);
setReadOnlyPackageName(event.package?.name ?? null);
setEventPackageMeta(event.package
? {
id: Number(event.package.id),
name: event.package.name ?? (typeof event.package === 'string' ? event.package : ''),
purchasedAt: event.package.purchased_at ?? null,
expiresAt: event.package.expires_at ?? null,
}
: null);
setAutoSlug(false);
} catch (err) {
if (!isAuthError(err)) {
setError('Event konnte nicht geladen werden.');
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
})();
const name = normalizeName(loadedEvent.name);
return () => {
cancelled = true;
};
}, [isEdit, slugParam]);
setForm((prev) => ({
...prev,
name,
slug: loadedEvent.slug,
date: loadedEvent.event_date ? loadedEvent.event_date.slice(0, 10) : '',
eventTypeId: loadedEvent.event_type_id ?? prev.eventTypeId,
isPublished: loadedEvent.status === 'published',
package_id: loadedEvent.package?.id ? Number(loadedEvent.package.id) : prev.package_id,
}));
setOriginalSlug(loadedEvent.slug);
setReadOnlyPackageName(loadedEvent.package?.name ?? null);
setEventPackageMeta(loadedEvent.package
? {
id: Number(loadedEvent.package.id),
name: loadedEvent.package.name ?? (typeof loadedEvent.package === 'string' ? loadedEvent.package : ''),
purchasedAt: loadedEvent.package.purchased_at ?? null,
expiresAt: loadedEvent.package.expires_at ?? null,
}
: null);
setAutoSlug(false);
}, [isEdit, loadedEvent]);
React.useEffect(() => {
if (!isEdit || !eventLoadError) {
return;
}
if (!isAuthError(eventLoadError)) {
setError('Event konnte nicht geladen werden.');
}
}, [isEdit, eventLoadError]);
const loading = isEdit ? eventLoading : false;
function handleNameChange(value: string) {
setForm((prev) => ({ ...prev, name: value }));

View File

@@ -1,12 +1,15 @@
import React from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { ArrowLeft, Copy, Loader2, QrCode, RefreshCw, Share2, Sparkles, X } from 'lucide-react';
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 {
@@ -19,7 +22,7 @@ import {
updateEventQrInvite,
EventQrInviteLayout,
} from '../api';
import { isAuthError } from '../auth/tokens';
import { authorizedFetch, isAuthError } from '../auth/tokens';
import {
ADMIN_EVENTS_PATH,
ADMIN_EVENT_VIEW_PATH,
@@ -35,6 +38,8 @@ interface PageState {
error: string | null;
}
type TabKey = 'layout' | 'export' | 'links';
export default function EventInvitesPage(): JSX.Element {
const { slug } = useParams<{ slug?: string }>();
const navigate = useNavigate();
@@ -47,6 +52,14 @@ export default function EventInvitesPage(): JSX.Element {
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) {
@@ -70,6 +83,27 @@ export default function EventInvitesPage(): JSX.Element {
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');
@@ -78,6 +112,12 @@ export default function EventInvitesPage(): JSX.Element {
[state.invites, selectedInviteId]
);
React.useEffect(() => {
setExportError(null);
setExportDownloadBusy(null);
setExportPrintBusy(null);
}, [selectedInvite?.id]);
React.useEffect(() => {
if (state.invites.length === 0) {
setSelectedInviteId(null);
@@ -101,10 +141,12 @@ export default function EventInvitesPage(): JSX.Element {
}, [selectedInvite]);
React.useEffect(() => {
if (selectedInvite) {
console.debug('[Invites] Selected invite', selectedInvite.id, selectedInvite.layouts, selectedInvite.layouts_url);
if (currentCustomization?.mode === 'advanced') {
setDesignerMode('advanced');
} else if (designerMode !== 'standard' && currentCustomization) {
setDesignerMode('standard');
}
}, [selectedInvite]);
}, [currentCustomization?.mode]);
const inviteCountSummary = React.useMemo(() => {
const active = state.invites.filter((invite) => invite.is_active && !invite.revoked_at).length;
@@ -229,26 +271,155 @@ export default function EventInvitesPage(): JSX.Element {
}
}
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 = (
<>
<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')}
<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 variant="outline" onClick={() => navigate(ADMIN_EVENT_VIEW_PATH(slug))} className="border-slate-200 text-slate-600 hover:bg-slate-50">
<Button
size="sm"
variant="ghost"
onClick={() => navigate(ADMIN_EVENT_VIEW_PATH(slug))}
className="hover:text-foreground"
>
{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">
<Button
size="sm"
variant="ghost"
onClick={() => navigate(ADMIN_EVENT_PHOTOS_PATH(slug))}
className="hover:text-foreground"
>
{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">
<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 (
@@ -257,80 +428,311 @@ export default function EventInvitesPage(): JSX.Element {
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}
<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>
<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>
{state.error ? (
<Alert variant="destructive">
<AlertTitle>{t('invites.errorTitle', 'Aktion fehlgeschlagen')}</AlertTitle>
<AlertDescription>{state.error}</AlertDescription>
</Alert>
) : null}
<InviteLayoutCustomizerPanel
invite={selectedInvite ?? null}
eventName={eventName}
saving={customizerSaving}
resetting={customizerResetting}
onSave={handleSaveCustomization}
onReset={handleResetCustomization}
initialCustomization={currentCustomization}
/>
<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,
@@ -375,23 +777,32 @@ function InviteListCard({
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'}`}
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-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>
<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-slate-200 bg-slate-50 px-2 py-1 font-mono text-xs text-slate-700">
<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
@@ -408,7 +819,7 @@ function InviteListCard({
</Button>
</div>
<div className="grid gap-1 text-xs text-slate-500 sm:grid-cols-2">
<div className="grid gap-1 text-xs text-muted-foreground sm:grid-cols-2">
<span>
{t('invites.labels.usage', 'Nutzung')}: {usageLabel}
</span>
@@ -425,7 +836,7 @@ function InviteListCard({
{t('invites.labels.selected', 'Aktuell ausgewählt')}
</Badge>
) : (
<div className="text-xs text-slate-500">{t('invites.labels.tapToEdit', 'Zum Anpassen auswählen')}</div>
<div className="text-xs text-muted-foreground">{t('invites.labels.tapToEdit', 'Zum Anpassen auswählen')}</div>
)}
<Button
variant="ghost"
@@ -435,7 +846,7 @@ function InviteListCard({
onRevoke();
}}
disabled={revoking || invite.revoked_at !== null || !invite.is_active}
className="text-slate-500 hover:text-rose-500 disabled:opacity-50"
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')}
@@ -458,9 +869,9 @@ function InviteSkeleton() {
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>
<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')}
@@ -492,12 +903,12 @@ function getInviteStatus(invite: EventQrInvite): 'Aktiv' | 'Deaktiviert' | 'Abge
function statusBadgeClass(status: string): string {
if (status === 'Aktiv') {
return 'bg-emerald-100 text-emerald-700 border-emerald-200';
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';
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';
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 {

View File

@@ -18,6 +18,7 @@ import {
ADMIN_EVENT_PHOTOS_PATH,
ADMIN_EVENT_MEMBERS_PATH,
ADMIN_EVENT_TASKS_PATH,
ADMIN_EVENT_INVITES_PATH,
ADMIN_EVENT_TOOLKIT_PATH,
} from '../constants';
@@ -157,7 +158,7 @@ function EventCard({ event }: { event: TenantEvent }) {
<Link to={ADMIN_EVENT_TASKS_PATH(slug)}>Tasks</Link>
</Button>
<Button asChild variant="outline" className="border-slate-200 text-slate-700 hover:bg-slate-50">
<Link to={`${ADMIN_EVENT_VIEW_PATH(slug)}#qr-invites`}>
<Link to={ADMIN_EVENT_INVITES_PATH(slug)}>
<Share2 className="h-3.5 w-3.5" /> QR-Einladungen
</Link>
</Button>
@@ -224,4 +225,3 @@ function renderName(name: TenantEvent['name']): string {
}
return 'Unbenanntes Event';
}