import React from 'react'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { ArrowLeft, RefreshCcw, Plus, ChevronDown } from 'lucide-react'; import { YStack, XStack } from '@tamagui/stacks'; import { SizableText as Text } from '@tamagui/text'; import { Pressable } from '@tamagui/react-native-web-lite'; import { Accordion } from '@tamagui/accordion'; import { HexColorPicker } from 'react-colorful'; import { Portal } from '@tamagui/portal'; import { MobileShell, HeaderActionButton } from './components/MobileShell'; import { MobileCard, CTAButton, PillBadge } from './components/Primitives'; import { MobileInput, MobileSelect, MobileTextArea } from './components/FormControls'; import { TenantEvent, EventQrInvite, EventQrInviteLayout, TenantFont, getEvent, getEventQrInvites, updateEventQrInvite, } from '../api'; import { isAuthError } from '../auth/tokens'; import { getApiErrorMessage } from '../lib/apiError'; import { useTenantFonts, ensureFontLoaded } from '../lib/fonts'; import toast from 'react-hot-toast'; import { generatePdfBytes, generatePngDataUrl, triggerDownloadFromBlob, triggerDownloadFromDataUrl, } from './invite-layout/export-utils'; import { LayoutElement, CANVAS_WIDTH, CANVAS_HEIGHT } from './invite-layout/schema'; import { buildInitialTextFields } from './qr/utils'; import { adminPath } from '../constants'; import { useBackNavigation } from './hooks/useBackNavigation'; import { ADMIN_COLORS, ADMIN_GRADIENTS, useAdminTheme } from './theme'; type Step = 'background' | 'text' | 'preview'; const BACKGROUND_PRESETS = [ { id: 'bg-blue-floral', src: '/storage/layouts/backgrounds-portrait/bg-blue-floral.png', labelKey: 'events.qr.backgroundPresets.blueFloral', label: 'Blue Floral', }, { id: 'bg-artdeco', src: '/storage/layouts/backgrounds-portrait/bg-artdeco.png', labelKey: 'events.qr.backgroundPresets.artDeco', label: 'Art Deco', }, { id: 'bg-eukalyptus-floral', src: '/storage/layouts/backgrounds-portrait/bg-eukalyptus-floral.png', labelKey: 'events.qr.backgroundPresets.eukalyptusFloral', label: 'Eucalyptus Floral', }, { id: 'bg-eukalyptus-rahmen', src: '/storage/layouts/backgrounds-portrait/bg-eukalyptus-rahmen.png', labelKey: 'events.qr.backgroundPresets.eukalyptusFrame', label: 'Eucalyptus Frame', }, { id: 'bg-eukalyptus', src: '/storage/layouts/backgrounds-portrait/bg-eukalyptus.png', labelKey: 'events.qr.backgroundPresets.eukalyptus', label: 'Eucalyptus', }, { id: 'bg-goldframe', src: '/storage/layouts/backgrounds-portrait/bg-goldframe.png', labelKey: 'events.qr.backgroundPresets.goldFrame', label: 'Gold Frame', }, { id: 'bg-jugendstil', src: '/storage/layouts/backgrounds-portrait/bg-jugendstil.png', labelKey: 'events.qr.backgroundPresets.jugendstil', label: 'Jugendstil', }, { id: 'bg-kornblumen', src: '/storage/layouts/backgrounds-portrait/bg-kornblumen.png', labelKey: 'events.qr.backgroundPresets.kornblumen', label: 'Cornflowers', }, { id: 'bg-kornblumen2', src: '/storage/layouts/backgrounds-portrait/bg-kornblumen2.png', labelKey: 'events.qr.backgroundPresets.kornblumenTwo', label: 'Cornflowers II', }, { id: 'gr-green-floral', src: '/storage/layouts/backgrounds-portrait/gr-green-floral.png', labelKey: 'events.qr.backgroundPresets.greenFloral', label: 'Green Floral', }, ]; const DEFAULT_BODY_FONT = 'Manrope'; const DEFAULT_DISPLAY_FONT = 'Archivo Black'; export default function MobileQrLayoutCustomizePage() { const { slug: slugParam, tokenId: tokenParam } = useParams<{ slug?: string; tokenId?: string }>(); const [searchParams] = useSearchParams(); const slug = slugParam ?? null; const tokenId = tokenParam ? Number(tokenParam) : null; const layoutParam = searchParams.get('layout'); const navigate = useNavigate(); const { t } = useTranslation('management'); const { textStrong, danger, muted, border, primary, surface, surfaceMuted, overlay, shadow } = useAdminTheme(); const [event, setEvent] = React.useState(null); const [invite, setInvite] = React.useState(null); const [selectedLayoutId, setSelectedLayoutId] = React.useState(layoutParam); const [backgroundPreset, setBackgroundPreset] = React.useState(null); const [backgroundGradient, setBackgroundGradient] = React.useState<{ angle: number; stops: string[] } | null>(null); const [backgroundSolid, setBackgroundSolid] = React.useState(null); const [textFields, setTextFields] = React.useState({ headline: '', subtitle: '', description: '', instructions: [''], }); const [slotOverrides, setSlotOverrides] = React.useState>>({}); const [qrPngUrl, setQrPngUrl] = React.useState(null); const [step, setStep] = React.useState('background'); const [saving, setSaving] = React.useState(false); const [loading, setLoading] = React.useState(true); const [error, setError] = React.useState(null); const { fonts: tenantFonts } = useTenantFonts(); const back = useBackNavigation(slug ? adminPath(`/mobile/events/${slug}/qr`) : adminPath('/mobile/events')); const handleSelectPreset = React.useCallback((id: string | null) => { setBackgroundPreset(id); if (id) { setBackgroundGradient(null); setBackgroundSolid(null); } }, []); const handleSelectGradient = React.useCallback((gradient: { angle: number; stops: string[] } | null) => { setBackgroundGradient(gradient); if (gradient) { setBackgroundPreset(null); setBackgroundSolid(null); } }, []); const handleSelectSolid = React.useCallback((color: string | null) => { setBackgroundSolid(color); if (color) { setBackgroundPreset(null); setBackgroundGradient(null); } }, []); const handleUpdateSlot = React.useCallback((slotKey: string, patch: Partial) => { setSlotOverrides((prev) => ({ ...prev, [slotKey]: { ...(prev[slotKey] ?? {}), ...patch, }, })); }, []); React.useEffect(() => { if (!slug) return; (async () => { setLoading(true); try { const eventData = await getEvent(slug); const invites = await getEventQrInvites(slug); const matchedInvite = tokenId ? invites.find((item) => item.id === tokenId) : invites.find((i) => i.is_active) ?? invites[0] ?? null; const eventName = renderEventName(eventData?.name); setEvent(eventData); setInvite(matchedInvite ?? null); setQrPngUrl(matchedInvite?.qr_code_data_url ?? null); const customization = (matchedInvite?.metadata as any)?.layout_customization ?? {}; const initialLayoutId = layoutParam ?? customization.layout_id ?? matchedInvite?.layouts?.[0]?.id ?? null; const layoutDefaults = matchedInvite?.layouts?.find((l) => l.id === initialLayoutId) ?? matchedInvite?.layouts?.[0] ?? null; setBackgroundPreset(typeof customization.background_preset === 'string' ? customization.background_preset : null); setSelectedLayoutId(initialLayoutId); setBackgroundGradient(customization.background_gradient ?? null); setBackgroundSolid(customization.background_color ?? null); setTextFields( buildInitialTextFields({ customization, layoutDefaults, eventName, }) ); setSlotOverrides(normalizeSlotOverrides((customization as any)?.slots)); setError(null); } catch (err) { if (!isAuthError(err)) { setError(getApiErrorMessage(err, t('events.errors.loadFailed', 'QR-Daten konnten nicht geladen werden.'))); } } finally { setLoading(false); } })(); }, [slug, tokenId, layoutParam, t]); const selectedLayout = invite?.layouts.find((l) => l.id === selectedLayoutId) ?? invite?.layouts?.[0] ?? null; const isFoldable = (selectedLayout?.panel_mode ?? '').toLowerCase() === 'double-mirror'; const handleSave = async () => { if (!slug || !invite || !selectedLayout) { toast.error(t('events.qr.missing', 'Kein QR-Link vorhanden')); return; } setSaving(true); try { const payload = { metadata: { layout_customization: { layout_id: selectedLayout.id, background_preset: backgroundPreset, background_gradient: backgroundGradient ?? undefined, background_color: backgroundSolid ?? undefined, headline: textFields.headline || null, subtitle: textFields.subtitle || null, description: textFields.description || null, instructions: textFields.instructions.filter((item) => item.trim().length > 0), slots: slotOverrides, }, }, }; const updated = await updateEventQrInvite(slug, invite.id, payload); setInvite(updated); toast.success(t('common.saved', 'Gespeichert')); } catch (err) { toast.error(getApiErrorMessage(err, t('events.errors.saveFailed', 'Speichern fehlgeschlagen.'))); } finally { setSaving(false); } }; return ( window.location.reload()} ariaLabel={t('common.refresh', 'Refresh')}> } > {error ? ( {error} ) : null} {step === 'background' && ( { handleSave(); setStep('text'); }} saving={saving || loading} /> )} {step === 'text' && ( setStep('background')} textFields={textFields} onChange={(fields) => setTextFields(fields)} onSave={() => { handleSave(); setStep('preview'); }} saving={saving || loading} /> )} {step === 'preview' && ( setStep('text')} layout={selectedLayout} backgroundPreset={backgroundPreset} backgroundGradient={backgroundGradient} backgroundSolid={backgroundSolid} presets={BACKGROUND_PRESETS} textFields={textFields} qrUrl={qrPngUrl ?? invite?.url ?? ''} qrPngUrl={qrPngUrl} isFoldable={isFoldable} slotOverrides={slotOverrides} onUpdateSlot={handleUpdateSlot} tenantFonts={tenantFonts} /> )} ); } function Stepper({ current, onStepChange }: { current: Step; onStepChange: (step: Step) => void }) { const { t } = useTranslation('management'); const { textStrong, muted, border, primary, accentSoft, surface, successText } = useAdminTheme(); const steps: { key: Step; label: string }[] = [ { key: 'background', label: t('events.qr.background', 'Hintergrund') }, { key: 'text', label: t('events.qr.text', 'Text') }, { key: 'preview', label: t('events.qr.preview', 'Vorschau') }, ]; const currentIndex = steps.findIndex((s) => s.key === current); const progress = ((currentIndex + 1) / steps.length) * 100; return ( {steps.map((step, idx) => { const active = step.key === current; const completed = idx < currentIndex; return ( onStepChange(step.key)} style={{ flex: 1 }}> {step.label} {t('events.qr.step', 'Schritt')} {idx + 1} ); })} ); } type SlotDefinition = { x: number; y: number; w: number; h?: number; fontSize?: number; fontWeight?: string | number; fontFamily?: string; lineHeight?: number; align?: 'left' | 'center' | 'right'; color?: string; }; type SlotOverrides = Record>; type PaperSize = 'a4' | 'a5'; const PAPER_MM: Record = { a4: { width: 210, height: 297 }, a5: { width: 148, height: 210 }, }; function toNumber(value: unknown): number | undefined { if (typeof value === 'number' && Number.isFinite(value)) { return value; } const parsed = typeof value === 'string' ? Number(value) : NaN; return Number.isFinite(parsed) ? parsed : undefined; } function clampValue(value: number, min: number, max: number): number { if (!Number.isFinite(value)) return min; return Math.min(Math.max(value, min), max); } function normalizeSlotOverrides(raw: unknown): SlotOverrides { if (!raw || typeof raw !== 'object') { return {}; } return Object.entries(raw as Record).reduce((acc, [key, value]) => { if (!value || typeof value !== 'object') return acc; const slot = value as Record; const align = typeof slot.align === 'string' ? (slot.align as SlotDefinition['align']) : undefined; acc[key] = { x: toNumber(slot.x), y: toNumber(slot.y), w: toNumber(slot.w), h: toNumber(slot.h), fontSize: toNumber(slot.fontSize), lineHeight: toNumber(slot.lineHeight), fontFamily: typeof slot.fontFamily === 'string' ? slot.fontFamily : undefined, color: typeof slot.color === 'string' ? slot.color : undefined, align, }; return acc; }, {}); } function resolvePaper(layout: EventQrInviteLayout | null): PaperSize { const paper = (layout?.paper ?? 'a4').toString().toLowerCase(); return paper === 'a5' ? 'a5' : 'a4'; } function resolveCanvasBase(layout: EventQrInviteLayout | null, isFoldable: boolean): { width: number; height: number } { const svgWidth = (layout as any)?.preview?.svg?.width; const svgHeight = (layout as any)?.preview?.svg?.height; if (svgWidth && svgHeight) { return { width: svgWidth, height: svgHeight }; } const paper = resolvePaper(layout); const orientation = ((layout?.orientation ?? (isFoldable ? 'landscape' : 'portrait')) || '').toString().toLowerCase(); const mm = PAPER_MM[paper]; // Keep the existing pixel density consistent with the A4 canvas width. const pxPerMm = CANVAS_WIDTH / PAPER_MM.a4.width; let width = Math.round(mm.width * pxPerMm); let height = Math.round(mm.height * pxPerMm); if (orientation === 'landscape') { [width, height] = [height, width]; } return { width, height }; } function renderEventName(name: TenantEvent['name'] | null | undefined): string | null { if (!name) return null; if (typeof name === 'string' && name.trim()) return name.trim(); if (typeof name === 'object') { const values = Object.values(name).filter((val) => typeof val === 'string' && val.trim()) as string[]; if (values.length) { return values[0].trim(); } } return null; } function getDefaultSlots(): Record { return { headline: { x: 0.08, y: 0.12, w: 0.84, fontSize: 90, fontWeight: 800, fontFamily: DEFAULT_DISPLAY_FONT, align: 'center' as const }, subtitle: { x: 0.1, y: 0.21, w: 0.8, fontSize: 37, fontWeight: 600, fontFamily: DEFAULT_BODY_FONT, align: 'center' as const }, description: { x: 0.1, y: 0.66, w: 0.8, fontSize: 26, fontFamily: DEFAULT_BODY_FONT, lineHeight: 1.4, align: 'center' as const }, instructions: { x: 0.1, y: 0.7, w: 0.8, fontSize: 24, fontFamily: DEFAULT_BODY_FONT, lineHeight: 1.5 }, qr: { x: 0.39, y: 0.37, w: 0.27 }, }; } function resolveSlots(layout: EventQrInviteLayout | null, isFoldable: boolean, overrides: SlotOverrides = {}): Record { const fromLayout = (layout as any)?.slots; if (fromLayout && typeof fromLayout === 'object') { const base = Object.entries(fromLayout as Record).reduce>((acc, [key, value]) => { if (value && typeof value === 'object') { const slot = value as Record; acc[key] = { x: Number(slot.x ?? 0), y: Number(slot.y ?? 0), w: Number(slot.w ?? 0.8), h: typeof slot.h === 'number' ? slot.h : undefined, fontSize: typeof slot.fontSize === 'number' ? slot.fontSize : undefined, fontWeight: typeof slot.fontWeight === 'string' || typeof slot.fontWeight === 'number' ? slot.fontWeight : undefined, fontFamily: typeof slot.fontFamily === 'string' ? slot.fontFamily : undefined, lineHeight: typeof slot.lineHeight === 'number' ? slot.lineHeight : undefined, align: typeof slot.align === 'string' ? (slot.align as SlotDefinition['align']) : undefined, }; } return acc; }, {}); return applySlotOverrides(base, overrides); } const baseSlots = isFoldable ? { headline: { x: 0.08, y: 0.24, w: 0.84, fontSize: 50, fontWeight: 800, fontFamily: DEFAULT_DISPLAY_FONT, align: 'center' as const }, subtitle: { x: 0.1, y: 0.28, w: 0.8, fontSize: 37, fontWeight: 600, fontFamily: DEFAULT_BODY_FONT, align: 'center' as const }, description: { x: 0.42, y: 0.6, w: 0.58, fontSize: 23, fontFamily: DEFAULT_BODY_FONT, lineHeight: 1.4, align: 'left' as const }, instructions: { x: 0.42, y: 0.42, w: 0.58, fontSize: 15, fontFamily: DEFAULT_BODY_FONT, lineHeight: 1.3, align: 'left' as const }, qr: { x: 0, y: 0.3, w: 0.26 }, } : getDefaultSlots(); return applySlotOverrides(baseSlots, overrides); } function applySlotOverrides(baseSlots: Record, overrides: SlotOverrides): Record { const keys = new Set([...Object.keys(baseSlots), ...Object.keys(overrides)]); return Array.from(keys).reduce>((acc, key) => { const base = baseSlots[key] ?? baseSlots.headline ?? { x: 0, y: 0, w: 1 }; const override = overrides[key] ?? {}; acc[key] = { ...base, x: override.x ?? base.x, y: override.y ?? base.y, w: override.w ?? base.w, h: override.h ?? base.h, fontSize: override.fontSize ?? base.fontSize, fontWeight: override.fontWeight ?? base.fontWeight, fontFamily: override.fontFamily ?? base.fontFamily, lineHeight: override.lineHeight ?? base.lineHeight, align: override.align ?? base.align, color: override.color ?? base.color, }; return acc; }, {}); } function buildFabricOptions({ layout, isFoldable, backgroundGradient, backgroundSolid, backgroundImageUrl, textFields, qrUrl, qrPngUrl, baseWidth = CANVAS_WIDTH, baseHeight = CANVAS_HEIGHT, renderWidth = CANVAS_WIDTH, renderHeight = CANVAS_HEIGHT, slotOverrides = {}, }: { layout: EventQrInviteLayout | null; isFoldable: boolean; backgroundGradient: { angle: number; stops: string[] } | null; backgroundSolid: string | null; backgroundImageUrl: string | null; textFields: { headline: string; subtitle: string; description: string; instructions: string[] }; qrUrl: string; qrPngUrl?: string | null; baseWidth?: number; baseHeight?: number; renderWidth?: number; renderHeight?: number; slotOverrides?: SlotOverrides; }) { const scaleX = renderWidth / baseWidth; const scaleY = renderHeight / baseHeight; const panelWidth = isFoldable ? baseWidth / 2 : baseWidth; const panelHeight = baseHeight; const slots = resolveSlots(layout, isFoldable, slotOverrides); const backgroundPanels = isFoldable && backgroundImageUrl ? [ { url: backgroundImageUrl, centerX: (renderWidth / 2) * 0.5, centerY: renderHeight / 2, width: renderWidth / 2, height: renderHeight, rotation: 0, mirrored: false, }, { url: backgroundImageUrl, centerX: (renderWidth / 2) * 1.5, centerY: renderHeight / 2, width: renderWidth / 2, height: renderHeight, rotation: 0, mirrored: true, }, ] : null; const elements: LayoutElement[] = []; const textColor = layout?.preview?.text ?? ADMIN_COLORS.backdrop; const accentColor = layout?.preview?.accent ?? ADMIN_COLORS.primary; const secondaryColor = (layout?.preview as any)?.secondary ?? ADMIN_COLORS.text; const badgeColor = (layout?.preview as any)?.badge ?? accentColor; const rotatePoint = (cx: number, cy: number, x: number, y: number, angleDeg: number) => { const rad = (angleDeg * Math.PI) / 180; const cos = Math.cos(rad); const sin = Math.sin(rad); const dx = x - cx; const dy = y - cy; return { x: cx + dx * cos - dy * sin, y: cy + dx * sin + dy * cos, }; }; const addTextElement = ( type: LayoutElement['type'], content: string, slot: SlotDefinition, opts: { panelOffset: number; mirrored: boolean } ) => { if (!content) return; const localWidth = slot.w * panelWidth; const localHeight = (slot.h ?? 0.12) * panelHeight; const widthPx = localWidth * scaleX; const heightPx = localHeight * scaleY; // Default: top-left positioning for non-rotated panels let targetX = opts.panelOffset + slot.x * panelWidth; let targetY = slot.y * panelHeight; let rotation = 0; // Foldable layouts rotate each panel around its own center; Fabric expects center-based coords when rotated. if (isFoldable) { const baseCenterX = opts.panelOffset + slot.x * panelWidth + localWidth / 2; const baseCenterY = slot.y * panelHeight + localHeight / 2; const panelCenterX = opts.panelOffset + panelWidth / 2; const panelCenterY = panelHeight / 2; rotation = opts.panelOffset === 0 ? 90 : -90; const rotated = rotatePoint(panelCenterX, panelCenterY, baseCenterX, baseCenterY, rotation); targetX = rotated.x; targetY = rotated.y; } elements.push({ id: `${type}-${opts.panelOffset}-${opts.mirrored ? 'm' : 'n'}`, type, x: targetX * scaleX, y: targetY * scaleY, width: widthPx, height: heightPx, fontSize: slot.fontSize ?? (type === 'headline' ? 30 : type === 'subtitle' ? 18 : 16), align: slot.align ?? 'center', lineHeight: slot.lineHeight ?? 1.35, content, fill: slot.color ?? textColor, fontFamily: slot.fontFamily, rotation, }); }; const addQr = (slot: SlotDefinition, opts: { panelOffset: number; mirrored: boolean }) => { if (!qrUrl) return; const localSize = slot.w * panelWidth; const sizePx = localSize * scaleX; let targetX = opts.panelOffset + slot.x * panelWidth; let targetY = slot.y * panelHeight; let rotation = 0; if (isFoldable) { const baseCenterX = opts.panelOffset + slot.x * panelWidth + localSize / 2; const baseCenterY = slot.y * panelHeight + localSize / 2; const panelCenterX = opts.panelOffset + panelWidth / 2; const panelCenterY = panelHeight / 2; rotation = opts.panelOffset === 0 ? 90 : -90; const rotated = rotatePoint(panelCenterX, panelCenterY, baseCenterX, baseCenterY, rotation); targetX = rotated.x; targetY = rotated.y; } elements.push({ id: `qr-${opts.panelOffset}-${opts.mirrored ? 'm' : 'n'}`, type: 'qr', x: targetX * scaleX, y: targetY * scaleY, width: sizePx, height: sizePx, rotation, }); }; const instructions = textFields.instructions.filter((i) => i.trim().length > 0); const instructionContent = instructions.map((line, idx) => `${idx + 1}. ${line}`).join('\n'); const drawPanelElements = (panelOffset: number, mirrored: boolean) => { addTextElement('headline', textFields.headline, slots.headline, { panelOffset, mirrored }); addTextElement('subtitle', textFields.subtitle, slots.subtitle, { panelOffset, mirrored }); addTextElement('description', textFields.description, slots.description, { panelOffset, mirrored }); if (instructionContent) { addTextElement('description', instructionContent, slots.instructions, { panelOffset, mirrored }); } addQr(slots.qr, { panelOffset, mirrored }); }; drawPanelElements(0, false); if (isFoldable) { drawPanelElements(panelWidth, true); } return { elements, accentColor, textColor, secondaryColor, badgeColor, qrCodeDataUrl: qrPngUrl ?? (qrUrl ? `https://api.qrserver.com/v1/create-qr-code/?size=400x400&data=${encodeURIComponent(qrUrl)}` : null), logoDataUrl: null, backgroundColor: backgroundSolid ?? layout?.preview?.background ?? ADMIN_COLORS.surface, backgroundGradient, backgroundImageUrl: backgroundPanels ? null : backgroundImageUrl, backgroundImagePanels: backgroundPanels ?? undefined, canvasWidth: renderWidth, canvasHeight: renderHeight, readOnly: true, } as const; } function BackgroundStep({ onBack, presets, selectedPreset, onSelectPreset, selectedLayout, layouts, onSelectGradient, onSelectSolid, selectedGradient, selectedSolid, onSave, saving, }: { onBack: () => void; presets: { id: string; src: string; labelKey: string; label: string }[]; selectedPreset: string | null; onSelectPreset: (id: string) => void; selectedLayout: EventQrInviteLayout | null; layouts: EventQrInviteLayout[]; onSelectGradient: (gradient: { angle: number; stops: string[] } | null) => void; onSelectSolid: (color: string | null) => void; selectedGradient: { angle: number; stops: string[] } | null; selectedSolid: string | null; onSave: () => void; saving: boolean; }) { const { t } = useTranslation('management'); const { textStrong, muted, border, primary, accentSoft, surface, surfaceMuted } = useAdminTheme(); const resolvedLayout = selectedLayout ?? layouts.find((layout) => { const panel = (layout.panel_mode ?? '').toLowerCase(); const orientation = (layout.orientation ?? '').toLowerCase(); return panel === 'double-mirror' || orientation === 'landscape'; }) ?? layouts[0] ?? null; const isFoldable = (resolvedLayout?.panel_mode ?? '').toLowerCase() === 'double-mirror'; const formatPaper = resolvePaper(resolvedLayout).toUpperCase(); const isLandscape = ((resolvedLayout?.orientation ?? (isFoldable ? 'landscape' : 'portrait')) || '').toLowerCase() === 'landscape'; const orientationLabel = isLandscape ? t('events.qr.orientation.landscape', 'Landscape') : t('events.qr.orientation.portrait', 'Portrait'); const formatLabel = isFoldable ? t('events.qr.formatLabel.foldable', '{{paper}} {{orientation}} (double A5/mirrored)', { paper: formatPaper, orientation: orientationLabel, }) : t('events.qr.formatLabel.standard', '{{paper}} {{orientation}}', { paper: formatPaper, orientation: orientationLabel, }); const disablePresets = false; const gradientPresets = [ { angle: 180, stops: ['#F8FAFC', '#EEF2FF', '#F8FAFC'], labelKey: 'events.qr.gradientPresets.softLilac', label: 'Soft Lilac', }, { angle: 135, stops: ['#FEE2E2', '#EFF6FF', '#ECFDF3'], labelKey: 'events.qr.gradientPresets.pastel', label: 'Pastel', }, { angle: 210, stops: ['#0B132B', '#1C2541', '#274690'], labelKey: 'events.qr.gradientPresets.midnight', label: 'Midnight', }, ]; const solidPresets = [ ADMIN_COLORS.surface, ADMIN_COLORS.surfaceMuted, ADMIN_COLORS.backdrop, ADMIN_COLORS.primary, ADMIN_COLORS.warning, ]; return ( {t('common.back', 'Zurück')} {formatLabel} {disablePresets ? t('events.qr.backgroundPickerFoldable', 'Hintergrund für A5 (Gradient/Farbe)') : t('events.qr.backgroundPicker', 'Hintergrund auswählen ({{formatLabel}})', { formatLabel })} {!disablePresets ? ( <> {presets.map((preset) => { const isSelected = selectedPreset === preset.id; return ( onSelectPreset(preset.id)} style={{ width: '32%' }}> {t(preset.labelKey, preset.label)} {isSelected ? {t('common.selected', 'Ausgewählt')} : null} ); })} {t('events.qr.backgroundNote', 'Diese Presets sind für A4 Hochformat. Spiegelung erfolgt automatisch bei Tischkarten.')} ) : null} {t('events.qr.gradients', 'Gradienten')} {gradientPresets.map((gradient) => { const isSelected = selectedGradient && selectedGradient.angle === gradient.angle && JSON.stringify(selectedGradient.stops) === JSON.stringify(gradient.stops); const style = { backgroundImage: `linear-gradient(${gradient.angle}deg, ${gradient.stops.join(',')})`, } as React.CSSProperties; const key = `${gradient.labelKey}-${gradient.angle}`; return ( onSelectGradient(gradient)} style={{ width: '30%' }} aria-label={t(gradient.labelKey, gradient.label)} > ); })} {t('events.qr.colors', 'Vollfarbe')} {solidPresets.map((color, idx) => { const isSelected = selectedSolid === color; const key = `${color}-${idx}`; return ( onSelectSolid(color)} style={{ width: '20%' }}> ); })} ); } function TextStep({ onBack, textFields, onChange, onSave, onBulkAdd, saving, }: { onBack: () => void; textFields: { headline: string; subtitle: string; description: string; instructions: string[] }; onChange: (fields: { headline: string; subtitle: string; description: string; instructions: string[] }) => void; onSave: () => void; onBulkAdd?: () => void; saving: boolean; }) { const { t } = useTranslation('management'); const { textStrong, border, surface, muted } = useAdminTheme(); const updateField = (key: 'headline' | 'subtitle' | 'description', value: string) => { onChange({ ...textFields, [key]: value }); }; const updateInstruction = (idx: number, value: string) => { const next = [...textFields.instructions]; next[idx] = value; onChange({ ...textFields, instructions: next }); }; const addInstruction = () => { onChange({ ...textFields, instructions: [...textFields.instructions, ''] }); }; const removeInstruction = (idx: number) => { const next = textFields.instructions.filter((_, i) => i !== idx); onChange({ ...textFields, instructions: next.length ? next : [''] }); }; return ( {t('common.back', 'Zurück')} {t('events.qr.textStep', 'Texte & Hinweise')} {t('events.qr.textFields', 'Texte')} updateField('headline', event.target.value)} /> updateField('subtitle', event.target.value)} /> updateField('description', event.target.value)} /> {t('events.qr.instructions', 'Anleitung')} {textFields.instructions.map((item, idx) => ( updateInstruction(idx, event.target.value)} placeholder={t('events.qr.instructionsPlaceholder', 'Schritt hinzufügen')} style={{ flex: 1 }} compact /> removeInstruction(idx)} disabled={textFields.instructions.length === 1}> {idx === textFields.instructions.length - 1 ? ( ) : null} ))} ); } function PreviewStep({ onBack, layout, backgroundPreset, backgroundGradient, backgroundSolid, presets, textFields, qrUrl, qrPngUrl, isFoldable, slotOverrides, onUpdateSlot, tenantFonts, }: { onBack: () => void; layout: EventQrInviteLayout | null; backgroundPreset: string | null; backgroundGradient: { angle: number; stops: string[] } | null; backgroundSolid: string | null; presets: { id: string; src: string; labelKey: string; label: string }[]; textFields: { headline: string; subtitle: string; description: string; instructions: string[] }; qrUrl: string; qrPngUrl?: string | null; isFoldable: boolean; slotOverrides: SlotOverrides; onUpdateSlot: (slot: string, patch: Partial) => void; tenantFonts: TenantFont[]; }) { const { t } = useTranslation('management'); const { textStrong, muted, border, surface } = useAdminTheme(); const presetSrc = backgroundPreset ? presets.find((p) => p.id === backgroundPreset)?.src ?? null : null; const resolvedBgGradient = backgroundGradient ?? layout?.preview?.background_gradient ?? (layout?.preview?.background ? { angle: 180, stops: [layout.preview.background, layout.preview.background, layout.preview.background] } : null); const resolvedBgSolid = backgroundSolid ?? layout?.preview?.background ?? null; const backgroundImageUrl = presetSrc ?? null; const canvasBase = React.useMemo(() => resolveCanvasBase(layout, isFoldable), [layout, isFoldable]); const renderCanvas = React.useMemo( () => ({ width: isFoldable ? CANVAS_HEIGHT : CANVAS_WIDTH, height: isFoldable ? CANVAS_WIDTH : CANVAS_HEIGHT, }), [isFoldable], ); const resolvedSlots = React.useMemo(() => resolveSlots(layout, isFoldable, slotOverrides), [isFoldable, layout, slotOverrides]); const exportOptions = React.useMemo(() => { return buildFabricOptions({ layout, isFoldable, backgroundGradient: resolvedBgGradient, backgroundSolid: resolvedBgSolid, backgroundImageUrl, textFields, qrUrl, qrPngUrl, baseWidth: canvasBase.width, baseHeight: canvasBase.height, renderWidth: renderCanvas.width, renderHeight: renderCanvas.height, slotOverrides, }); }, [ backgroundImageUrl, canvasBase.height, canvasBase.width, isFoldable, layout, qrPngUrl, qrUrl, renderCanvas.height, renderCanvas.width, resolvedBgGradient, resolvedBgSolid, slotOverrides, textFields, ]); const [previewUrl, setPreviewUrl] = React.useState(null); const [previewLoading, setPreviewLoading] = React.useState(false); const loadFonts = React.useCallback(async () => { const families = Array.from( new Set( Object.values(resolvedSlots) .map((slot) => slot.fontFamily) .filter(Boolean) as string[] ) ); for (const family of families) { const font = tenantFonts.find((item) => item.family === family); if (font) { await ensureFontLoaded(font); } else if (typeof document !== 'undefined' && document.fonts?.load) { await document.fonts.load(`400 1rem "${family}"`); } } }, [resolvedSlots, tenantFonts]); React.useEffect(() => { let cancelled = false; async function renderPreview() { setPreviewLoading(true); try { await loadFonts(); const url = await generatePngDataUrl(exportOptions, 1); if (!cancelled) { setPreviewUrl(url); } } catch (error) { console.error('[QR Preview] failed to render', error); if (!cancelled) { setPreviewUrl(null); } } finally { if (!cancelled) { setPreviewLoading(false); } } } renderPreview(); return () => { cancelled = true; }; }, [exportOptions, loadFonts]); const aspectRatio = `${canvasBase.width}/${canvasBase.height}`; const paper = resolvePaper(layout); const orientation = ((layout?.orientation ?? (isFoldable ? 'landscape' : 'portrait')) || '').toString().toLowerCase(); const isLandscape = orientation === 'landscape'; const orientationLabel = isLandscape ? t('events.qr.orientation.landscape', 'Landscape') : t('events.qr.orientation.portrait', 'Portrait'); const qrImageSrc = qrPngUrl ?? qrUrl ?? ''; return ( {t('common.back', 'Zurück')} {paper.toUpperCase()} {orientationLabel} {t('events.qr.preview', 'Vorschau')} {previewLoading ? ( {t('common.loading', 'Lädt Vorschau …')} ) : previewUrl ? ( {t('events.qr.previewAlt', ) : ( {t('events.qr.missing', 'Kein QR-Link vorhanden')} )} { try { await loadFonts(); const pdfBytes = await generatePdfBytes(exportOptions, paper, orientation); const blob = new Blob([pdfBytes as any], { type: 'application/pdf' }); triggerDownloadFromBlob(blob, 'qr-layout.pdf'); } catch (err) { toast.error(t('events.qr.exportFailed', 'Export fehlgeschlagen')); console.error(err); } }} style={{ flex: 1, minWidth: 0 } as any} /> { try { await loadFonts(); const dataUrl = await generatePngDataUrl(exportOptions); await triggerDownloadFromDataUrl(dataUrl, 'qr-layout.png'); } catch (err) { toast.error(t('events.qr.exportFailed', 'Export fehlgeschlagen')); console.error(err); } }} style={{ flex: 1, minWidth: 0 } as any} /> ); } function LayoutControls({ slots, slotOverrides, onUpdateSlot, tenantFonts, qrUrl, }: { slots: Record; slotOverrides: SlotOverrides; onUpdateSlot: (slot: string, patch: Partial) => void; tenantFonts: TenantFont[]; qrUrl: string | null; }) { const { t } = useTranslation('management'); const [openColorSlot, setOpenColorSlot] = React.useState(null); const { border, surface, surfaceMuted, textStrong, muted, overlay, shadow, danger } = useAdminTheme(); const fontOptions = React.useMemo(() => { const preset = ['Archivo Black', 'Manrope', 'Inter', 'Roboto', 'Lora']; const tenant = tenantFonts.map((font) => font.family); return Array.from(new Set([...tenant, ...preset])); }, [tenantFonts]); const StepperInput = ({ value, min, max, step, onChange, }: { value: number; min: number; max: number; step: number; onChange: (value: number) => void; }) => { const dec = () => onChange(clampValue(value - step, min, max)); const inc = () => onChange(clampValue(value + step, min, max)); const decimals = step % 1 === 0 ? 0 : Math.min(4, `${step}`.split('.')[1]?.length ?? 1); const formatValue = (val: number) => val.toFixed(decimals); return ( { const next = Number(event.target.value); if (Number.isFinite(next)) { onChange(next); } }} compact style={{ width: 90, textAlign: 'center', fontFamily: DEFAULT_BODY_FONT, backgroundColor: surfaceMuted, }} /> + ); }; const renderTextSlot = (slotKey: string, label: string) => { const slot = slots[slotKey]; if (!slot) return null; const override = slotOverrides[slotKey] ?? {}; const onPercentChange = (field: 'x' | 'y' | 'w') => (value: number) => { const pct = clampValue(value, 0, 100); onUpdateSlot(slotKey, { [field]: pct / 100 }); }; const onFontSizeChange = (value: number) => { const next = clampValue(value, 8, 200); onUpdateSlot(slotKey, { fontSize: next }); }; const onLineHeightChange = (value: number) => { const next = clampValue(value, 0.8, 3); onUpdateSlot(slotKey, { lineHeight: next }); }; const onFontFamilyChange = (value: string) => { onUpdateSlot(slotKey, { fontFamily: value || undefined }); }; const currentX = override.x ?? slot.x; const currentY = override.y ?? slot.y; const currentW = override.w ?? slot.w; const centerX = () => { const centered = clampValue((1 - currentW) / 2, 0, 1); onUpdateSlot(slotKey, { x: centered }); }; return ( {label} {t('events.qr.positionX', 'X (%)')} onPercentChange('x')(val)} /> {t('common.center', 'Zentrieren')} {t('events.qr.positionY', 'Y (%)')} onPercentChange('y')(val)} /> {t('events.qr.width', 'Breite (%)')} onPercentChange('w')(val)} /> {t('events.qr.fontSize', 'Font Size (px)')} onFontSizeChange(val)} /> {t('events.qr.fontFamily', 'Font Family')} onFontFamilyChange(event.target.value)} > {fontOptions.map((family) => ( ))} {t('events.qr.fontColor', 'Schriftfarbe')} setOpenColorSlot(openColorSlot === slotKey ? null : slotKey)}> onUpdateSlot(slotKey, { color: event.target.value })} compact style={{ width: 110, backgroundColor: surfaceMuted }} /> {openColorSlot === slotKey ? ( setOpenColorSlot(null)} > {t('events.qr.fontColor', 'Schriftfarbe')} onUpdateSlot(slotKey, { color: val })} style={{ width: 240, height: 200 }} /> setOpenColorSlot(null)}> {t('common.close', 'Schließen')} ) : null} {t('events.qr.align', 'Align')} onUpdateSlot(slotKey, { align: event.target.value as SlotDefinition['align'] })} > {t('events.qr.lineHeight', 'Line Height')} onLineHeightChange(val)} /> ); }; const qrSlot = slots.qr; const qrOverride = slotOverrides.qr ?? {}; const onQrPercentChange = (field: 'x' | 'y' | 'w') => (value: number) => { const pct = clampValue(value, 0, 100); onUpdateSlot('qr', { [field]: pct / 100 }); }; const accordionDefaults = ['headline']; return ( {t('events.qr.layoutControls', 'Layout & Schrift')} {renderTextSlot('headline', t('events.qr.headline', 'Headline'))} {renderTextSlot('subtitle', t('events.qr.subtitle', 'Subtitle'))} {renderTextSlot('description', t('events.qr.bottomNote', 'Unterer Hinweistext'))} {renderTextSlot('instructions', t('events.qr.instructions', 'Anleitung'))} {qrSlot ? ( {t('events.qr.qr_code_label', 'QR‑Code')} {t('events.qr.positionX', 'X (%)')} onQrPercentChange('x')(val)} /> {t('events.qr.positionY', 'Y (%)')} onQrPercentChange('y')(val)} /> {t('events.qr.size', 'Größe (%)')} onQrPercentChange('w')(val)} /> {!qrUrl ? ( {t('events.qr.missing', 'Kein QR-Link vorhanden')} ) : null} ) : null} ); }