// @ts-nocheck import React from 'react'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { AlertTriangle, ArrowLeft, CheckCircle2, Circle, Copy, Download, ExternalLink, Link2, Loader2, Mail, Printer, QrCode, RefreshCw, Share2, X, ShoppingCart, Save, Plus } 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 toast from 'react-hot-toast'; import { AdminLayout } from '../components/AdminLayout'; import { createQrInvite, EventQrInvite, getEvent, getEventQrInvites, revokeEventQrInvite, TenantEvent, updateEventQrInvite, EventQrInviteLayout, createEventAddonCheckout, getAddonCatalog, type EventAddonCatalogItem, } from '../api'; import { isAuthError } from '../auth/tokens'; import { ADMIN_EVENTS_PATH, } from '../constants'; import { buildLimitWarnings } from '../lib/limitWarnings'; import { buildEventTabs } from '../lib/eventTabs'; import { getApiErrorMessage } from '../lib/apiError'; import { AddonsPicker } from '../components/Addons/AddonsPicker'; import { AddonSummaryList } from '../components/Addons/AddonSummaryList'; import { InviteLayoutCustomizerPanel, QrLayoutCustomization } from './components/InviteLayoutCustomizerPanel'; import { buildDownloadFilename, normalizeEventDateSegment } from './components/invite-layout/fileNames'; import { DesignerCanvas } from './components/invite-layout/DesignerCanvas'; import { CANVAS_HEIGHT, CANVAS_WIDTH, buildDefaultElements, clamp, normalizeElements, payloadToElements, LayoutElement, } from './components/invite-layout/schema'; import { generatePdfBytes, generatePngDataUrl, openPdfInNewTab, triggerDownloadFromBlob, triggerDownloadFromDataUrl, } from './components/invite-layout/export-utils'; import { preloadedBackgrounds } from './components/invite-layout/backgrounds'; import { useOnboardingProgress } from '../onboarding'; import { FloatingActionBar, type FloatingAction } from '../components/FloatingActionBar'; interface PageState { event: TenantEvent | null; invites: EventQrInvite[]; loading: boolean; error: string | null; } type TabKey = 'layout' | 'share' | 'export'; function resolveTabKey(value: string | null): TabKey { if (value === 'export') { return 'export'; } if (value === 'share' || value === 'links') { return 'share'; } return 'layout'; } 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) .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(): React.ReactElement { const { slug } = useParams<{ slug?: string }>(); const navigate = useNavigate(); const { t } = useTranslation('management'); const { t: tLimits } = useTranslation('common', { keyPrefix: 'limits' }); const [state, setState] = React.useState({ event: null, invites: [], loading: true, error: null }); const [creatingInvite, setCreatingInvite] = React.useState(false); const [revokingId, setRevokingId] = React.useState(null); const [selectedInviteId, setSelectedInviteId] = React.useState(null); const [copiedInviteId, setCopiedInviteId] = React.useState(null); const [customizerSaving, setCustomizerSaving] = React.useState(false); const [customizerResetting, setCustomizerResetting] = React.useState(false); const [customizerDraft, setCustomizerDraft] = React.useState(null); const [searchParams, setSearchParams] = useSearchParams(); const tabParam = searchParams.get('tab'); const initialTab = resolveTabKey(tabParam); const [activeTab, setActiveTab] = React.useState(initialTab); const [exportDownloadBusy, setExportDownloadBusy] = React.useState(null); const [exportPrintBusy, setExportPrintBusy] = React.useState(null); const [exportError, setExportError] = React.useState(null); const exportPreviewContainerRef = React.useRef(null); const [exportScale, setExportScale] = React.useState(0.34); const { markStep } = useOnboardingProgress(); 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, catalog] = await Promise.all([ getEvent(slug), getEventQrInvites(slug), getAddonCatalog(), ]); setState({ event: eventData, invites: invitesData, loading: false, error: null }); setSelectedInviteId((current) => current ?? invitesData[0]?.id ?? null); setAddonsCatalog(catalog); } catch (error) { if (!isAuthError(error)) { setState({ event: null, invites: [], loading: false, error: 'QR-QR-Code konnten nicht geladen werden.' }); } } }, [slug]); const recomputeExportScale = React.useCallback(() => { const container = exportPreviewContainerRef.current; if (!container) { return; } const widthRatio = container.clientWidth / CANVAS_WIDTH; const heightRatio = container.clientHeight ? container.clientHeight / CANVAS_HEIGHT : Number.POSITIVE_INFINITY; const portraitRatio = 1754 / 1240; // A4 height/width for portrait priority const adjustedHeightRatio = heightRatio * portraitRatio; const base = Math.min(widthRatio, adjustedHeightRatio); const safeBase = Number.isFinite(base) && base > 0 ? base : 1; const clampedScale = clamp(safeBase, 0.1, 1); setExportScale((prev) => (Math.abs(prev - clampedScale) < 0.001 ? prev : clampedScale)); }, []); React.useEffect(() => { void load(); }, [load]); React.useEffect(() => { recomputeExportScale(); }, [recomputeExportScale]); React.useEffect(() => { const handleResize = () => recomputeExportScale(); window.addEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize); }, [recomputeExportScale]); React.useEffect(() => { const nextTab = resolveTabKey(searchParams.get('tab')); setActiveTab((current) => (current === nextTab ? current : nextTab)); }, [searchParams]); const handleTabChange = React.useCallback( (value: string) => { const nextTab = resolveTabKey(value); setActiveTab(nextTab); const nextParams = new URLSearchParams(searchParams); if (nextTab === 'layout') { nextParams.delete('tab'); } else { nextParams.set('tab', nextTab === 'share' ? 'share' : 'export'); } setSearchParams(nextParams, { replace: true }); }, [searchParams, setSearchParams] ); const event = state.event; const eventName = event ? renderEventName(event.name) : t('toolkit.titleFallback', 'Event'); const eventDate = event?.event_date ?? null; const eventTabs = React.useMemo(() => { if (!event || !slug) { return []; } const translateMenu = (key: string, fallback: string) => t(key, { defaultValue: fallback }); return buildEventTabs(event, translateMenu, { photos: event.photo_count ?? event.pending_photo_count ?? undefined, tasks: event.tasks_count ?? undefined, }); }, [event, slug, state.invites.length, t]); 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]); React.useEffect(() => { setCustomizerDraft(null); }, [selectedInviteId]); const currentCustomization = React.useMemo(() => { if (!selectedInvite) { return null; } const metadata = selectedInvite.metadata as Record | undefined | null; const raw = metadata?.layout_customization; return raw && typeof raw === 'object' ? (raw as QrLayoutCustomization) : null; }, [selectedInvite]); const effectiveCustomization = customizerDraft ?? currentCustomization; const exportLayout = React.useMemo(() => { if (!selectedInvite || selectedInvite.layouts.length === 0) { return null; } const targetId = effectiveCustomization?.layout_id; if (targetId) { const match = selectedInvite.layouts.find((layout) => layout.id === targetId); if (match) { return match; } } return selectedInvite.layouts[0]; }, [selectedInvite, effectiveCustomization?.layout_id]); const exportPreview = React.useMemo(() => { if (!exportLayout || !selectedInvite) { return null; } const customization = effectiveCustomization ?? 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 = '#1F2937'; const badgeColor = normalizeHexColor(customization?.badge_color ?? (layoutPreview.accent as string | undefined)) ?? accentColor; const gradient = normalizeGradient(customization?.background_gradient ?? layoutPreview.background_gradient ?? null); const backgroundImage = customization?.background_image ?? 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) ?? 480; return { backgroundStyle: buildBackgroundStyle(backgroundColor, gradient), backgroundColor, backgroundGradient: gradient, backgroundImage, 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('a4'), orientationLabel: 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, effectiveCustomization, selectedInvite, eventName, t]); const exportElements = React.useMemo(() => { if (!exportLayout) { return []; } if (effectiveCustomization?.mode === 'advanced' && Array.isArray(effectiveCustomization.elements) && effectiveCustomization.elements.length) { return normalizeElements(payloadToElements(effectiveCustomization.elements)); } const baseForm: QrLayoutCustomization = { ...effectiveCustomization, layout_id: exportLayout.id, link_label: effectiveCustomization?.link_label ?? selectedInvite?.url ?? '', badge_label: effectiveCustomization?.badge_label ?? exportLayout.badge_label ?? undefined, instructions: ensureInstructionList(effectiveCustomization?.instructions, exportLayout.instructions ?? []), instructions_heading: effectiveCustomization?.instructions_heading ?? exportLayout.instructions_heading ?? undefined, logo_data_url: effectiveCustomization?.logo_data_url ?? undefined, logo_url: effectiveCustomization?.logo_url ?? undefined, }; return buildDefaultElements( exportLayout, baseForm, eventName, exportLayout.preview?.qr_size_px ?? 480 ); }, [exportLayout, effectiveCustomization, selectedInvite?.url, eventName]); React.useEffect(() => { if (activeTab !== 'export') { return; } recomputeExportScale(); }, [activeTab, recomputeExportScale, exportElements.length, exportLayout?.id, selectedInvite?.id]); React.useEffect(() => { if (typeof ResizeObserver !== 'function') { return undefined; } const target = exportPreviewContainerRef.current; if (!target) { return undefined; } const observer = new ResizeObserver(() => recomputeExportScale()); observer.observe(target); return () => observer.disconnect(); }, [recomputeExportScale, activeTab]); const exportCanvasKey = React.useMemo( () => `export:${selectedInvite?.id ?? 'none'}:${exportLayout?.id ?? 'layout'}:${exportPreview?.mode ?? 'standard'}`, [selectedInvite?.id, exportLayout?.id, exportPreview?.mode] ); const exportLogo = effectiveCustomization?.logo_data_url ?? effectiveCustomization?.logo_url ?? null; const exportQr = selectedInvite?.qr_code_data_url ?? null; const handlePreviewSelect = React.useCallback(() => undefined, []); const handlePreviewChange = React.useCallback(() => undefined, []); const handleCustomizerDraftChange = React.useCallback((draft: QrLayoutCustomization | null) => { setCustomizerDraft((previous) => { const prevSignature = previous ? JSON.stringify(previous) : null; const nextSignature = draft ? JSON.stringify(draft) : null; if (prevSignature === nextSignature) { return previous; } return draft; }); }, []); 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]); const primaryInvite = React.useMemo(() => selectedInvite ?? state.invites[0] ?? null, [selectedInvite, state.invites]); const workflowSteps = React.useMemo(() => { const layoutReady = Boolean(effectiveCustomization); const shareReady = state.invites.length > 0; const exportReady = Boolean(exportPreview && exportElements.length); const mapStatus = (tab: TabKey, done: boolean) => { if (done) return 'done'; if (activeTab === tab) return 'active'; return 'pending'; }; return [ { key: 'layout', title: t('invites.workflow.steps.layout.title', 'Vorlage wählen'), description: t('invites.workflow.steps.layout.description', 'Wähle ein Layout und passe Texte, Farben und QR-Elemente an.'), status: mapStatus('layout', layoutReady), }, { key: 'share', title: t('invites.workflow.steps.share.title', 'Links & QR teilen'), description: t('invites.workflow.steps.share.description', 'Aktiviere Gästelinks, kopiere QR-Codes und verteile sie im Team.'), status: mapStatus('share', shareReady), }, { key: 'export', title: t('invites.workflow.steps.export.title', 'Drucken & Export'), description: t('invites.workflow.steps.export.description', 'Erzeuge PDFs oder PNGs für den Druck deiner Karten.'), status: mapStatus('export', exportReady), }, ]; }, [activeTab, effectiveCustomization, exportElements.length, exportPreview, state.invites.length, t]); 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); toast.success(t('invites.actions.copied', 'Link kopiert')); } catch { // ignore clipboard failures } toast.success(t('invites.actions.created', 'QR-Code erstellt')); markStep({ lastStep: 'invite', serverStep: 'invite_created', meta: { invite_id: invite.id }, }); } catch (error) { if (!isAuthError(error)) { setState((prev) => ({ ...prev, error: 'QR-QR-Code konnte nicht erstellt werden.' })); toast.error(t('invites.actions.createFailed', 'QR-Code konnte nicht erstellt werden.')); } } finally { setCreatingInvite(false); } } async function handleCopy(invite: EventQrInvite) { try { await navigator.clipboard.writeText(invite.url); setCopiedInviteId(invite.id); toast.success(t('invites.actions.copied', 'Link kopiert')); } catch (error) { console.warn('[Invites] Clipboard copy failed', error); toast.error(t('invites.actions.copyFailed', 'Link konnte nicht kopiert werden.')); } } 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)); } toast.success(t('invites.actions.revoked', 'QR-Code deaktiviert')); } catch (error) { if (!isAuthError(error)) { setState((prev) => ({ ...prev, error: 'QR-QR-Code konnte nicht deaktiviert werden.' })); toast.error(t('invites.actions.revokeFailed', 'QR-Code 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)), })); setCustomizerDraft(null); toast.success(t('invites.customizer.toastSaved', 'Layout gespeichert')); markStep({ lastStep: 'branding', serverStep: 'branding_configured', meta: { invite_id: selectedInvite.id, has_custom_branding: true, }, }); } catch (error) { if (!isAuthError(error)) { setState((prev) => ({ ...prev, error: 'Anpassung konnte nicht gespeichert werden.' })); toast.error(t('invites.customizer.toastSaveFailed', 'Layout 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)), })); setCustomizerDraft(null); } catch (error) { if (!isAuthError(error)) { setState((prev) => ({ ...prev, error: 'Anpassungen konnten nicht zurückgesetzt werden.' })); } } finally { setCustomizerResetting(false); } } 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; const eventDateSegment = normalizeEventDateSegment(eventDate); const downloadName = buildDownloadFilename( ['QR Code fuer', eventName, eventDateSegment], 'png', 'qr-code', ); link.download = downloadName; 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, eventName, eventDate, t]); const handleExportDownload = React.useCallback( async (format: string) => { if (!selectedInvite || !exportLayout || !exportPreview) { return; } const normalizedFormat = format.toLowerCase(); const busyKey = `${exportLayout.id}-${normalizedFormat}`; setExportDownloadBusy(busyKey); setExportError(null); const eventDateSegment = normalizeEventDateSegment(eventDate); const filename = buildDownloadFilename( ['QR-Codeslayout', eventName, exportLayout.name ?? null, eventDateSegment], normalizedFormat, 'QR-Codeslayout', ); 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, backgroundImageUrl: exportPreview.backgroundImage ?? null, readOnly: true, selectedId: null, } as const; try { if (normalizedFormat === 'png') { const dataUrl = await generatePngDataUrl(exportOptions); await triggerDownloadFromDataUrl(dataUrl, filename); } else if (normalizedFormat === 'pdf') { const pdfBytes = await generatePdfBytes( exportOptions, 'a4', 'portrait', ); triggerDownloadFromBlob(new Blob([pdfBytes.buffer as ArrayBuffer], { type: 'application/pdf' }), filename); } else { setExportError(t('invites.customizer.errors.downloadFailed', 'Download fehlgeschlagen. Bitte versuche es erneut.')); } } catch (error) { console.error('[Invites] Export download failed', error); setExportError(t('invites.customizer.errors.downloadFailed', 'Download fehlgeschlagen. Bitte versuche es erneut.')); } finally { setExportDownloadBusy(null); } }, [selectedInvite, exportLayout, exportPreview, exportElements, exportQr, exportLogo, eventName, eventDate, t] ); const handleExportPrint = React.useCallback( async () => { if (!selectedInvite || !exportLayout || !exportPreview) { return; } 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, backgroundImageUrl: exportPreview.backgroundImage ?? null, readOnly: true, selectedId: null, } as const; try { const pdfBytes = await generatePdfBytes( exportOptions, 'a4', 'portrait', ); await openPdfInNewTab(pdfBytes); } catch (error) { console.error('[Invites] Export print failed', error); setExportError(t('invites.customizer.errors.printFailed', 'Druck konnte nicht gestartet werden.')); } finally { setExportPrintBusy(null); } }, [selectedInvite, exportLayout, exportPreview, exportElements, exportQr, exportLogo, t] ); const actions = (
); const limitWarnings = React.useMemo( () => buildLimitWarnings(state.event?.limits, tLimits), [state.event?.limits, tLimits] ); const [addonBusy, setAddonBusy] = React.useState(null); const [addonsCatalog, setAddonsCatalog] = React.useState([]); //const [searchParams] = useSearchParams(); const handleAddonPurchase = React.useCallback( async (addonKey?: string) => { if (!slug) return; setAddonBusy('guests'); const key = addonKey ?? 'extra_guests_100'; try { const currentUrl = window.location.origin + window.location.pathname; const successUrl = `${currentUrl}?addon_success=1`; const checkout = await createEventAddonCheckout(slug, { addon_key: key, quantity: 1, success_url: successUrl, cancel_url: currentUrl, }); if (checkout.checkout_url) { window.location.href = checkout.checkout_url; } } catch (err) { toast(getApiErrorMessage(err, 'Checkout fehlgeschlagen.')); } finally { setAddonBusy(null); } }, [slug], ); const fabActions = React.useMemo(() => { const items: FloatingAction[] = [ { key: 'create-invite', label: creatingInvite ? t('invites.actions.creating', 'Erstellen...') : t('invites.actions.create', 'Neue QR-Code erstellen'), icon: Plus, onClick: () => { void handleCreateInvite(); }, loading: creatingInvite, disabled: creatingInvite || state.event?.limits?.can_add_guests === false, tone: 'primary', }, { key: 'refresh', label: state.loading ? t('invites.actions.refreshing', 'Aktualisieren...') : t('invites.actions.refresh', 'Aktualisieren'), icon: RefreshCw, onClick: () => { void load(); }, loading: state.loading, disabled: state.loading, tone: 'secondary', }, ]; if (activeTab === 'layout' && selectedInvite && effectiveCustomization) { items.unshift({ key: 'save-layout', label: customizerSaving ? t('invites.customizer.actions.saving', 'Speichert...') : t('invites.customizer.actions.save', 'Layout speichern'), icon: Save, onClick: () => { void handleSaveCustomization(effectiveCustomization); }, loading: customizerSaving, disabled: customizerSaving || customizerResetting, tone: 'primary', }); } return items; }, [activeTab, selectedInvite, effectiveCustomization, customizerSaving, customizerResetting, creatingInvite, state.event?.limits?.can_add_guests, state.loading, t, handleSaveCustomization, load]); const limitScopeLabels = React.useMemo( () => ({ photos: tLimits('photosTitle'), guests: tLimits('guestsTitle'), gallery: tLimits('galleryTitle'), }), [tLimits] ); React.useEffect(() => { const success = searchParams.get('addon_success'); if (success && slug) { toast(t('events.success.addonApplied', 'Add-on angewendet. Limits aktualisieren sich in Kürze.')); void load(); searchParams.delete('addon_success'); navigate(window.location.pathname, { replace: true }); } }, [searchParams, slug, load, navigate, t]); return (
{limitWarnings.length > 0 && (
{limitWarnings.map((warning) => (
{limitScopeLabels[warning.scope]} {warning.message}
{warning.scope === 'guests' ? (
{ void handleAddonPurchase(key); }} busy={addonBusy === 'guests'} t={(key, fallback) => t(key, fallback)} />
) : null}
))}
)} {state.event?.addons?.length ? ( {t('events.sections.addons.title', 'Add-ons & Upgrades')} {t('events.sections.addons.description', 'Zusätzliche Kontingente für dieses Event.')} t(key, fallback)} /> ) : null} handleTabChange(tab)} /> {t('invites.tabs.share', 'Links & QR teilen')} {t('invites.tabs.layout', 'Layout anpassen')} {t('invites.tabs.export', 'Drucken & Export')} {state.error ? ( {t('invites.errorTitle', 'Aktion fehlgeschlagen')} {state.error} ) : null}

{t('invites.customizer.heading', 'QR-Codeslayout anpassen')}

{t('invites.customizer.copy', 'Bearbeite Texte, Farben und Positionen direkt neben der Live-Vorschau. Änderungen werden sofort sichtbar.')}

{state.loading ? ( ) : ( )}
{t('invites.export.title', 'Drucken & Export')} {t('invites.export.description', 'Überprüfe das Layout, starte Testdrucke und exportiere alle Formate.')}
{exportError ? ( {t('invites.export.errorTitle', 'Aktion fehlgeschlagen')} {exportError} ) : null} {selectedInvite ? ( exportPreview && exportLayout ? (

{exportPreview.layoutLabel}

{exportPreview.layoutSubtitle ? (

{exportPreview.layoutSubtitle}

) : null}
{exportPreview.mode === 'advanced' ? t('invites.export.mode.advanced', 'Freier Editor') : t('invites.export.mode.standard', 'Standardlayout')} {exportPreview.paperLabel} {exportPreview.orientationLabel}

{t('invites.export.previewHint', 'Speichere nach Änderungen, um neue Exportdateien zu erzeugen.')}

{exportElements.length ? (
) : (
{t('invites.export.noLayoutPreview', 'Für diese Kombination liegt noch keine Vorschau vor. Speichere das Layout zuerst.')}
)}
{t('invites.export.meta.title', 'Layout-Details')} {t('invites.export.meta.description', 'Wichtige Kennzahlen für den Druck.')}
{t('invites.export.meta.paper', 'Papierformat')} {exportPreview.paperLabel}
{t('invites.export.meta.orientation', 'Ausrichtung')} {exportPreview.orientationLabel}
{t('invites.export.meta.qrSize', 'QR-Code-Größe')} {exportPreview.qrSizeLabel}
{t('invites.export.meta.formats', 'Verfügbare Formate')}
{exportPreview.formatBadges.map((item) => ( {item} ))}
{exportPreview.lastUpdated ? (
{t('invites.export.meta.updated', 'Zuletzt aktualisiert')} {exportPreview.lastUpdated}
) : null}
{t('invites.export.workflow.title', 'Ablauf vor dem Event')} {t('invites.export.workflow.description', 'So stellst du sicher, dass Gäste den QR-Code finden.')}
    {exportPreview.workflowSteps.map((step, index) => (
  1. {index + 1} {step}
  2. ))}
{t('invites.export.actions.title', 'Aktionen')} {t('invites.export.actions.description', 'Starte deinen Testdruck oder lade die Layouts herunter.')}
{exportPreview.formats.map((format) => { const key = format.toLowerCase(); const busyKey = `${exportLayout.id}-${key}`; const isBusy = exportDownloadBusy === busyKey; return ( ); })}
{t('invites.export.qr.title', 'QR-Code & Link')} {t('invites.export.qr.description', 'Verteile den Link digital oder erstelle weitere Auszüge.')}
{selectedInvite.qr_code_data_url ? ( {t('invites.export.qr.alt', ) : (
{t('invites.export.qr.placeholder', 'QR-Code wird nach dem Speichern generiert.')}
)}
{selectedInvite.url}
{t('invites.export.tips.title', 'Tipps für perfekte Ausdrucke')}
    {exportPreview.tips.map((tip, index) => (
  • {tip}
  • ))}
) : (
{t('invites.export.noLayouts', 'Für diese QR-Code sind aktuell keine Layouts verfügbar.')}
) ) : (
{t('invites.export.noInviteSelected', 'Wähle zunächst eine QR-Code aus, um Downloads zu starten.')}
)}
{primaryInvite ? ( handleCopy(primaryInvite)} onCreate={handleCreateInvite} onOpenLayout={() => handleTabChange('layout')} onOpenExport={() => handleTabChange('export')} stats={inviteCountSummary} /> ) : null}
{t('invites.cardTitle', 'QR-QR-Code & Layouts')} {t('invites.cardDescription', 'Erzeuge QR-Code, passe Layouts an und stelle druckfertige Vorlagen bereit.')}
{t('invites.summary.active', 'Aktive QR-Code')}: {inviteCountSummary.active} {t('invites.summary.total', 'Gesamt')}: {inviteCountSummary.total}
{!state.loading && state.event?.limits?.can_add_guests === false && (

{tLimits('guestsBlocked')}

)}
{state.loading ? ( ) : state.invites.length === 0 ? ( ) : (
{state.invites.map((invite) => ( setSelectedInviteId(invite.id)} onCopy={() => handleCopy(invite)} onRevoke={() => handleRevoke(invite)} selected={invite.id === selectedInvite?.id} revoking={revokingId === invite.id} copied={copiedInviteId === invite.id} /> ))}
)}
); } type InviteWorkflowStep = { key: TabKey; title: string; description: string; status: 'done' | 'active' | 'pending'; }; function InviteWorkflowSteps({ steps, onSelectStep }: { steps: InviteWorkflowStep[]; onSelectStep: (tab: TabKey) => void }) { const { t } = useTranslation('management'); return (
{t('invites.workflow.title', 'QR-Codes-Workflow')} {t('invites.workflow.description', 'Durchlaufe die Schritte in Reihenfolge – Layout gestalten, Links teilen, Export starten.')}
{t('invites.workflow.badge', 'Setup')}
{steps.map((step) => { const isDone = step.status === 'done'; const isActive = step.status === 'active'; return ( ); })}
); } type InviteShareSummaryProps = { invite: EventQrInvite; onCopy: () => void; onCreate: () => void; onOpenLayout: () => void; onOpenExport: () => void; stats: { active: number; total: number }; }; function InviteShareSummaryCard({ invite, onCopy, onCreate, onOpenLayout, onOpenExport, stats }: InviteShareSummaryProps) { const { t } = useTranslation('management'); return (
{t('invites.share.title', 'Schnellzugriff auf Gästelink')} {t('invites.share.description', 'Nutze den Standardlink, um QR-Codes zu teilen oder weitere Karten zu erzeugen.')}
{t('invites.share.stats.active', { defaultValue: '{{count}} aktiv', count: stats.active })} {t('invites.share.stats.total', { defaultValue: '{{count}} gesamt', count: stats.total })}
{t('invites.share.primaryLabel', 'Hauptlink')}
{invite.url}
{invite.url ? ( ) : null}
); } function InviteCustomizerSkeleton(): React.ReactElement { return (
{Array.from({ length: 3 }).map((_, index) => (
))}
); } 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; 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(); 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 (
{ 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)]'}`} >
{invite.label?.trim() || `QR-Code #${invite.id}`} {status} {isAutoGenerated ? ( {t('invites.labels.standard', 'Standard')} ) : null} {customization ? ( {t('tasks.customizer.badge', 'Angepasst')} ) : null}
{invite.qr_code_data_url ? ( {t('invites.labels.qrAlt', ) : null}
{invite.url}
{t('invites.labels.usage', 'Nutzung')}: {usageLabel} {t('invites.labels.layout', 'Layout')}: {layoutName || t('invites.labels.layoutFallback', 'Standard')} {invite.expires_at ? {t('invites.labels.validUntil', 'Gültig bis')} {formatDateTime(invite.expires_at)} : null} {invite.created_at ? {t('invites.labels.createdAt', 'Erstellt am')} {formatDateTime(invite.created_at)} : null}
{selected ? ( {t('invites.labels.selected', 'Aktuell ausgewählt')} ) : (
{t('invites.labels.tapToEdit', 'Zum Anpassen auswählen')}
)}
); } function InviteSkeleton() { return (
{Array.from({ length: 3 }).map((_, index) => (
))}
); } function EmptyState({ onCreate }: { onCreate: () => void }) { const { t } = useTranslation('management'); return (

{t('invites.empty.title', 'Noch keine QR-Code')}

{t('invites.empty.copy', 'Erstelle eine QR-Code, um druckfertige QR-Layouts zu erhalten.')}

); } 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', }); }