zu fabricjs gewechselt, noch nicht funktionsfähig

This commit is contained in:
Codex Agent
2025-10-31 20:19:09 +01:00
parent 06df61f706
commit eb0c31c90b
33 changed files with 7718 additions and 2062 deletions

View File

@@ -9,7 +9,6 @@ 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 {
@@ -22,7 +21,7 @@ import {
updateEventQrInvite,
EventQrInviteLayout,
} from '../api';
import { authorizedFetch, isAuthError } from '../auth/tokens';
import { isAuthError } from '../auth/tokens';
import {
ADMIN_EVENTS_PATH,
ADMIN_EVENT_VIEW_PATH,
@@ -30,6 +29,20 @@ import {
ADMIN_EVENT_PHOTOS_PATH,
} from '../constants';
import { InviteLayoutCustomizerPanel, QrLayoutCustomization } from './components/InviteLayoutCustomizerPanel';
import { DesignerCanvas } from './components/invite-layout/DesignerCanvas';
import {
buildDefaultElements,
normalizeElements,
payloadToElements,
LayoutElement,
} from './components/invite-layout/schema';
import {
generatePdfBytes,
generatePngDataUrl,
openPdfInNewTab,
triggerDownloadFromBlob,
triggerDownloadFromDataUrl,
} from './components/invite-layout/export-utils';
interface PageState {
event: TenantEvent | null;
@@ -40,6 +53,105 @@ interface PageState {
type TabKey = 'layout' | 'export' | 'links';
const HEX_COLOR_FULL = /^#([0-9A-Fa-f]{6})$/;
const HEX_COLOR_SHORT = /^#([0-9A-Fa-f]{3})$/;
function normalizeHexColor(value?: string | null): string | null {
if (!value) {
return null;
}
const trimmed = value.trim();
if (HEX_COLOR_FULL.test(trimmed)) {
return trimmed.toUpperCase();
}
if (HEX_COLOR_SHORT.test(trimmed)) {
const [, shorthand] = HEX_COLOR_SHORT.exec(trimmed)!;
const expanded = shorthand
.split('')
.map((char) => char + char)
.join('');
return `#${expanded}`.toUpperCase();
}
return null;
}
function normalizeGradient(value: unknown): { angle: number; stops: string[] } | null {
if (!value || typeof value !== 'object') {
return null;
}
const gradient = value as { angle?: unknown; stops?: unknown };
const angle = typeof gradient.angle === 'number' ? gradient.angle : 180;
const stops = Array.isArray(gradient.stops)
? gradient.stops
.map((stop) => normalizeHexColor(typeof stop === 'string' ? stop : null))
.filter((stop): stop is string => Boolean(stop))
: [];
return stops.length ? { angle, stops } : null;
}
function buildBackgroundStyle(background: string | null, gradient: { angle: number; stops: string[] } | null): React.CSSProperties {
if (gradient) {
return { backgroundImage: `linear-gradient(${gradient.angle}deg, ${gradient.stops.join(', ')})` };
}
return { backgroundColor: background ?? '#F8FAFC' };
}
function toStringList(value: unknown): string[] {
if (!value) {
return [];
}
if (Array.isArray(value)) {
return value
.map((entry) => (typeof entry === 'string' ? entry.trim() : ''))
.filter((entry) => entry.length > 0);
}
if (typeof value === 'object') {
return Object.values(value as Record<string, unknown>)
.map((entry) => (typeof entry === 'string' ? entry.trim() : ''))
.filter((entry) => entry.length > 0);
}
if (typeof value === 'string') {
const trimmed = value.trim();
return trimmed ? [trimmed] : [];
}
return [];
}
function ensureInstructionList(value: unknown, fallback: string[]): string[] {
const source = toStringList(value);
const base = source.length ? source : fallback;
return base
.map((entry) => (typeof entry === 'string' ? entry.trim() : ''))
.filter((entry) => entry.length > 0)
.slice(0, 5);
}
function formatPaperLabel(paper?: string | null): string {
if (!paper) {
return 'A4';
}
return paper.toUpperCase();
}
function formatQrSizeLabel(sizePx: number | null, fallback: string): string {
if (!sizePx || Number.isNaN(sizePx)) {
return fallback;
}
return `${sizePx}px`;
}
export default function EventInvitesPage(): JSX.Element {
const { slug } = useParams<{ slug?: string }>();
const navigate = useNavigate();
@@ -52,7 +164,6 @@ 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';
@@ -112,6 +223,7 @@ export default function EventInvitesPage(): JSX.Element {
[state.invites, selectedInviteId]
);
React.useEffect(() => {
setExportError(null);
setExportDownloadBusy(null);
@@ -140,13 +252,133 @@ export default function EventInvitesPage(): JSX.Element {
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');
const exportLayout = React.useMemo(() => {
if (!selectedInvite || selectedInvite.layouts.length === 0) {
return null;
}
}, [currentCustomization?.mode]);
const targetId = currentCustomization?.layout_id;
if (targetId) {
const match = selectedInvite.layouts.find((layout) => layout.id === targetId);
if (match) {
return match;
}
}
return selectedInvite.layouts[0];
}, [selectedInvite, currentCustomization?.layout_id]);
const exportPreview = React.useMemo(() => {
if (!exportLayout || !selectedInvite) {
return null;
}
const customization = currentCustomization ?? null;
const layoutPreview = exportLayout.preview ?? {};
const backgroundColor = normalizeHexColor(customization?.background_color ?? (layoutPreview.background as string | undefined)) ?? '#F8FAFC';
const accentColor = normalizeHexColor(customization?.accent_color ?? (layoutPreview.accent as string | undefined)) ?? '#6366F1';
const textColor = normalizeHexColor(customization?.text_color ?? (layoutPreview.text as string | undefined)) ?? '#111827';
const secondaryColor = normalizeHexColor(customization?.secondary_color ?? (layoutPreview.secondary as string | undefined)) ?? '#1F2937';
const badgeColor = normalizeHexColor(customization?.badge_color ?? (layoutPreview.accent as string | undefined)) ?? accentColor;
const gradient = normalizeGradient(customization?.background_gradient ?? layoutPreview.background_gradient ?? null);
const instructions = ensureInstructionList(customization?.instructions, exportLayout.instructions ?? []);
const workflowSteps = toStringList(t('invites.export.workflow.steps', { returnObjects: true }));
const tips = toStringList(t('invites.export.tips.items', { returnObjects: true }));
const formatKeys = exportLayout.formats ?? [];
const formatBadges = formatKeys.map((format) => String(format).toUpperCase());
const formatLabel = formatBadges.length ? formatBadges.join(' · ') : t('invites.export.meta.formatsNone', 'Keine Formate hinterlegt');
const qrSizePx = (layoutPreview.qr_size_px as number | undefined) ?? (exportLayout.qr?.size_px ?? null);
return {
backgroundStyle: buildBackgroundStyle(backgroundColor, gradient),
backgroundColor,
backgroundGradient: gradient,
badgeLabel: customization?.badge_label?.trim() || t('tasks.customizer.defaults.badgeLabel'),
badgeColor,
badgeTextColor: '#FFFFFF',
accentColor,
textColor,
secondaryColor,
headline: customization?.headline?.trim() || eventName,
subtitle: customization?.subtitle?.trim() || exportLayout.subtitle || '',
description: customization?.description?.trim() || exportLayout.description || '',
instructionsHeading: customization?.instructions_heading?.trim() || t('tasks.customizer.defaults.instructionsHeading'),
instructions: instructions.slice(0, 4),
linkHeading: customization?.link_heading?.trim() || t('tasks.customizer.defaults.linkHeading'),
linkLabel: (customization?.link_label?.trim() || selectedInvite.url || ''),
ctaLabel: customization?.cta_label?.trim() || t('tasks.customizer.defaults.ctaLabel'),
layoutLabel: exportLayout.name || t('invites.customizer.layoutFallback', 'Layout'),
layoutSubtitle: exportLayout.subtitle || '',
formatLabel,
formatBadges,
formats: formatKeys,
paperLabel: formatPaperLabel(exportLayout.paper),
orientationLabel:
exportLayout.orientation === 'landscape'
? t('invites.export.meta.orientationLandscape', 'Querformat')
: t('invites.export.meta.orientationPortrait', 'Hochformat'),
qrSizeLabel: formatQrSizeLabel(qrSizePx, t('invites.export.meta.qrSizeFallback', 'Automatisch')),
lastUpdated: selectedInvite.created_at ? formatDateTime(selectedInvite.created_at) : null,
mode: customization?.mode === 'advanced' ? 'advanced' : 'standard',
workflowSteps: workflowSteps.length
? workflowSteps
: [
t('invites.export.workflow.default1', 'Testdruck ausführen und Farben prüfen.'),
t('invites.export.workflow.default2', 'Ausdrucke laminieren oder in Schutzfolien stecken.'),
t('invites.export.workflow.default3', 'Mehrere QR-Codes im Eingangsbereich und an Hotspots platzieren.'),
],
tips: tips.length
? tips
: [
t('invites.export.tips.default1', 'Nutze Papier mit mindestens 160 g/m² für langlebige Ausdrucke.'),
t('invites.export.tips.default2', 'Drucke einen QR-Code zur Sicherheit in Reserve aus.'),
t('invites.export.tips.default3', 'Fotografiere den gedruckten QR-Code testweise, um die Lesbarkeit zu prüfen.'),
],
};
}, [exportLayout, currentCustomization, selectedInvite, eventName, t]);
const exportElements = React.useMemo<LayoutElement[]>(() => {
if (!exportLayout) {
return [];
}
if (currentCustomization?.mode === 'advanced' && Array.isArray(currentCustomization.elements) && currentCustomization.elements.length) {
return normalizeElements(payloadToElements(currentCustomization.elements));
}
const baseForm: QrLayoutCustomization = {
...currentCustomization,
layout_id: exportLayout.id,
link_label: currentCustomization?.link_label ?? selectedInvite?.url ?? '',
badge_label: currentCustomization?.badge_label ?? exportLayout.badge_label ?? undefined,
instructions: ensureInstructionList(currentCustomization?.instructions, exportLayout.instructions ?? []),
instructions_heading: currentCustomization?.instructions_heading ?? exportLayout.instructions_heading ?? undefined,
logo_data_url: currentCustomization?.logo_data_url ?? undefined,
logo_url: currentCustomization?.logo_url ?? undefined,
};
return buildDefaultElements(
exportLayout,
baseForm,
eventName,
exportLayout.preview?.qr_size_px ?? exportLayout.qr?.size_px ?? 480
);
}, [exportLayout, currentCustomization, selectedInvite?.url, eventName]);
const exportCanvasKey = React.useMemo(
() => `export:${selectedInvite?.id ?? 'none'}:${exportLayout?.id ?? 'layout'}:${exportPreview?.mode ?? 'standard'}`,
[selectedInvite?.id, exportLayout?.id, exportPreview?.mode]
);
const exportLogo = currentCustomization?.logo_data_url ?? currentCustomization?.logo_url ?? exportLayout?.logo_url ?? null;
const exportQr = selectedInvite?.qr_code_data_url ?? null;
const handlePreviewSelect = React.useCallback((_id: string | null) => undefined, []);
const handlePreviewChange = React.useCallback((_id: string, _patch: Partial<LayoutElement>) => undefined, []);
const inviteCountSummary = React.useMemo(() => {
const active = state.invites.filter((invite) => invite.is_active && !invite.revoked_at).length;
@@ -271,113 +503,118 @@ export default function EventInvitesPage(): JSX.Element {
}
}
const handleQrDownload = React.useCallback(async () => {
if (!selectedInvite?.qr_code_data_url) {
return;
}
try {
const response = await fetch(selectedInvite.qr_code_data_url);
const blob = await response.blob();
const objectUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = objectUrl;
link.download = `${selectedInvite.token || 'invite'}-qr.png`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
} catch (error) {
console.error('[Invites] QR download failed', error);
setExportError(t('invites.export.qr.error', 'QR-Code konnte nicht gespeichert werden.'));
}
}, [selectedInvite, t]);
const handleExportDownload = React.useCallback(
async (layout: EventQrInviteLayout, format: string, rawUrl?: string | null) => {
if (!selectedInvite) {
async (format: string) => {
if (!selectedInvite || !exportLayout || !exportPreview) {
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}`;
const busyKey = `${exportLayout.id}-${normalizedFormat}`;
setExportDownloadBusy(busyKey);
setExportError(null);
const exportOptions = {
elements: exportElements,
accentColor: exportPreview.accentColor,
textColor: exportPreview.textColor,
secondaryColor: exportPreview.secondaryColor ?? '#1F2937',
badgeColor: exportPreview.badgeColor,
qrCodeDataUrl: exportQr,
logoDataUrl: exportLogo,
backgroundColor: exportPreview.backgroundColor ?? '#FFFFFF',
backgroundGradient: exportPreview.backgroundGradient ?? null,
readOnly: true,
selectedId: null,
} as const;
const filenameStem = `${selectedInvite.token || 'invite'}-${exportLayout.id}`;
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}`);
if (normalizedFormat === 'png') {
const dataUrl = await generatePngDataUrl(exportOptions);
await triggerDownloadFromDataUrl(dataUrl, `${filenameStem}.png`);
} else if (normalizedFormat === 'pdf') {
const pdfBytes = await generatePdfBytes(
exportOptions,
exportLayout.paper ?? 'a4',
exportLayout.orientation ?? 'portrait',
);
triggerDownloadFromBlob(new Blob([pdfBytes], { type: 'application/pdf' }), `${filenameStem}.pdf`);
} else {
setExportError(t('invites.customizer.errors.downloadFailed', 'Download fehlgeschlagen. Bitte versuche es erneut.'));
}
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.'),
);
setExportError(t('invites.customizer.errors.downloadFailed', 'Download fehlgeschlagen. Bitte versuche es erneut.'));
} finally {
setExportDownloadBusy(null);
}
},
[selectedInvite, t]
[selectedInvite, exportLayout, exportPreview, exportElements, exportQr, exportLogo, t]
);
const handleExportPrint = React.useCallback(
async (layout: EventQrInviteLayout) => {
if (!selectedInvite) {
async () => {
if (!selectedInvite || !exportLayout || !exportPreview) {
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);
setExportPrintBusy(exportLayout.id);
setExportError(null);
const exportOptions = {
elements: exportElements,
accentColor: exportPreview.accentColor,
textColor: exportPreview.textColor,
secondaryColor: exportPreview.secondaryColor ?? '#1F2937',
badgeColor: exportPreview.badgeColor,
qrCodeDataUrl: exportQr,
logoDataUrl: exportLogo,
backgroundColor: exportPreview.backgroundColor ?? '#FFFFFF',
backgroundGradient: exportPreview.backgroundGradient ?? null,
readOnly: true,
selectedId: null,
} as const;
try {
const response = await authorizedFetch(resolveInternalUrl(rawUrl), {
headers: { Accept: 'application/pdf' },
});
const pdfBytes = await generatePdfBytes(
exportOptions,
exportLayout.paper ?? 'a4',
exportLayout.orientation ?? 'portrait',
);
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);
await openPdfInNewTab(pdfBytes);
} 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.'),
);
setExportError(t('invites.customizer.errors.printFailed', 'Druck konnte nicht gestartet werden.'));
} finally {
setExportPrintBusy(null);
}
},
[selectedInvite, t]
[selectedInvite, exportLayout, exportPreview, exportElements, exportQr, exportLogo, t]
);
const actions = (
@@ -452,29 +689,16 @@ export default function EventInvitesPage(): JSX.Element {
<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>
<h2 className="text-lg font-semibold text-foreground">{t('invites.customizer.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.')}
{t('invites.customizer.copy', 'Bearbeite Texte, Farben und Positionen direkt neben der Live-Vorschau. Änderungen werden sofort sichtbar.')}
</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}
@@ -484,8 +708,6 @@ export default function EventInvitesPage(): JSX.Element {
onReset={handleResetCustomization}
initialCustomization={currentCustomization}
/>
) : (
<AdvancedDesignerPlaceholder onBack={() => setDesignerMode('standard')} />
)}
</section>
</TabsContent>
@@ -499,7 +721,7 @@ export default function EventInvitesPage(): JSX.Element {
{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.')}
{t('invites.export.description', 'Überprüfe das Layout, starte Testdrucke und exportiere alle Formate.')}
</CardDescription>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:gap-3">
@@ -533,72 +755,225 @@ export default function EventInvitesPage(): JSX.Element {
<CardContent className="space-y-6">
{exportError ? (
<Alert variant="destructive">
<AlertTitle>{t('invites.export.errorTitle', 'Download fehlgeschlagen')}</AlertTitle>
<AlertTitle>{t('invites.export.errorTitle', 'Aktion 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">
exportPreview && exportLayout ? (
<div className="space-y-6">
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.6fr)_minmax(0,0.6fr)]">
<div className="space-y-6">
<div className="rounded-3xl border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-strong)]/80 p-6 shadow-inner transition-colors">
<div className="flex flex-col gap-3 border-b border-[var(--tenant-border-strong)] pb-4 sm:flex-row sm:items-start sm:justify-between">
<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>
<h3 className="text-base font-semibold text-foreground">{exportPreview.layoutLabel}</h3>
{exportPreview.layoutSubtitle ? (
<p className="text-xs text-muted-foreground">{exportPreview.layoutSubtitle}</p>
) : null}
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<Badge variant="outline" className="border-primary/40 bg-primary/10 text-primary">
{exportPreview.mode === 'advanced'
? t('invites.export.mode.advanced', 'Freier Editor')
: t('invites.export.mode.standard', 'Standardlayout')}
</Badge>
<span>{exportPreview.paperLabel}</span>
<span></span>
<span>{exportPreview.orientationLabel}</span>
</div>
</div>
{layout.formats?.length ? (
<Badge className="bg-amber-500/15 text-amber-700">
{layout.formats.map((format) => String(format).toUpperCase()).join(' · ')}
</Badge>
) : null}
<p className="text-xs text-muted-foreground sm:max-w-[220px]">
{t('invites.export.previewHint', 'Speichere nach Änderungen, um neue Exportdateien zu erzeugen.')}
</p>
</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 className="mt-6 flex justify-center">
{exportElements.length ? (
<div className="pointer-events-none">
<DesignerCanvas
elements={exportElements}
selectedId={null}
onSelect={handlePreviewSelect}
onChange={handlePreviewChange}
background={exportPreview.backgroundColor}
gradient={exportPreview.backgroundGradient}
accent={exportPreview.accentColor}
text={exportPreview.textColor}
secondary={exportPreview.secondaryColor}
badge={exportPreview.badgeColor}
qrCodeDataUrl={exportQr}
logoDataUrl={exportLogo}
scale={0.34}
layoutKey={exportCanvasKey}
/>
</div>
) : (
<div className="rounded-3xl border border-dashed border-[var(--tenant-border-strong)] bg-[var(--tenant-layer)]/80 px-10 py-20 text-center text-sm text-[var(--tenant-foreground-soft)]">
{t('invites.export.noLayoutPreview', 'Für diese Kombination liegt noch keine Vorschau vor. Speichere das Layout zuerst.')}
</div>
)}
</div>
</div>
);
})}
<div className="grid gap-6 lg:grid-cols-2">
<Card className="border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-strong)]/70 shadow-sm">
<CardHeader className="gap-1">
<CardTitle className="text-sm text-foreground">{t('invites.export.meta.title', 'Layout-Details')}</CardTitle>
<CardDescription className="text-xs text-muted-foreground">{t('invites.export.meta.description', 'Wichtige Kennzahlen für den Druck.')}</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<div className="flex items-center justify-between">
<span className="text-muted-foreground">{t('invites.export.meta.paper', 'Papierformat')}</span>
<span className="font-medium text-foreground">{exportPreview.paperLabel}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">{t('invites.export.meta.orientation', 'Ausrichtung')}</span>
<span className="font-medium text-foreground">{exportPreview.orientationLabel}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">{t('invites.export.meta.qrSize', 'QR-Code-Größe')}</span>
<span className="font-medium text-foreground">{exportPreview.qrSizeLabel}</span>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-muted-foreground">{t('invites.export.meta.formats', 'Verfügbare Formate')}</span>
<div className="flex flex-wrap gap-1">
{exportPreview.formatBadges.map((item) => (
<Badge key={item} className="bg-primary/10 text-primary">
{item}
</Badge>
))}
</div>
</div>
{exportPreview.lastUpdated ? (
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>{t('invites.export.meta.updated', 'Zuletzt aktualisiert')}</span>
<span>{exportPreview.lastUpdated}</span>
</div>
) : null}
</CardContent>
</Card>
<Card className="border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-strong)]/70 shadow-sm">
<CardHeader className="gap-1">
<CardTitle className="text-sm text-foreground">{t('invites.export.workflow.title', 'Ablauf vor dem Event')}</CardTitle>
<CardDescription className="text-xs text-muted-foreground">{t('invites.export.workflow.description', 'So stellst du sicher, dass Gäste den QR-Code finden.')}</CardDescription>
</CardHeader>
<CardContent>
<ol className="space-y-3 text-sm text-muted-foreground">
{exportPreview.workflowSteps.map((step, index) => (
<li key={`workflow-step-${index}`} className="flex items-start gap-3">
<span className="mt-0.5 flex h-6 w-6 items-center justify-center rounded-full bg-primary/10 text-xs font-semibold text-primary">
{index + 1}
</span>
<span>{step}</span>
</li>
))}
</ol>
</CardContent>
</Card>
</div>
</div>
<div className="space-y-6">
<Card className="border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-strong)]/70 shadow-sm">
<CardHeader className="gap-1">
<CardTitle className="text-sm text-foreground">{t('invites.export.actions.title', 'Aktionen')}</CardTitle>
<CardDescription className="text-xs text-muted-foreground">{t('invites.export.actions.description', 'Starte deinen Testdruck oder lade die Layouts herunter.')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Button
size="lg"
className="w-full bg-gradient-to-r from-amber-500 via-orange-500 to-rose-500 text-white shadow-lg shadow-rose-500/30 hover:from-amber-400 hover:via-orange-500 hover:to-rose-500"
onClick={() => void handleExportPrint()}
disabled={exportPrintBusy === exportLayout.id || Boolean(exportDownloadBusy)}
>
{exportPrintBusy === exportLayout.id ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Printer className="mr-2 h-4 w-4" />
)}
{t('invites.export.actions.printNow', 'Direkt drucken')}
</Button>
<div className="grid gap-2 sm:grid-cols-2">
{exportPreview.formats.map((format) => {
const key = format.toLowerCase();
const busyKey = `${exportLayout.id}-${key}`;
const isBusy = exportDownloadBusy === busyKey;
return (
<Button
key={busyKey}
variant="outline"
onClick={() => void handleExportDownload(key)}
disabled={(!!exportDownloadBusy && !isBusy) || exportPrintBusy === exportLayout.id}
className="justify-between"
>
<span>{format.toUpperCase()}</span>
{isBusy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Download className="h-4 w-4" />}
</Button>
);
})}
</div>
<p className="text-xs text-muted-foreground">
{t('invites.export.actions.hint', 'PDF enthält Beschnittmarken, PNG ist für schnelle digitale Freigaben geeignet.')}
</p>
</CardContent>
</Card>
<Card className="border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-strong)]/70 shadow-sm">
<CardHeader className="gap-1">
<CardTitle className="text-sm text-foreground">{t('invites.export.qr.title', 'QR-Code & Link')}</CardTitle>
<CardDescription className="text-xs text-muted-foreground">{t('invites.export.qr.description', 'Verteile den Link digital oder erstelle weitere Auszüge.')}</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex justify-center">
{selectedInvite.qr_code_data_url ? (
<img
src={selectedInvite.qr_code_data_url}
alt={t('invites.export.qr.alt', 'QR-Code der Einladung')}
className="h-40 w-40 rounded-2xl border border-[var(--tenant-border-strong)] bg-white p-3 shadow-md"
/>
) : (
<div className="flex h-40 w-40 items-center justify-center rounded-2xl border border-dashed border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-muted)] text-xs text-muted-foreground">
{t('invites.export.qr.placeholder', 'QR-Code wird nach dem Speichern generiert.')}
</div>
)}
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<Button variant="outline" className="w-full" onClick={() => void handleQrDownload()} disabled={!selectedInvite.qr_code_data_url}>
<Download className="mr-2 h-4 w-4" />
{t('invites.export.qr.download', 'QR-Code speichern')}
</Button>
<Button
variant="outline"
className="w-full"
onClick={() => handleCopy(selectedInvite)}
>
<Copy className="mr-2 h-4 w-4" />
{t('invites.export.qr.copyLink', 'Link kopieren')}
</Button>
</div>
<div className="flex items-center justify-between rounded-lg border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface)] px-3 py-2 text-xs text-muted-foreground">
<span className="truncate font-mono">{selectedInvite.url}</span>
</div>
</CardContent>
</Card>
<Alert className="border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-muted)]/80">
<AlertTitle>{t('invites.export.tips.title', 'Tipps für perfekte Ausdrucke')}</AlertTitle>
<AlertDescription>
<ul className="mt-2 space-y-1 text-sm text-muted-foreground">
{exportPreview.tips.map((tip, index) => (
<li key={`export-tip-${index}`} className="flex gap-2">
<span></span>
<span>{tip}</span>
</li>
))}
</ul>
</AlertDescription>
</Alert>
</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)]">
@@ -681,19 +1056,6 @@ export default function EventInvitesPage(): JSX.Element {
);
}
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">
@@ -709,30 +1071,6 @@ function InviteCustomizerSkeleton(): JSX.Element {
</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,