import React from 'react'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { ArrowLeft, RefreshCcw, Plus } 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 { Input, TextArea } from 'tamagui'; import type { fabric as FabricNS } from 'fabric'; import { MobileShell } from './components/MobileShell'; import { MobileCard, CTAButton, PillBadge } from './components/Primitives'; import { TenantEvent, EventQrInvite, EventQrInviteLayout, getEvent, getEventQrInvites, updateEventQrInvite, } from '../api'; import { isAuthError } from '../auth/tokens'; import { getApiErrorMessage } from '../lib/apiError'; import toast from 'react-hot-toast'; import { generatePdfBytes, generatePngDataUrl, triggerDownloadFromBlob, triggerDownloadFromDataUrl, } from '../pages/components/invite-layout/export-utils'; import { LayoutElement, CANVAS_WIDTH, CANVAS_HEIGHT } from '../pages/components/invite-layout/schema'; type Step = 'background' | 'text' | 'preview'; const BACKGROUND_PRESETS = [ { id: 'bg-blue-floral', src: '/storage/layouts/backgrounds-portrait/bg-blue-floral.png', label: 'Blue Floral' }, { id: 'bg-goldframe', src: '/storage/layouts/backgrounds-portrait/bg-goldframe.png', label: 'Gold Frame' }, { id: 'gr-green-floral', src: '/storage/layouts/backgrounds-portrait/gr-green-floral.png', label: 'Green Floral' }, ]; export function buildInitialTextFields({ customization, layoutDefaults, eventName, }: { customization: Record | null | undefined; layoutDefaults: EventQrInviteLayout | null | undefined; eventName?: string | null; }): { headline: string; subtitle: string; description: string; instructions: string[] } { const initialInstructions = Array.isArray(customization?.instructions) && customization.instructions.length ? customization.instructions .map((item: unknown) => String(item ?? '')) .filter((item: string) => item.length > 0) : [ 'QR-Code scannen\nKamera-App öffnen und auf den Code richten.', 'Webseite öffnen\nDer Link öffnet direkt das gemeinsame Jubiläumsalbum.', 'Fotos hochladen\nZeigt eure Lieblingsmomente oder erfüllt kleine Fotoaufgaben, um besondere Erinnerungen beizusteuern.', ]; return { headline: (typeof customization?.headline === 'string' && customization.headline.length ? customization.headline : null) ?? (typeof eventName === 'string' && eventName.length ? eventName : null) ?? (typeof layoutDefaults?.name === 'string' ? layoutDefaults.name : '') ?? '', subtitle: typeof customization?.subtitle === 'string' ? customization.subtitle : '', description: (typeof customization?.description === 'string' && customization.description.length ? customization.description : null) ?? (typeof layoutDefaults?.description === 'string' ? layoutDefaults.description : null) ?? 'Helft uns, diesen besonderen Tag mit euren schönen Momenten festzuhalten.', instructions: initialInstructions, }; } 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 [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 [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 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); } }, []); 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; 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: eventData?.name ?? null, }) ); 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), }, }, }; 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 ( navigate(-1)} headerActions={ window.location.reload()}> } > {error ? ( {error} ) : null} {step === 'background' && ( navigate(-1)} presets={BACKGROUND_PRESETS} selectedPreset={backgroundPreset} onSelectPreset={handleSelectPreset} selectedLayout={selectedLayout} layouts={invite?.layouts ?? []} onSelectGradient={handleSelectGradient} onSelectSolid={handleSelectSolid} selectedGradient={backgroundGradient} selectedSolid={backgroundSolid} onSave={() => { 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 ?? ''} isFoldable={isFoldable} onExport={(format) => { const layout = selectedLayout; const url = layout?.download_urls?.[format]; if (!url) { toast.error(t('events.qr.missing', 'Kein QR-Link vorhanden')); return; } window.open(url, '_blank', 'noopener'); }} /> )} ); } function Stepper({ current, onStepChange }: { current: Step; onStepChange: (step: Step) => void }) { const { t } = useTranslation('management'); 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; lineHeight?: number; align?: 'left' | 'center' | 'right'; }; function getDefaultSlots(): Record { return { headline: { x: 0.08, y: 0.08, w: 0.84, fontSize: 30, fontWeight: 800, align: 'center' }, subtitle: { x: 0.1, y: 0.16, w: 0.8, fontSize: 18, fontWeight: 600, align: 'center' }, description: { x: 0.1, y: 0.22, w: 0.8, fontSize: 16, lineHeight: 1.4, align: 'center' }, instructions: { x: 0.1, y: 0.34, w: 0.8, fontSize: 14, lineHeight: 1.35 }, qr: { x: 0.35, y: 0.55, w: 0.3 }, }; } function resolveSlots(layout: EventQrInviteLayout | null, isFoldable: boolean): Record { const fromLayout = (layout as any)?.slots; if (fromLayout && typeof fromLayout === 'object') { return 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, lineHeight: typeof slot.lineHeight === 'number' ? slot.lineHeight : undefined, align: typeof slot.align === 'string' ? (slot.align as SlotDefinition['align']) : undefined, }; } return acc; }, {}); } if (isFoldable) { return { headline: { x: 0.1, y: 0.1, w: 0.8, fontSize: 28, fontWeight: 800, align: 'center' }, subtitle: { x: 0.12, y: 0.18, w: 0.76, fontSize: 18, fontWeight: 600, align: 'center' }, description: { x: 0.12, y: 0.24, w: 0.76, fontSize: 15, lineHeight: 1.4, align: 'center' }, instructions: { x: 0.12, y: 0.36, w: 0.76, fontSize: 13, lineHeight: 1.3 }, qr: { x: 0.36, y: 0.56, w: 0.28 }, }; } return getDefaultSlots(); } function getCanvasSize(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 }; } if ((layout?.orientation || '').toLowerCase() === 'landscape' || isFoldable) { return { width: 1754, height: 1240 }; // A4 landscape fallback } return { width: 1240, height: 1754 }; // A4 portrait fallback } function FabricCanvasPreview({ layout, isFoldable, backgroundPresetSrc, backgroundGradient, backgroundSolid, textFields, qrUrl, }: { layout: EventQrInviteLayout | null; isFoldable: boolean; backgroundPresetSrc: string | null; backgroundGradient: { angle: number; stops: string[] } | null; backgroundSolid: string | null; textFields: { headline: string; subtitle: string; description: string; instructions: string[] }; qrUrl: string; }) { const canvasRef = React.useRef(null); const slots = React.useMemo(() => resolveSlots(layout, isFoldable), [layout, isFoldable]); const containerRef = React.useRef(null); const [frame, setFrame] = React.useState<{ width: number; height: number } | null>(null); React.useLayoutEffect(() => { const measure = () => { if (!containerRef.current) return; const rect = containerRef.current.getBoundingClientRect(); const width = rect.width || 320; const aspect = layout?.preview?.aspect_ratio ?? ((layout?.orientation || '').toLowerCase() === 'landscape' || isFoldable ? 297 / 210 : 210 / 297); const height = width / aspect; setFrame({ width, height }); }; measure(); window.addEventListener('resize', measure); return () => window.removeEventListener('resize', measure); }, [layout?.orientation, layout?.preview?.aspect_ratio, isFoldable]); React.useEffect(() => { if (!frame) return; const baseWidth = frame.width; const baseHeight = frame.height; let canvas: FabricNS.Canvas | null = null; let disposed = false; async function render() { const mod = await import('fabric'); const fabricLib: typeof FabricNS | undefined = (mod as any)?.fabric ?? (mod as any)?.default ?? (mod as any); const canvasEl = canvasRef.current; const fallbackColor = backgroundSolid ?? '#f8fafc'; if (canvasEl) { const ctx = canvasEl.getContext('2d'); if (ctx) { ctx.fillStyle = fallbackColor; ctx.fillRect(0, 0, baseWidth, baseHeight); } } if (!fabricLib || !fabricLib.Canvas || !canvasEl) { return; } if (disposed || !canvasRef.current) return; const fabric: typeof FabricNS = fabricLib as unknown as typeof FabricNS; canvasEl.width = baseWidth; canvasEl.height = baseHeight; canvas = new fabric.Canvas(canvasRef.current, { selection: false, hoverCursor: 'default', }); canvas.setWidth(baseWidth); canvas.setHeight(baseHeight); canvas.clear(); const lower = (canvas as any).lowerCanvasEl as HTMLCanvasElement | undefined; const upper = (canvas as any).upperCanvasEl as HTMLCanvasElement | undefined; [lower, upper].forEach((el, idx) => { if (!el) return; el.style.position = 'absolute'; el.style.left = '0'; el.style.top = '0'; el.style.width = '100%'; el.style.height = '100%'; el.style.setProperty('background-color', 'transparent', 'important'); el.style.pointerEvents = 'none'; el.style.zIndex = idx === 0 ? '1' : '2'; }); canvas.set('backgroundColor', 'transparent'); canvas.renderAll(); const textColor = layout?.preview?.text ?? '#0f172a'; const panelWidth = isFoldable ? baseWidth / 2 : baseWidth; const panelHeight = baseHeight; const drawPanel = async (offset: number, mirrored: boolean) => { const placeX = (slotX: number, slotW: number) => mirrored ? offset + (panelWidth - (slotX + slotW) * panelWidth) : offset + slotX * panelWidth; // Headline if (textFields.headline) { const slot = slots.headline; const txt = new fabric.Textbox(textFields.headline, { left: placeX(slot.x, slot.w), top: slot.y * panelHeight, width: slot.w * panelWidth, fontSize: slot.fontSize ?? 28, fontWeight: slot.fontWeight ?? '800', fill: textColor, textAlign: slot.align ?? 'center', selectable: false, evented: false, }); canvas!.add(txt); } if (textFields.subtitle) { const slot = slots.subtitle; const txt = new fabric.Textbox(textFields.subtitle, { left: placeX(slot.x, slot.w), top: slot.y * panelHeight, width: slot.w * panelWidth, fontSize: slot.fontSize ?? 18, fontWeight: slot.fontWeight ?? '600', fill: textColor, textAlign: slot.align ?? 'center', selectable: false, evented: false, }); canvas!.add(txt); } if (textFields.description) { const slot = slots.description; const txt = new fabric.Textbox(textFields.description, { left: placeX(slot.x, slot.w), top: slot.y * panelHeight, width: slot.w * panelWidth, fontSize: slot.fontSize ?? 16, lineHeight: slot.lineHeight ?? 1.35, fill: textColor, textAlign: slot.align ?? 'center', selectable: false, evented: false, }); canvas!.add(txt); } const instructions = textFields.instructions.filter((i) => i.trim().length > 0); if (instructions.length) { const slot = slots.instructions; const txt = new fabric.Textbox( instructions .map((line, idx) => `${idx + 1}. ${line}`) .join('\n'), { left: placeX(slot.x, slot.w), top: slot.y * panelHeight, width: slot.w * panelWidth, fontSize: slot.fontSize ?? 14, lineHeight: slot.lineHeight ?? 1.3, fill: textColor, textAlign: slot.align ?? 'left', selectable: false, evented: false, } ); canvas!.add(txt); } if (qrUrl) { const slot = slots.qr; const size = slot.w * panelWidth; const left = placeX(slot.x, slot.w); const top = slot.y * panelHeight; await new Promise((resolve) => { fabric.Image.fromURL( qrUrl, (img) => { if (!img) return resolve(); img.set({ left, top, selectable: false, evented: false, objectCaching: false, }); img.scaleToWidth(size); img.scaleToHeight(size); canvas!.add(img); resolve(); }, { crossOrigin: qrUrl.startsWith(window.location.origin) ? undefined : 'anonymous' } ); }); } }; await drawPanel(0, false); if (isFoldable) { await drawPanel(panelWidth, true); } canvas.renderAll(); } render(); return () => { disposed = true; if (canvas) { canvas.dispose(); } }; }, [ backgroundGradient, backgroundPresetSrc, backgroundSolid, frame, isFoldable, layout?.preview?.text, qrUrl, slots, textFields.description, textFields.headline, textFields.instructions, textFields.subtitle, ]); return ( ); } function buildFabricOptions({ layout, isFoldable, backgroundGradient, backgroundSolid, backgroundImageUrl, textFields, qrUrl, qrPngUrl, baseWidth = CANVAS_WIDTH, baseHeight = CANVAS_HEIGHT, }: { 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; }) { const scaleX = CANVAS_WIDTH / baseWidth; const scaleY = CANVAS_HEIGHT / baseHeight; const panelWidth = isFoldable ? baseWidth / 2 : baseWidth; const panelHeight = baseHeight; const slots = resolveSlots(layout, isFoldable); const elements: LayoutElement[] = []; const textColor = layout?.preview?.text ?? '#0f172a'; const accentColor = layout?.preview?.accent ?? '#2563EB'; const secondaryColor = layout?.preview?.secondary ?? '#1f2937'; const badgeColor = layout?.preview?.badge ?? accentColor; const addTextElement = ( type: LayoutElement['type'], content: string, slot: SlotDefinition, opts: { panelOffset: number; mirrored: boolean } ) => { if (!content) return; const widthPx = slot.w * panelWidth * scaleX; const heightPx = (slot.h ?? 0.12) * panelHeight * scaleY; const xBase = opts.mirrored ? opts.panelOffset + (panelWidth - (slot.x + slot.w) * panelWidth) : opts.panelOffset + slot.x * panelWidth; const xPx = xBase * scaleX; const yPx = slot.y * panelHeight * scaleY; elements.push({ id: `${type}-${opts.panelOffset}-${opts.mirrored ? 'm' : 'n'}`, type, x: xPx, y: yPx, 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: textColor, }); }; const addQr = (slot: SlotDefinition, opts: { panelOffset: number; mirrored: boolean }) => { if (!qrUrl) return; const sizePx = slot.w * panelWidth * scaleX; const xBase = opts.mirrored ? opts.panelOffset + (panelWidth - (slot.x + slot.w) * panelWidth) : opts.panelOffset + slot.x * panelWidth; const xPx = xBase * scaleX; const yPx = slot.y * panelHeight * scaleY; elements.push({ id: `qr-${opts.panelOffset}-${opts.mirrored ? 'm' : 'n'}`, type: 'qr', x: xPx, y: yPx, width: sizePx, height: sizePx, }); }; 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 ?? '#ffffff', backgroundGradient, backgroundImageUrl, 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; 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 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 formatLabel = isFoldable ? 'A4 Landscape (Double/Mirror)' : 'A4 Portrait'; const disablePresets = isFoldable; const gradientPresets = [ { angle: 180, stops: ['#F8FAFC', '#EEF2FF', '#F8FAFC'], label: 'Soft Lilac' }, { angle: 135, stops: ['#FEE2E2', '#EFF6FF', '#ECFDF3'], label: 'Pastell' }, { angle: 210, stops: ['#0B132B', '#1C2541', '#274690'], label: 'Midnight' }, ]; const solidPresets = ['#FFFFFF', '#F8FAFC', '#0F172A', '#2563EB', '#F59E0B']; return ( {t('common.back', 'Zurück')} {formatLabel} {disablePresets ? t('events.qr.backgroundPicker', 'Hintergrund für A5 (Gradient/Farbe)') : t('events.qr.backgroundPicker', `Hintergrund auswählen (${formatLabel})`)} {!disablePresets ? ( <> {presets.map((preset) => { const isSelected = selectedPreset === preset.id; return ( onSelectPreset(preset.id)} style={{ width: '48%' }}> {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.')} ) : ( {t('events.qr.foldableBackgroundNote', 'Für A5 Tischkarten bitte Gradient oder Vollfarbe wählen.')} )} {t('events.qr.gradients', 'Gradienten')} {gradientPresets.map((gradient, idx) => { 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; return ( onSelectGradient(gradient)} style={{ width: '30%' }}> ); })} {t('events.qr.colors', 'Vollfarbe')} {solidPresets.map((color) => { const isSelected = selectedSolid === color; return ( onSelectSolid(color)} style={{ width: '20%' }}> ); })} ); } function TextStep({ onBack, textFields, onChange, onSave, 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; saving: boolean; }) { const { t } = useTranslation('management'); 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', val)} size="$4" /> updateField('subtitle', val)} size="$4" />