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: 900, height: 240, fontSize: 82, align: 'left' }, subtitle: { width: 760, height: 170, fontSize: 40, align: 'left' }, description: { width: 920, height: 340, fontSize: 32, align: 'left' }, link: { width: 520, height: 130, fontSize: 30, align: 'center' }, badge: { width: 420, height: 100, fontSize: 26, align: 'center' }, logo: { width: 320, height: 220, align: 'center' }, cta: { width: 520, height: 130, fontSize: 28, align: 'center' }, qr: { width: 640, height: 640 }, text: { width: 720, height: 260, fontSize: 28, align: 'left' }, }; const DEFAULT_PRESET: LayoutPreset = [ { id: 'badge', type: 'badge', x: 140, y: 160, width: 440, height: 100, align: 'center', fontSize: 28 }, { id: 'headline', type: 'headline', x: 140, y: 300, width: (context) => context.canvasWidth - 280, height: 240, fontSize: 84, align: 'left', }, { id: 'subtitle', type: 'subtitle', x: 140, y: 560, width: (context) => context.canvasWidth - 280, height: 170, fontSize: 42, align: 'left', }, { id: 'description', type: 'description', x: 140, y: 750, width: (context) => context.canvasWidth - 280, height: 340, fontSize: 32, align: 'left', }, { id: 'qr', type: 'qr', x: (context) => context.canvasWidth - Math.min(context.qrSize, 680) - 180, y: 360, width: (context) => Math.min(context.qrSize, 680), height: (context) => Math.min(context.qrSize, 680), }, { id: 'link', type: 'link', x: (context) => context.canvasWidth - 540, y: (context) => 420 + Math.min(context.qrSize, 680), width: 520, height: 130, fontSize: 28, align: 'center', }, { id: 'cta', type: 'cta', x: (context) => context.canvasWidth - 540, y: (context) => 460 + Math.min(context.qrSize, 680) + 160, width: 520, height: 130, fontSize: 30, align: 'center', }, ]; const evergreenVowsPreset: LayoutPreset = [ { id: 'logo', type: 'logo', x: 160, y: 140, width: 340, height: 240 }, { id: 'badge', type: 'badge', x: 540, y: 160, width: 420, height: 100, align: 'center', fontSize: 28 }, { id: 'headline', type: 'headline', x: 160, y: 360, width: (context) => context.canvasWidth - 320, height: 250, fontSize: 86, align: 'left', }, { id: 'subtitle', type: 'subtitle', x: 160, y: 630, width: (context) => context.canvasWidth - 320, height: 180, fontSize: 42, align: 'left', }, { id: 'description', type: 'description', x: 160, y: 840, width: (context) => context.canvasWidth - 320, height: 360, fontSize: 34, align: 'left', }, { id: 'qr', type: 'qr', x: (context) => context.canvasWidth - Math.min(context.qrSize, 640) - 200, y: 420, width: (context) => Math.min(context.qrSize, 640), height: (context) => Math.min(context.qrSize, 640), }, { id: 'link', type: 'link', x: (context) => context.canvasWidth - 560, y: (context) => 480 + Math.min(context.qrSize, 640), width: 520, height: 130, align: 'center', }, { id: 'cta', type: 'cta', x: (context) => context.canvasWidth - 560, y: (context) => 520 + Math.min(context.qrSize, 640) + 180, width: 520, height: 130, align: 'center', }, ]; const midnightGalaPreset: LayoutPreset = [ { id: 'badge', type: 'badge', x: (context) => context.canvasWidth / 2 - 300, y: 180, width: 600, height: 120, align: 'center', fontSize: 32 }, { id: 'headline', type: 'headline', x: (context) => context.canvasWidth / 2 - (context.canvasWidth - 220) / 2, y: 340, width: (context) => context.canvasWidth - 220, height: 260, fontSize: 90, align: 'center', }, { id: 'subtitle', type: 'subtitle', x: (context) => context.canvasWidth / 2 - (context.canvasWidth - 320) / 2, y: 640, width: (context) => context.canvasWidth - 320, height: 200, fontSize: 46, align: 'center', }, { id: 'qr', type: 'qr', x: (context) => (context.canvasWidth - Math.min(context.qrSize, 640)) / 2, y: 880, width: (context) => Math.min(context.qrSize, 640), height: (context) => Math.min(context.qrSize, 640), }, { id: 'link', type: 'link', x: (context) => (context.canvasWidth - 560) / 2, y: (context) => 940 + Math.min(context.qrSize, 640), width: 560, height: 140, align: 'center', }, { id: 'cta', type: 'cta', x: (context) => (context.canvasWidth - 560) / 2, y: (context) => 980 + Math.min(context.qrSize, 640) + 200, width: 560, height: 140, align: 'center', }, { id: 'description', type: 'description', x: (context) => context.canvasWidth / 2 - (context.canvasWidth - 240) / 2, y: 1250, width: (context) => context.canvasWidth - 240, height: 360, fontSize: 34, align: 'center', }, ]; const gardenBrunchPreset: LayoutPreset = [ { id: 'badge', type: 'badge', x: 180, y: 180, width: 500, height: 110, align: 'center', fontSize: 30 }, { id: 'headline', type: 'headline', x: 180, y: 340, width: (context) => context.canvasWidth - 360, height: 260, fontSize: 86, align: 'left' }, { id: 'description', type: 'description', x: 180, y: 630, width: (context) => context.canvasWidth - 360, height: 360, fontSize: 34, align: 'left' }, { id: 'qr', type: 'qr', x: 180, y: 1000, width: (context) => Math.min(context.qrSize, 660), height: (context) => Math.min(context.qrSize, 660), }, { id: 'link', type: 'link', x: 180, y: (context) => 1060 + Math.min(context.qrSize, 660), width: 520, height: 140, align: 'center', }, { id: 'cta', type: 'cta', x: 180, y: (context) => 1100 + Math.min(context.qrSize, 660) + 190, width: 520, height: 140, align: 'center', }, { id: 'subtitle', type: 'subtitle', x: (context) => context.canvasWidth - 460, y: 360, width: 420, height: 200, fontSize: 38, align: 'left' }, { id: 'text-strip', type: 'text', x: (context) => context.canvasWidth - 460, y: 620, width: 420, height: 360, fontSize: 28, align: 'left' }, ]; const sparklerSoireePreset: LayoutPreset = [ { id: 'badge', type: 'badge', x: (context) => context.canvasWidth / 2 - 320, y: 200, width: 640, height: 120, align: 'center', fontSize: 32 }, { id: 'headline', type: 'headline', x: (context) => context.canvasWidth / 2 - (context.canvasWidth - 220) / 2, y: 360, width: (context) => context.canvasWidth - 220, height: 280, fontSize: 94, align: 'center', }, { id: 'subtitle', type: 'subtitle', x: (context) => context.canvasWidth / 2 - (context.canvasWidth - 320) / 2, y: 660, width: (context) => context.canvasWidth - 320, height: 210, fontSize: 46, align: 'center', }, { id: 'description', type: 'description', x: (context) => context.canvasWidth / 2 - (context.canvasWidth - 320) / 2, y: 920, width: (context) => context.canvasWidth - 320, height: 380, fontSize: 34, align: 'center', }, { id: 'qr', type: 'qr', x: (context) => (context.canvasWidth - Math.min(context.qrSize, 680)) / 2, y: 1200, width: (context) => Math.min(context.qrSize, 680), height: (context) => Math.min(context.qrSize, 680), }, { id: 'link', type: 'link', x: (context) => (context.canvasWidth - 580) / 2, y: (context) => 1260 + Math.min(context.qrSize, 680), width: 580, height: 150, align: 'center', }, { id: 'cta', type: 'cta', x: (context) => (context.canvasWidth - 580) / 2, y: (context) => 1300 + Math.min(context.qrSize, 680) + 200, width: 580, height: 150, align: 'center', }, ]; const confettiBashPreset: LayoutPreset = [ { id: 'badge', type: 'badge', x: 180, y: 220, width: 520, height: 120, align: 'center', fontSize: 32 }, { id: 'headline', type: 'headline', x: 180, y: 380, width: (context) => context.canvasWidth - 360, height: 260, fontSize: 90, align: 'left', }, { id: 'subtitle', type: 'subtitle', x: 180, y: 660, width: (context) => context.canvasWidth - 360, height: 200, fontSize: 46, align: 'left', }, { id: 'description', type: 'description', x: 180, y: 910, width: (context) => context.canvasWidth - 360, height: 360, fontSize: 34, align: 'left', }, { id: 'qr', type: 'qr', x: (context) => context.canvasWidth - Math.min(context.qrSize, 680) - 200, y: 460, width: (context) => Math.min(context.qrSize, 680), height: (context) => Math.min(context.qrSize, 680), }, { id: 'link', type: 'link', x: (context) => context.canvasWidth - 560, y: (context) => 520 + Math.min(context.qrSize, 680), width: 520, height: 140, align: 'center', }, { id: 'cta', type: 'cta', x: (context) => context.canvasWidth - 560, y: (context) => 560 + Math.min(context.qrSize, 680) + 200, width: 520, height: 140, align: 'center', }, { id: 'text-strip', type: 'text', x: 180, y: 1220, width: (context) => context.canvasWidth - 360, height: 360, fontSize: 30, 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); }