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 { Input, TextArea } from 'tamagui'; 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 { 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-goldframe', src: '/storage/layouts/backgrounds-portrait/bg-goldframe.png', labelKey: 'events.qr.backgroundPresets.goldFrame', label: 'Gold Frame', }, { 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 = 'Fraunces'; 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 } = 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' }, subtitle: { x: 0.1, y: 0.21, w: 0.8, fontSize: 37, fontWeight: 600, fontFamily: DEFAULT_BODY_FONT, align: 'center' }, description: { x: 0.1, y: 0.66, w: 0.8, fontSize: 26, fontFamily: DEFAULT_BODY_FONT, lineHeight: 1.4, align: 'center' }, 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.15, w: 0.84, fontSize: 50, fontWeight: 800, fontFamily: DEFAULT_DISPLAY_FONT, align: 'center' }, subtitle: { x: 0.1, y: 0.21, w: 0.8, fontSize: 37, fontWeight: 600, fontFamily: DEFAULT_BODY_FONT, align: 'center' }, description: { x: 0.1, y: 0.66, w: 0.8, fontSize: 13, fontFamily: DEFAULT_BODY_FONT, lineHeight: 1.4, align: 'center' }, instructions: { x: 0.12, y: 0.36, w: 0.76, fontSize: 13, fontFamily: DEFAULT_BODY_FONT, lineHeight: 1.3 }, qr: { x: 0.3, y: 0.3, w: 0.28 }, } : 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, 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; slotOverrides?: SlotOverrides; }) { const scaleX = CANVAS_WIDTH / baseWidth; const scaleY = CANVAS_HEIGHT / baseHeight; const panelWidth = isFoldable ? baseWidth / 2 : baseWidth; const panelHeight = baseHeight; const slots = resolveSlots(layout, isFoldable, slotOverrides); const elements: LayoutElement[] = []; const textColor = layout?.preview?.text ?? ADMIN_COLORS.backdrop; const accentColor = layout?.preview?.accent ?? ADMIN_COLORS.primary; const secondaryColor = layout?.preview?.secondary ?? ADMIN_COLORS.text; const badgeColor = layout?.preview?.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, 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 = isFoldable; 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: '48%' }}> {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.')} ) : ( {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%' }} aria-label={t(gradient.labelKey, gradient.label)} > ); })} {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" />