import React from 'react'; import { useTranslation } from 'react-i18next'; import { AlignCenter, AlignLeft, AlignRight, BadgeCheck, ChevronDown, Download, Heading, Link as LinkIcon, Loader2, Megaphone, Minus, Plus, Printer, QrCode, RotateCcw, Save, Trash2, Type, Undo2, Redo2, UploadCloud, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Textarea } from '@/components/ui/textarea'; import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { cn } from '@/lib/utils'; import { ensureFontLoaded, useTenantFonts } from '../../lib/fonts'; import { preloadedBackgrounds, type BackgroundImageOption } from './invite-layout/backgrounds'; const DEFAULT_FONT_VALUE = '__default'; import type { EventQrInvite, EventQrInviteLayout } from '../../api'; import { authorizedFetch } from '../../auth/tokens'; import { CANVAS_HEIGHT, CANVAS_WIDTH, QrLayoutCustomization, LayoutElement, LayoutElementPayload, LayoutElementType, LayoutSerializationContext, buildDefaultElements, clamp, clampElement, normalizeElements, payloadToElements, } from './invite-layout/schema'; import { DesignerCanvas } from './invite-layout/DesignerCanvas'; import { generatePdfBytes, generatePngDataUrl, openPdfInNewTab, triggerDownloadFromBlob, triggerDownloadFromDataUrl, } from './invite-layout/export-utils'; import { buildDownloadFilename, normalizeEventDateSegment } from './invite-layout/fileNames'; export type { QrLayoutCustomization } from './invite-layout/schema'; const ELEMENT_BINDING_FIELD: Partial> = { headline: 'headline', subtitle: 'subtitle', description: 'description', badge: 'badge_label', link: 'link_label', cta: 'cta_label', }; function sanitizeColor(value: string | null | undefined): string | null { if (!value) { return null; } const trimmed = value.trim(); const hexMatch = trimmed.match(/^#([A-Fa-f0-9]{3}|[A-Fa-f0-9]{6})$/); if (hexMatch) { if (trimmed.length === 4) { const r = trimmed[1]; const g = trimmed[2]; const b = trimmed[3]; return `#${r}${r}${g}${g}${b}${b}`.toUpperCase(); } return trimmed.toUpperCase(); } return null; } function sanitizeGradientStops(stops: unknown): string[] | null { if (!Array.isArray(stops)) { return null; } const normalized = stops .map((stop) => (typeof stop === 'string' ? sanitizeColor(stop) : null)) .filter((stop): stop is string => !!stop); return normalized.length ? normalized : null; } function sanitizePayload(payload: QrLayoutCustomization): QrLayoutCustomization { const normalized: QrLayoutCustomization = { ...payload }; normalized.accent_color = sanitizeColor(payload.accent_color ?? null) ?? undefined; normalized.text_color = sanitizeColor(payload.text_color ?? null) ?? undefined; normalized.background_color = sanitizeColor(payload.background_color ?? null) ?? undefined; normalized.secondary_color = sanitizeColor(payload.secondary_color ?? null) ?? undefined; normalized.badge_color = sanitizeColor(payload.badge_color ?? null) ?? undefined; if (typeof payload.background_image === 'string') { const trimmed = payload.background_image.trim(); normalized.background_image = trimmed.length ? trimmed : undefined; } else { normalized.background_image = undefined; } if (payload.background_gradient && typeof payload.background_gradient === 'object') { const { angle, stops } = payload.background_gradient as { angle?: number; stops?: unknown }; const normalizedStops = sanitizeGradientStops(stops); normalized.background_gradient = normalizedStops ? { angle: typeof angle === 'number' ? angle : 180, stops: normalizedStops } : null; } if (Array.isArray(payload.instructions)) { normalized.instructions = payload.instructions .map((entry) => (typeof entry === 'string' ? entry.trim() : '')) .filter((entry) => entry.length > 0); } if (typeof payload.link_label === 'string') { normalized.link_label = payload.link_label.trim(); } return normalized; } function serializeElements(elements: LayoutElement[], context: LayoutSerializationContext): LayoutElementPayload[] { return normalizeElements(elements).map((element) => { const base = clampElement(element); let content: string | null = base.content ?? null; switch (base.type) { case 'headline': content = context.form.headline ?? context.eventName; break; case 'subtitle': content = context.form.subtitle ?? ''; break; case 'description': content = context.form.description ?? ''; break; case 'link': content = context.form.link_label && context.form.link_label.trim().length > 0 ? context.form.link_label : context.inviteUrl; break; case 'badge': content = context.form.badge_label ?? context.badgeFallback; break; case 'logo': content = context.logoUrl ?? context.form.logo_url ?? null; break; case 'cta': content = context.form.cta_label ?? context.form.link_label ?? context.inviteUrl; break; default: break; } return { id: base.id, type: base.type, x: Math.round(base.x), y: Math.round(base.y), width: Math.round(base.width), height: Math.round(base.height), rotation: Math.round(base.rotation ?? 0), font_size: base.fontSize ? Math.round(base.fontSize) : undefined, align: base.align, content, font_family: base.fontFamily ?? null, letter_spacing: base.letterSpacing, line_height: base.lineHeight, fill: base.fill ?? null, locked: Boolean(base.locked), }; }); } type InviteLayoutCustomizerPanelProps = { invite: EventQrInvite | null; eventName: string; eventDate: string | null; backgroundImages?: BackgroundImageOption[]; saving: boolean; resetting: boolean; onSave: (customization: QrLayoutCustomization) => Promise; onReset: () => Promise; initialCustomization: QrLayoutCustomization | null; draftCustomization?: QrLayoutCustomization | null; onDraftChange?: (draft: QrLayoutCustomization | null) => void; }; const MAX_INSTRUCTIONS = 5; const ZOOM_MIN = 0.1; const ZOOM_MAX = 2; const ZOOM_STEP = 0.05; export function InviteLayoutCustomizerPanel({ invite, eventName, eventDate, saving, resetting, onSave, onReset, initialCustomization, draftCustomization, onDraftChange, backgroundImages = preloadedBackgrounds, }: InviteLayoutCustomizerPanelProps): React.JSX.Element { const { t } = useTranslation('management'); const fabricCanvasRef = React.useRef(null); const { fonts: availableFonts, isLoading: fontsLoading } = useTenantFonts(); const inviteUrl = invite?.url ?? ''; const qrCodeDataUrl = invite?.qr_code_data_url ?? null; if (!qrCodeDataUrl) { console.warn('QR DataURL is null - using fallback in canvas'); } const defaultInstructions = React.useMemo(() => { const value = t('tasks.customizer.defaults.instructions', { returnObjects: true }) as unknown; return Array.isArray(value) ? (value as string[]) : ['QR-Code scannen', 'Profil anlegen', 'Fotos teilen']; }, [t]); const [availableLayouts, setAvailableLayouts] = React.useState(invite?.layouts ?? []); const [layoutsLoading, setLayoutsLoading] = React.useState(false); const [layoutsError, setLayoutsError] = React.useState(null); const [selectedLayoutId, setSelectedLayoutId] = React.useState( draftCustomization?.layout_id ?? initialCustomization?.layout_id ?? invite?.layouts?.[0]?.id, ); const [form, setForm] = React.useState({}); const [instructions, setInstructions] = React.useState([]); const [error, setError] = React.useState(null); const formRef = React.useRef(null); const [downloadBusy, setDownloadBusy] = React.useState(null); const [printBusy, setPrintBusy] = React.useState(false); const [elements, setElements] = React.useState([]); const [activeElementId, setActiveElementId] = React.useState(null); const [inspectorElementId, setInspectorElementId] = React.useState(null); const [showFloatingActions, setShowFloatingActions] = React.useState(false); const [zoomScale, setZoomScale] = React.useState(1); const [fitScale, setFitScale] = React.useState(1); const [previewMode, setPreviewMode] = React.useState<'fit' | 'full'>('fit'); const [isCompact, setIsCompact] = React.useState(false); const fitScaleRef = React.useRef(1); const manualZoomRef = React.useRef(false); const actionsSentinelRef = React.useRef(null); const historyRef = React.useRef([]); const historyIndexRef = React.useRef(-1); const restoringRef = React.useRef(false); const [canUndo, setCanUndo] = React.useState(false); const [canRedo, setCanRedo] = React.useState(false); const designerViewportRef = React.useRef(null); const canvasContainerRef = React.useRef(null); const draftSignatureRef = React.useRef(null); const initialElementsRef = React.useRef([]); const activeCustomization = React.useMemo( () => draftCustomization ?? initialCustomization ?? null, [draftCustomization, initialCustomization], ); const customizationSignature = React.useMemo( () => (activeCustomization ? JSON.stringify(activeCustomization) : null), [activeCustomization], ); const appliedSignatureRef = React.useRef(null); const appliedLayoutRef = React.useRef(null); const appliedInviteRef = React.useRef(null); React.useEffect(() => { if (!availableFonts.length || !elements.length) { return; } const families = Array.from( new Set( elements .map((element) => element.fontFamily) .filter((value): value is string => Boolean(value)), ), ); families.forEach((family) => { const font = availableFonts.find((entry) => entry.family === family); if (font) { void ensureFontLoaded(font); } }); }, [availableFonts, elements]); React.useEffect(() => { if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') { setIsCompact(false); return; } const query = window.matchMedia('(max-width: 1023px)'); const update = (event?: MediaQueryListEvent) => { if (typeof event?.matches === 'boolean') { setIsCompact(event.matches); return; } setIsCompact(query.matches); }; update(); if (typeof query.addEventListener === 'function') { const listener = (event: MediaQueryListEvent) => update(event); query.addEventListener('change', listener); return () => query.removeEventListener('change', listener); } const legacyListener = (event: MediaQueryListEvent) => update(event); query.addListener(legacyListener); return () => query.removeListener(legacyListener); }, []); const clampZoom = React.useCallback( (value: number) => clamp(Number.isFinite(value) ? value : 1, ZOOM_MIN, ZOOM_MAX), [], ); const recomputeFitScale = React.useCallback(() => { const viewport = designerViewportRef.current; if (!viewport) { return; } const { clientWidth, clientHeight } = viewport; if (!clientWidth || !clientHeight) { return; } const style = window.getComputedStyle(viewport); const paddingX = parseFloat(style.paddingLeft ?? '0') + parseFloat(style.paddingRight ?? '0'); const paddingY = parseFloat(style.paddingTop ?? '0') + parseFloat(style.paddingBottom ?? '0'); const availableWidth = clientWidth - paddingX; const availableHeight = clientHeight - paddingY; if (availableWidth <= 0 || availableHeight <= 0) { return; } const widthScale = availableWidth / CANVAS_WIDTH; const heightScale = availableHeight / CANVAS_HEIGHT; const nextRaw = Math.min(widthScale, heightScale); let baseScale = Number.isFinite(nextRaw) && nextRaw > 0 ? nextRaw : 1; const minScale = 0.3; baseScale = Math.max(baseScale, minScale); const limitedScale = Math.min(baseScale, 1); const clamped = clampZoom(limitedScale); fitScaleRef.current = clamped; setFitScale((prev) => (Math.abs(prev - clamped) < 0.001 ? prev : clamped)); if (!manualZoomRef.current) { setZoomScale((prev) => (Math.abs(prev - clamped) < 0.001 ? prev : clamped)); } console.debug('[Invites][Zoom] viewport size', { availableWidth, availableHeight, widthScale, heightScale, clamped, }); }, [clampZoom]); React.useLayoutEffect(() => { recomputeFitScale(); }, [recomputeFitScale]); React.useEffect(() => { const viewport = designerViewportRef.current; const handleResize = () => { recomputeFitScale(); }; window.addEventListener('resize', handleResize); let observer: ResizeObserver | null = null; if (viewport && typeof ResizeObserver === 'function') { observer = new ResizeObserver(() => recomputeFitScale()); observer.observe(viewport); } recomputeFitScale(); return () => { window.removeEventListener('resize', handleResize); if (observer) { observer.disconnect(); } }; }, [recomputeFitScale]); const cloneElements = React.useCallback( (items: LayoutElement[]): LayoutElement[] => items.map((item) => ({ ...item })), [] ); const elementsAreEqual = React.useCallback((a: LayoutElement[], b: LayoutElement[]): boolean => { if (a.length !== b.length) { return false; } for (let index = 0; index < a.length; index += 1) { const left = a[index]; const right = b[index]; if ( left.id !== right.id || left.type !== right.type || left.x !== right.x || left.y !== right.y || left.width !== right.width || left.height !== right.height || (left.rotation ?? 0) !== (right.rotation ?? 0) || (left.fontSize ?? null) !== (right.fontSize ?? null) || (left.align ?? null) !== (right.align ?? null) || (left.content ?? null) !== (right.content ?? null) || (left.fontFamily ?? null) !== (right.fontFamily ?? null) || (left.letterSpacing ?? null) !== (right.letterSpacing ?? null) || (left.lineHeight ?? null) !== (right.lineHeight ?? null) || (left.fill ?? null) !== (right.fill ?? null) || Boolean(left.locked) !== Boolean(right.locked) || Boolean(left.initial) !== Boolean(right.initial) ) { return false; } } return true; }, []); const pushHistory = React.useCallback( (snapshot: LayoutElement[]) => { const copy = cloneElements(snapshot); let history = historyRef.current.slice(0, historyIndexRef.current + 1); history.push(copy); if (history.length > 60) { history = history.slice(history.length - 60); } historyRef.current = history; historyIndexRef.current = history.length - 1; setCanUndo(historyIndexRef.current > 0); setCanRedo(false); }, [cloneElements] ); const resetHistory = React.useCallback( (snapshot: LayoutElement[]) => { const copy = cloneElements(snapshot); historyRef.current = [copy]; historyIndexRef.current = 0; setCanUndo(false); setCanRedo(false); }, [cloneElements] ); const commitElements = React.useCallback( (producer: (current: LayoutElement[]) => LayoutElement[], options?: { silent?: boolean }) => { setElements((prev) => { const source = prev.length ? prev : initialElementsRef.current; const base = cloneElements(source.length ? source : []); const produced = producer(base); const normalized = normalizeElements(produced); if (elementsAreEqual(prev, normalized)) { return prev; } if (!options?.silent && !restoringRef.current) { pushHistory(normalized); } return normalized; }); }, [cloneElements, pushHistory, elementsAreEqual] ); const selectElement = React.useCallback((id: string | null, options: { preserveInspector?: boolean } = {}) => { setActiveElementId(id); if (id) { setInspectorElementId(id); return; } if (!options.preserveInspector) { setInspectorElementId(null); } }, []); const handleUndo = React.useCallback(() => { if (historyIndexRef.current <= 0) { return; } restoringRef.current = true; historyIndexRef.current -= 1; const snapshot = cloneElements(historyRef.current[historyIndexRef.current] ?? []); setElements(snapshot); setCanUndo(historyIndexRef.current > 0); setCanRedo(historyRef.current.length > historyIndexRef.current + 1); restoringRef.current = false; }, [cloneElements]); const handleRedo = React.useCallback(() => { if (historyIndexRef.current === -1) { return; } if (historyIndexRef.current >= historyRef.current.length - 1) { return; } restoringRef.current = true; historyIndexRef.current += 1; const snapshot = cloneElements(historyRef.current[historyIndexRef.current] ?? []); setElements(snapshot); setCanUndo(historyIndexRef.current > 0); setCanRedo(historyRef.current.length > historyIndexRef.current + 1); restoringRef.current = false; }, [cloneElements]); const formStateRef = React.useRef(form); React.useEffect(() => { formStateRef.current = form; }, [form]); const activeLayout = React.useMemo(() => { if (!availableLayouts.length) { return null; } if (selectedLayoutId) { const match = availableLayouts.find((layout) => layout.id === selectedLayoutId); if (match) { return match; } } return availableLayouts[0]; }, [availableLayouts, selectedLayoutId]); React.useEffect(() => { manualZoomRef.current = false; recomputeFitScale(); }, [recomputeFitScale, activeLayout?.id, invite?.id]); const activeLayoutQrSize = React.useMemo(() => { const qrElement = elements.find((element) => element.type === 'qr'); if (qrElement && typeof qrElement.width === 'number' && qrElement.width > 0) { return qrElement.width; } if (activeCustomization?.mode === 'advanced' && Array.isArray(activeCustomization.elements)) { const qrElement = activeCustomization.elements.find((element) => element?.type === 'qr'); if (qrElement && typeof qrElement.width === 'number' && qrElement.width > 0) { return qrElement.width; } } return activeLayout?.preview?.qr_size_px ?? 500; }, [elements, activeCustomization?.mode, activeCustomization?.elements, activeLayout?.preview?.qr_size_px]); const effectiveScale = React.useMemo(() => { if (previewMode === 'full') { return 1.0; } return clampZoom(Number.isFinite(zoomScale) && zoomScale > 0 ? zoomScale : fitScale); }, [clampZoom, zoomScale, fitScale, previewMode]); const zoomPercent = Math.round(effectiveScale * 100); const handleZoomStep = React.useCallback( (direction: 1 | -1) => { manualZoomRef.current = true; setZoomScale((current) => clampZoom(current + direction * ZOOM_STEP)); }, [clampZoom] ); const updateElement = React.useCallback( (id: string, updater: Partial | ((element: LayoutElement) => Partial), options?: { silent?: boolean }) => { commitElements( (current) => current.map((element) => { if (element.id !== id) { return element; } const patch = typeof updater === 'function' ? updater(element) : updater; return clampElement({ ...element, ...patch }); }), options ); }, [commitElements] ); const handleElementFontChange = React.useCallback( (id: string, family: string) => { updateElement(id, { fontFamily: family || null }); const font = availableFonts.find((entry) => entry.family === family); if (font) { void ensureFontLoaded(font).then(() => { fabricCanvasRef.current?.requestRenderAll(); }); } }, [availableFonts, updateElement] ); const handleFontOptionPreview = React.useCallback( (family: string) => { const font = availableFonts.find((entry) => entry.family === family); if (font) { void ensureFontLoaded(font); } }, [availableFonts] ); React.useEffect(() => { if (!invite) { setAvailableLayouts([]); setSelectedLayoutId(undefined); return; } const layouts = invite.layouts ?? []; setAvailableLayouts(layouts); setLayoutsError(null); setSelectedLayoutId((current) => { if (current && layouts.some((layout) => layout.id === current)) { return current; } if (activeCustomization?.layout_id && layouts.some((layout) => layout.id === activeCustomization.layout_id)) { return activeCustomization.layout_id; } return layouts[0]?.id; }); }, [invite, activeCustomization?.layout_id]); React.useEffect(() => { let cancelled = false; async function loadLayouts(url: string) { try { setLayoutsLoading(true); setLayoutsError(null); const target = (() => { try { if (url.startsWith('http://') || url.startsWith('https://')) { const parsed = new URL(url); return parsed.pathname + parsed.search; } } catch (parseError) { console.warn('[Invites] Failed to parse layout URL', parseError); } return url; })(); const response = await authorizedFetch(target, { method: 'GET', headers: { Accept: 'application/json' }, }); if (!response.ok) { console.error('[Invites] Layout request failed', response.status, response.statusText); throw new Error(`Failed with status ${response.status}`); } const json = await response.json(); const items = Array.isArray(json?.data) ? (json.data as EventQrInviteLayout[]) : []; if (!cancelled) { setAvailableLayouts(items); setSelectedLayoutId((current) => { if (current && items.some((layout) => layout.id === current)) { return current; } if (activeCustomization?.layout_id && items.some((layout) => layout.id === activeCustomization.layout_id)) { return activeCustomization.layout_id; } return items[0]?.id; }); } } catch (err) { if (!cancelled) { console.error('[Invites] Failed to load layouts', err); setLayoutsError(t('invites.customizer.loadingError', 'Layouts konnten nicht geladen werden.')); } } finally { if (!cancelled) { setLayoutsLoading(false); } } } if (!invite || availableLayouts.length > 0 || !invite.layouts_url) { return () => { cancelled = true; }; } void loadLayouts(invite.layouts_url); return () => { cancelled = true; }; }, [invite, availableLayouts.length, activeCustomization?.layout_id, t]); React.useEffect(() => { if (!availableLayouts.length) { return; } setSelectedLayoutId((current) => { if (current && availableLayouts.some((layout) => layout.id === current)) { return current; } if (activeCustomization?.layout_id && availableLayouts.some((layout) => layout.id === activeCustomization.layout_id)) { return activeCustomization.layout_id; } return availableLayouts[0].id; }); }, [availableLayouts, activeCustomization?.layout_id]); React.useEffect(() => { const inviteKey = invite?.id ?? null; const layoutId = activeLayout?.id ?? null; const incomingSignature = customizationSignature; if (!invite || !activeLayout) { setForm({}); setInstructions([]); commitElements(() => [], { silent: true }); resetHistory([]); initialElementsRef.current = []; appliedSignatureRef.current = null; appliedLayoutRef.current = layoutId; appliedInviteRef.current = inviteKey; return; } if ( draftCustomization && incomingSignature && incomingSignature === draftSignatureRef.current && appliedLayoutRef.current === layoutId && appliedInviteRef.current === inviteKey ) { appliedSignatureRef.current = incomingSignature; appliedLayoutRef.current = layoutId; appliedInviteRef.current = inviteKey; return; } const alreadyApplied = appliedSignatureRef.current === incomingSignature && appliedLayoutRef.current === layoutId && appliedInviteRef.current === inviteKey; if (alreadyApplied) { return; } const reuseCustomization = activeCustomization?.layout_id === activeLayout.id; const baseInstructions = reuseCustomization && Array.isArray(activeCustomization?.instructions) && activeCustomization.instructions?.length ? [...(activeCustomization.instructions as string[])] : ((activeLayout.instructions && activeLayout.instructions.length) ? [...activeLayout.instructions] : [...defaultInstructions]); setInstructions(baseInstructions); const newForm: QrLayoutCustomization = { layout_id: activeLayout.id, headline: reuseCustomization ? activeCustomization?.headline ?? eventName : eventName, subtitle: reuseCustomization ? activeCustomization?.subtitle ?? activeLayout.subtitle ?? '' : activeLayout.subtitle ?? '', description: reuseCustomization ? activeCustomization?.description ?? activeLayout.description ?? '' : activeLayout.description ?? '', badge_label: reuseCustomization ? activeCustomization?.badge_label ?? t('tasks.customizer.defaults.badgeLabel') : (activeLayout.badge_label ?? t('tasks.customizer.defaults.badgeLabel')), instructions_heading: reuseCustomization ? activeCustomization?.instructions_heading ?? t('tasks.customizer.defaults.instructionsHeading') : t('tasks.customizer.defaults.instructionsHeading'), link_heading: reuseCustomization ? activeCustomization?.link_heading ?? t('tasks.customizer.defaults.linkHeading') : t('tasks.customizer.defaults.linkHeading'), link_label: reuseCustomization ? activeCustomization?.link_label ?? inviteUrl : inviteUrl, cta_label: reuseCustomization ? activeCustomization?.cta_label ?? t('tasks.customizer.defaults.ctaLabel') : (activeLayout.cta_label ?? t('tasks.customizer.defaults.ctaLabel')), accent_color: sanitizeColor((reuseCustomization ? activeCustomization?.accent_color : activeLayout.preview?.accent) ?? null) ?? '#6366F1', text_color: sanitizeColor((reuseCustomization ? activeCustomization?.text_color : activeLayout.preview?.text) ?? null) ?? '#111827', background_color: sanitizeColor((reuseCustomization ? activeCustomization?.background_color : activeLayout.preview?.background) ?? null) ?? '#FFFFFF', secondary_color: reuseCustomization ? activeCustomization?.secondary_color ?? '#1F2937' : '#1F2937', badge_color: reuseCustomization ? activeCustomization?.badge_color ?? '#2563EB' : '#2563EB', background_image: reuseCustomization ? activeCustomization?.background_image ?? null : null, background_gradient: reuseCustomization ? activeCustomization?.background_gradient ?? activeLayout.preview?.background_gradient ?? null : activeLayout.preview?.background_gradient ?? null, logo_data_url: reuseCustomization ? activeCustomization?.logo_data_url ?? activeCustomization?.logo_url ?? null : null, mode: reuseCustomization ? activeCustomization?.mode : 'standard', elements: reuseCustomization ? activeCustomization?.elements : undefined, }; setForm(newForm); setError(null); const isCustomizedAdvanced = newForm.mode === 'advanced' && Array.isArray(newForm.elements) && newForm.elements.length > 0; const fallbackQrSize = (() => { if (Array.isArray(newForm.elements)) { const qrElement = newForm.elements.find((element) => element?.type === 'qr'); if (qrElement && typeof qrElement.width === 'number' && qrElement.width > 0) { return qrElement.width; } } if (typeof activeLayout.preview?.qr_size_px === 'number' && activeLayout.preview.qr_size_px > 0) { return activeLayout.preview.qr_size_px; } return 500; })(); if (isCustomizedAdvanced) { const initialElements = normalizeElements(payloadToElements(newForm.elements)); initialElementsRef.current = initialElements; commitElements(() => initialElements, { silent: true }); resetHistory(initialElements); } else { const defaults = buildDefaultElements(activeLayout, newForm, eventName, fallbackQrSize); const normalizedDefaults = normalizeElements(defaults); initialElementsRef.current = normalizedDefaults; commitElements(() => normalizedDefaults, { silent: true }); resetHistory(normalizedDefaults); } appliedSignatureRef.current = incomingSignature ?? null; appliedLayoutRef.current = layoutId; appliedInviteRef.current = inviteKey; selectElement(null); }, [ activeLayout, invite, invite?.id, activeCustomization, draftCustomization, customizationSignature, defaultInstructions, eventName, inviteUrl, t, commitElements, resetHistory, selectElement, ]); React.useEffect(() => { if (typeof IntersectionObserver === 'undefined') { setShowFloatingActions(false); return; } const node = actionsSentinelRef.current; if (!node) { setShowFloatingActions(false); return; } const observer = new IntersectionObserver(([entry]) => { setShowFloatingActions(!entry.isIntersecting); }); observer.observe(node); return () => { observer.disconnect(); }; }, [invite?.id, activeLayout?.id]); const nonRemovableIds = React.useMemo( () => new Set(['headline', 'qr']), [] ); const effectiveInstructions = instructions.filter((entry) => entry.trim().length > 0); const canvasElements = React.useMemo(() => { if (!activeLayout) { return [] as LayoutElement[]; } const base = elements.length ? elements : buildDefaultElements(activeLayout, form, eventName, activeLayoutQrSize); return base.map((element) => ({ ...element, initial: element.initial ?? nonRemovableIds.has(element.id), })); }, [activeLayout, elements, form, eventName, activeLayoutQrSize, nonRemovableIds]); React.useEffect(() => { if (!onDraftChange) { return; } if (!invite || !activeLayout) { if (draftSignatureRef.current !== null) { draftSignatureRef.current = null; onDraftChange(null); } return; } const serializationContext: LayoutSerializationContext = { form, eventName, inviteUrl, instructions: effectiveInstructions, qrSize: activeLayoutQrSize, badgeFallback: t('tasks.customizer.defaults.badgeLabel'), logoUrl: form.logo_url ?? null, }; const advancedElements = elements.length ? elements : buildDefaultElements(activeLayout, form, eventName, activeLayoutQrSize); const draftPayload: QrLayoutCustomization = { ...form, layout_id: activeLayout.id, instructions: effectiveInstructions, mode: 'advanced', elements: serializeElements(advancedElements, serializationContext), }; const sanitizedDraft = sanitizePayload(draftPayload); const signature = JSON.stringify(sanitizedDraft); if (signature !== draftSignatureRef.current) { draftSignatureRef.current = signature; onDraftChange(sanitizedDraft); } }, [ onDraftChange, invite, invite?.id, activeLayout, activeLayout?.id, form, elements, effectiveInstructions, eventName, inviteUrl, activeLayoutQrSize, t, ]); const elementLabelFor = React.useCallback( (element: LayoutElement): string => { switch (element.type) { case 'headline': return t('invites.customizer.elements.headline', 'Überschrift'); case 'subtitle': return t('invites.customizer.elements.subtitle', 'Untertitel'); case 'description': return t('invites.customizer.elements.description', 'Beschreibung'); case 'badge': return t('invites.customizer.elements.badge', 'Badge'); case 'link': return t('invites.customizer.elements.link', 'Linkfeld'); case 'cta': return t('invites.customizer.elements.cta', 'Call-to-Action'); case 'qr': return t('invites.customizer.elements.qr', 'QR-Code'); case 'logo': return t('invites.customizer.elements.logo', 'Logo'); case 'text': default: if (element.content && element.content.trim().length) { const preview = element.content.trim(); return preview.length > 28 ? `${preview.slice(0, 28)}…` : preview; } return t('invites.customizer.elements.text', 'Freier Textblock'); } }, [t] ); const elementIconFor = React.useCallback((element: LayoutElement) => { switch (element.type) { case 'headline': case 'subtitle': return Heading; case 'description': case 'text': return AlignLeft; case 'badge': return BadgeCheck; case 'link': return LinkIcon; case 'cta': return Megaphone; case 'qr': return QrCode; case 'logo': return Type; default: return AlignLeft; } }, []); const createPresetElement = React.useCallback( (type: LayoutElementType, preferredId?: string): LayoutElement | null => { if (!activeLayout) { return null; } const baseId = preferredId ?? `text-${Date.now()}`; const currentForm = formStateRef.current ?? {}; switch (type) { case 'subtitle': return { ...clampElement({ id: 'subtitle', type: 'subtitle', x: 80, y: 240, width: 520, height: 110, fontSize: 32, content: (currentForm.subtitle as string) ?? '', align: 'left', }), initial: false }; case 'badge': return { ...clampElement({ id: 'badge', type: 'badge', x: 80, y: 40, width: 220, height: 70, align: 'center', fontSize: 22, content: (currentForm.badge_label as string) ?? t('tasks.customizer.defaults.badgeLabel'), }), initial: false }; case 'link': return { ...clampElement({ id: 'link', type: 'link', x: 660, y: 340 + activeLayoutQrSize, width: 360, height: 110, fontSize: 26, align: 'center', content: currentForm.link_label && String(currentForm.link_label).trim().length > 0 ? String(currentForm.link_label) : inviteUrl, }), initial: false }; case 'cta': return { ...clampElement({ id: 'cta', type: 'cta', x: 660, y: 340 + activeLayoutQrSize + 130, width: 360, height: 100, align: 'center', fontSize: 24, content: (currentForm.cta_label as string) ?? t('tasks.customizer.defaults.ctaLabel'), }), initial: false }; case 'text': return { ...clampElement({ id: baseId, type: 'text', x: 120, y: 520, width: 520, height: 140, fontSize: 26, align: 'left', content: t('invites.customizer.defaults.textBlock', 'Neuer Textblock – hier kannst du eigene Hinweise ergänzen.'), }), initial: false }; default: return null; } }, [activeLayout, activeLayoutQrSize, inviteUrl, t] ); const addElementFromPreset = React.useCallback( (type: LayoutElementType, preferredId?: string) => { const preset = createPresetElement(type, preferredId); if (!preset) { return; } commitElements((current) => { const next = preferredId ? current.filter((item) => item.id !== preferredId) : [...current]; next.push({ ...preset, initial: false }); return next; }); selectElement(preset.id); }, [createPresetElement, commitElements, selectElement] ); const removeElement = React.useCallback( (id: string) => { if (nonRemovableIds.has(id)) { return; } commitElements((current) => current.filter((item) => item.id !== id)); if (activeElementId === id || inspectorElementId === id) { selectElement(null); } }, [activeElementId, inspectorElementId, nonRemovableIds, commitElements, selectElement] ); const updateElementAlign = React.useCallback( (id: string, align: 'left' | 'center' | 'right') => { selectElement(id, { preserveInspector: true }); updateElement(id, { align }); }, [selectElement, updateElement] ); const elementTypeOrder: Record = React.useMemo( () => ({ headline: 1, subtitle: 2, description: 3, text: 4, badge: 5, link: 6, cta: 7, qr: 8, logo: 9, }), [] ); const sortedElements = React.useMemo(() => { return [...elements].sort((a, b) => { const aOrder = elementTypeOrder[a.type] ?? 99; const bOrder = elementTypeOrder[b.type] ?? 99; if (aOrder === bOrder) { return a.id.localeCompare(b.id); } return aOrder - bOrder; }); }, [elements, elementTypeOrder]); const additionOptions = React.useMemo(() => { const existingIds = new Set(elements.map((item) => item.id)); return [ { key: 'subtitle', type: 'subtitle' as LayoutElementType, label: t('invites.customizer.elements.addSubtitle', 'Untertitel einblenden'), icon: Heading, unique: true, }, { key: 'badge', type: 'badge' as LayoutElementType, label: t('invites.customizer.elements.addBadge', 'Badge anzeigen'), icon: BadgeCheck, unique: true, }, { key: 'link', type: 'link' as LayoutElementType, label: t('invites.customizer.elements.addLink', 'Linkfeld hinzufügen'), icon: LinkIcon, unique: true, }, { key: 'cta', type: 'cta' as LayoutElementType, label: t('invites.customizer.elements.addCta', 'Call-to-Action einfügen'), icon: Megaphone, unique: true, }, { key: 'text', type: 'text' as LayoutElementType, label: t('invites.customizer.elements.addText', 'Freien Textblock hinzufügen'), icon: Type, unique: false, }, ].filter((option) => { if (!option.unique) { return true; } return !existingIds.has(option.key); }); }, [elements, t]); const elementBindings = React.useMemo( () => ({ headline: { field: 'headline' as const, label: t('invites.customizer.fields.headline', 'Überschrift'), multiline: false, }, subtitle: { field: 'subtitle' as const, label: t('invites.customizer.fields.subtitle', 'Unterzeile'), multiline: false, }, description: { field: 'description' as const, label: t('invites.customizer.fields.description', 'Beschreibung'), multiline: true, }, badge: { field: 'badge_label' as const, label: t('invites.customizer.fields.badge', 'Badge-Label'), multiline: false, }, link: { field: 'link_label' as const, label: t('invites.customizer.fields.linkLabel', 'Linktext'), multiline: false, }, cta: { field: 'cta_label' as const, label: t('invites.customizer.fields.cta', 'Call-to-Action'), multiline: false, }, }), [t] ); const updateForm = React.useCallback( (key: T, value: QrLayoutCustomization[T]) => { setForm((prev) => ({ ...prev, [key]: value })); const bindingEntry = Object.entries(elementBindings).find(([, binding]) => binding.field === key); if (!bindingEntry) { return; } const [elementId] = bindingEntry; selectElement(elementId, { preserveInspector: true }); commitElements( (current) => current.map((el) => el.id === elementId ? { ...el, content: String(value ?? '') } : el ), { silent: true }, ); }, [commitElements, elementBindings, selectElement], ); const updateElementContent = React.useCallback( (id: string, value: string) => { selectElement(id, { preserveInspector: true }); commitElements((current) => current.map((item) => (item.id === id ? { ...item, content: value } : item))); const bindingField = ELEMENT_BINDING_FIELD[id]; if (bindingField) { updateForm(bindingField, value); } }, [commitElements, selectElement, updateForm], ); const renderElementDetail = React.useCallback( (element: LayoutElement): React.ReactNode => { const binding = elementBindings[element.id as keyof typeof elementBindings]; const blocks: React.ReactNode[] = []; if (binding) { const value = (form[binding.field] as string) ?? ''; if (binding.multiline) { blocks.push(