import type { EventQrInviteLayout } from '../../api'; export const CANVAS_WIDTH = 1240; export const CANVAS_HEIGHT = 1754; export type LayoutElementType = | 'qr' | 'headline' | 'subtitle' | 'description' | 'link' | 'badge' | 'logo' | 'cta' | 'text'; export type LayoutTextAlign = 'left' | 'center' | 'right'; export interface LayoutElement { id: string; type: LayoutElementType; x: number; y: number; width: number; height: number; rotation?: number; fontSize?: number; align?: LayoutTextAlign; content?: string | null; fontFamily?: string | null; letterSpacing?: number; lineHeight?: number; fill?: string | null; locked?: boolean; initial?: boolean; } type PresetValue = number | ((context: LayoutPresetContext) => number); type LayoutPresetElement = { id: string; type: LayoutElementType; x: PresetValue; y: PresetValue; width?: PresetValue; height?: PresetValue; fontSize?: number; align?: LayoutTextAlign; locked?: boolean; initial?: boolean; }; type LayoutPreset = LayoutPresetElement[]; interface LayoutPresetContext { qrSize: number; canvasWidth: number; canvasHeight: number; } export interface LayoutElementPayload { id: string; type: LayoutElementType; x: number; y: number; width: number; height: number; rotation?: number; font_size?: number; align?: LayoutTextAlign; content?: string | null; font_family?: string | null; letter_spacing?: number; line_height?: number; fill?: string | null; locked?: boolean; initial?: boolean; } export interface LayoutSerializationContext { form: QrLayoutCustomization; eventName: string; inviteUrl: string; instructions: string[]; qrSize: number; badgeFallback: string; logoUrl: string | null; } export type QrLayoutCustomization = { layout_id?: string; headline?: string; subtitle?: string; description?: string; badge_label?: string; instructions_heading?: string; instructions?: string[]; link_heading?: string; link_label?: string; cta_label?: string; accent_color?: string; text_color?: string; background_color?: string; secondary_color?: string; badge_color?: string; background_gradient?: { angle?: number; stops?: string[] } | null; logo_data_url?: string | null; logo_url?: string | null; mode?: 'standard' | 'advanced'; elements?: LayoutElementPayload[]; }; export const MIN_QR_SIZE = 240; export const MAX_QR_SIZE = 720; export const MIN_TEXT_WIDTH = 160; export const MIN_TEXT_HEIGHT = 80; export function clamp(value: number, min: number, max: number): number { if (Number.isNaN(value)) { return min; } return Math.min(Math.max(value, min), max); } export function clampElement(element: LayoutElement): LayoutElement { return { ...element, x: clamp(element.x, 0, CANVAS_WIDTH - element.width), y: clamp(element.y, 0, CANVAS_HEIGHT - element.height), width: clamp(element.width, 40, CANVAS_WIDTH), height: clamp(element.height, 40, CANVAS_HEIGHT), }; } const DEFAULT_TYPE_STYLES: Record = { headline: { width: 620, height: 200, fontSize: 68, align: 'left' }, subtitle: { width: 580, height: 140, fontSize: 34, align: 'left' }, description: { width: 620, height: 280, fontSize: 28, align: 'left' }, link: { width: 400, height: 110, fontSize: 28, align: 'center' }, badge: { width: 280, height: 80, fontSize: 24, align: 'center' }, logo: { width: 240, height: 180, align: 'center' }, cta: { width: 400, height: 110, fontSize: 26, align: 'center' }, qr: { width: 520, height: 520 }, text: { width: 560, height: 200, fontSize: 26, align: 'left' }, }; const DEFAULT_PRESET: LayoutPreset = [ { id: 'badge', type: 'badge', x: 120, y: 140, width: 320, height: 80, align: 'center', fontSize: 24 }, { id: 'headline', type: 'headline', x: 120, y: 260, width: 620, height: 200, fontSize: 68, align: 'left' }, { id: 'subtitle', type: 'subtitle', x: 120, y: 440, width: 600, height: 140, fontSize: 34, align: 'left' }, { id: 'description', type: 'description', x: 120, y: 600, width: 620, height: 280, fontSize: 28, align: 'left' }, { id: 'qr', type: 'qr', x: (context) => context.canvasWidth - context.qrSize - 140, y: 360, width: (context) => context.qrSize, height: (context) => context.qrSize, }, { id: 'link', type: 'link', x: (context) => context.canvasWidth - 420, y: (context) => 400 + context.qrSize, width: 400, height: 110, fontSize: 28, align: 'center', }, { id: 'cta', type: 'cta', x: (context) => context.canvasWidth - 420, y: (context) => 420 + context.qrSize + 140, width: 400, height: 110, fontSize: 26, align: 'center', }, ]; const evergreenVowsPreset: LayoutPreset = [ { id: 'logo', type: 'logo', x: 120, y: 140, width: 240, height: 180 }, { id: 'badge', type: 'badge', x: 400, y: 160, width: 320, height: 80, align: 'center', fontSize: 24 }, { id: 'headline', type: 'headline', x: 120, y: 360, width: 620, height: 220, fontSize: 70, align: 'left' }, { id: 'subtitle', type: 'subtitle', x: 120, y: 560, width: 600, height: 140, fontSize: 34, align: 'left' }, { id: 'description', type: 'description', x: 120, y: 720, width: 620, height: 280, fontSize: 28, align: 'left' }, { id: 'qr', type: 'qr', x: (context) => context.canvasWidth - context.qrSize - 160, y: 460, width: (context) => context.qrSize, height: (context) => context.qrSize, }, { id: 'link', type: 'link', x: (context) => context.canvasWidth - 420, y: (context) => 500 + context.qrSize, width: 400, height: 110, align: 'center', }, { id: 'cta', type: 'cta', x: (context) => context.canvasWidth - 420, y: (context) => 520 + context.qrSize + 150, width: 400, height: 110, align: 'center', }, ]; const midnightGalaPreset: LayoutPreset = [ { id: 'badge', type: 'badge', x: 360, y: 160, width: 520, height: 90, align: 'center', fontSize: 26 }, { id: 'headline', type: 'headline', x: 220, y: 300, width: 800, height: 220, fontSize: 76, align: 'center' }, { id: 'subtitle', type: 'subtitle', x: 260, y: 520, width: 720, height: 140, fontSize: 36, align: 'center' }, { id: 'qr', type: 'qr', x: (context) => (context.canvasWidth - context.qrSize) / 2, y: 700, width: (context) => context.qrSize, height: (context) => context.qrSize, }, { id: 'link', type: 'link', x: (context) => (context.canvasWidth - 420) / 2, y: (context) => 740 + context.qrSize, width: 420, height: 120, align: 'center', }, { id: 'cta', type: 'cta', x: (context) => (context.canvasWidth - 420) / 2, y: (context) => 770 + context.qrSize + 150, width: 420, height: 120, align: 'center', }, { id: 'description', type: 'description', x: 200, y: 1040, width: 840, height: 260, fontSize: 28, align: 'center' }, ]; const gardenBrunchPreset: LayoutPreset = [ { id: 'badge', type: 'badge', x: 160, y: 160, width: 360, height: 80, align: 'center', fontSize: 24 }, { id: 'headline', type: 'headline', x: 160, y: 300, width: 560, height: 200, fontSize: 66, align: 'left' }, { id: 'description', type: 'description', x: 160, y: 520, width: 560, height: 260, fontSize: 28, align: 'left' }, { id: 'qr', type: 'qr', x: 160, y: 840, width: (context) => Math.min(context.qrSize, 520), height: (context) => Math.min(context.qrSize, 520), }, { id: 'link', type: 'link', x: 160, y: (context) => 880 + Math.min(context.qrSize, 520), width: 420, height: 110, align: 'center', }, { id: 'cta', type: 'cta', x: 160, y: (context) => 910 + Math.min(context.qrSize, 520) + 140, width: 420, height: 110, align: 'center', }, { id: 'subtitle', type: 'subtitle', x: 780, y: 320, width: 320, height: 140, fontSize: 32, align: 'left' }, { id: 'text-strip', type: 'text', x: 780, y: 480, width: 320, height: 320, fontSize: 24, align: 'left' }, ]; const sparklerSoireePreset: LayoutPreset = [ { id: 'badge', type: 'badge', x: 360, y: 150, width: 520, height: 90, align: 'center', fontSize: 26 }, { id: 'headline', type: 'headline', x: 200, y: 300, width: 840, height: 220, fontSize: 72, align: 'center' }, { id: 'subtitle', type: 'subtitle', x: 260, y: 520, width: 720, height: 140, fontSize: 34, align: 'center' }, { id: 'description', type: 'description', x: 220, y: 680, width: 800, height: 240, fontSize: 28, align: 'center' }, { id: 'qr', type: 'qr', x: (context) => (context.canvasWidth - context.qrSize) / 2, y: 960, width: (context) => context.qrSize, height: (context) => context.qrSize, }, { id: 'link', type: 'link', x: (context) => (context.canvasWidth - 420) / 2, y: (context) => 1000 + context.qrSize, width: 420, height: 110, align: 'center', }, { id: 'cta', type: 'cta', x: (context) => (context.canvasWidth - 420) / 2, y: (context) => 1030 + context.qrSize + 140, width: 420, height: 110, align: 'center', }, ]; const confettiBashPreset: LayoutPreset = [ { id: 'badge', type: 'badge', x: 140, y: 180, width: 360, height: 90, align: 'center', fontSize: 24 }, { id: 'headline', type: 'headline', x: 140, y: 320, width: 520, height: 220, fontSize: 68, align: 'left' }, { id: 'subtitle', type: 'subtitle', x: 140, y: 520, width: 520, height: 140, fontSize: 34, align: 'left' }, { id: 'description', type: 'description', x: 140, y: 680, width: 520, height: 240, fontSize: 26, align: 'left' }, { id: 'qr', type: 'qr', x: (context) => context.canvasWidth - context.qrSize - 200, y: 360, width: (context) => context.qrSize, height: (context) => context.qrSize, }, { id: 'link', type: 'link', x: (context) => context.canvasWidth - 420, y: (context) => 400 + context.qrSize, width: 400, height: 110, align: 'center', }, { id: 'cta', type: 'cta', x: (context) => context.canvasWidth - 420, y: (context) => 430 + context.qrSize + 140, width: 400, height: 110, align: 'center', }, { id: 'text-strip', type: 'text', x: 140, y: 960, width: 860, height: 220, fontSize: 26, align: 'left' }, ]; const LAYOUT_PRESETS: Record = { 'default': DEFAULT_PRESET, 'evergreen-vows': evergreenVowsPreset, 'midnight-gala': midnightGalaPreset, 'garden-brunch': gardenBrunchPreset, 'sparkler-soiree': sparklerSoireePreset, 'confetti-bash': confettiBashPreset, }; function resolvePresetValue(value: PresetValue | undefined, context: LayoutPresetContext, fallback: number): number { if (typeof value === 'function') { const resolved = value(context); return typeof resolved === 'number' ? resolved : fallback; } if (typeof value === 'number') { return value; } return fallback; } export function buildDefaultElements( layout: EventQrInviteLayout, form: QrLayoutCustomization, eventName: string, qrSize: number ): LayoutElement[] { const size = clamp(qrSize, MIN_QR_SIZE, MAX_QR_SIZE); const context: LayoutPresetContext = { qrSize: size, canvasWidth: CANVAS_WIDTH, canvasHeight: CANVAS_HEIGHT, }; const preset = LAYOUT_PRESETS[layout.id ?? ''] ?? DEFAULT_PRESET; const instructionsHeading = form.instructions_heading ?? layout.instructions_heading ?? "So funktioniert's"; const instructionsList = Array.isArray(form.instructions) && form.instructions.length ? form.instructions : (layout.instructions ?? []); const baseContent: Record = { headline: form.headline ?? eventName, subtitle: form.subtitle ?? layout.subtitle ?? '', description: form.description ?? layout.description ?? '', badge: form.badge_label ?? layout.badge_label ?? 'Digitale Gästebox', link: form.link_label ?? '', cta: form.cta_label ?? layout.cta_label ?? 'Scan mich & starte direkt', instructions_heading: instructionsHeading, instructions_text: instructionsList[0] ?? null, }; const elements = preset.map((config) => { const typeStyle = DEFAULT_TYPE_STYLES[config.type] ?? { width: 420, height: 160 }; const widthFallback = config.type === 'qr' ? size : typeStyle.width; const heightFallback = config.type === 'qr' ? size : typeStyle.height; const element: LayoutElement = { id: config.id, type: config.type, x: resolvePresetValue(config.x, context, 0), y: resolvePresetValue(config.y, context, 0), width: resolvePresetValue(config.width, context, widthFallback), height: resolvePresetValue(config.height, context, heightFallback), fontSize: config.fontSize ?? typeStyle.fontSize, align: config.align ?? typeStyle.align ?? 'left', content: null, locked: config.locked ?? typeStyle.locked ?? false, initial: config.initial ?? true, }; if (config.type === 'description') { element.lineHeight = 1.4; } switch (config.id) { case 'headline': element.content = baseContent.headline; break; case 'subtitle': element.content = baseContent.subtitle; break; case 'description': element.content = baseContent.description; break; case 'badge': element.content = baseContent.badge; break; case 'link': element.content = baseContent.link; break; case 'cta': element.content = baseContent.cta; break; case 'text-strip': element.content = instructionsList.join('\n').trim() || layout.description || 'Nutze diesen Bereich für zusätzliche Hinweise oder Storytelling.'; break; case 'logo': element.content = form.logo_data_url ?? form.logo_url ?? layout.logo_url ?? null; break; default: if (config.type === 'text') { element.content = element.content ?? 'Individualisiere diesen Textblock mit eigenen Infos.'; } break; } if (config.type === 'qr') { element.locked = false; } const clamped = clampElement(element); return { ...clamped, initial: element.initial ?? true, }; }); return elements; } export function payloadToElements(payload?: LayoutElementPayload[] | null): LayoutElement[] { if (!Array.isArray(payload)) { return []; } return payload.map((entry) => clampElement({ id: entry.id, type: entry.type, x: Number(entry.x ?? 0), y: Number(entry.y ?? 0), width: Number(entry.width ?? 100), height: Number(entry.height ?? 100), rotation: typeof entry.rotation === 'number' ? entry.rotation : 0, fontSize: typeof entry.font_size === 'number' ? entry.font_size : undefined, align: entry.align ?? 'left', content: entry.content ?? null, fontFamily: entry.font_family ?? null, letterSpacing: entry.letter_spacing ?? undefined, lineHeight: entry.line_height ?? undefined, fill: entry.fill ?? null, locked: Boolean(entry.locked), initial: Boolean(entry.initial), }) ); } export function elementsToPayload(elements: LayoutElement[]): LayoutElementPayload[] { return elements.map((element) => ({ id: element.id, type: element.type, x: element.x, y: element.y, width: element.width, height: element.height, rotation: element.rotation ?? 0, font_size: element.fontSize, align: element.align, content: element.content ?? null, font_family: element.fontFamily ?? null, letter_spacing: element.letterSpacing, line_height: element.lineHeight, fill: element.fill ?? null, locked: element.locked ?? false, initial: element.initial ?? false, })); } export function normalizeElements(elements: LayoutElement[]): LayoutElement[] { const seen = new Set(); return elements .filter((element) => { if (!element.id) { return false; } if (seen.has(element.id)) { return false; } seen.add(element.id); return true; }) .map(clampElement); }