['x' => 0.08, 'y' => 0.1, 'w' => 0.84, 'h' => 0.12, 'fontSize' => 30, 'fontWeight' => 800, 'align' => 'center'], 'subtitle' => ['x' => 0.1, 'y' => 0.13, 'w' => 0.8, 'h' => 0.08, 'fontSize' => 18, 'fontWeight' => 600, 'align' => 'center'], 'description' => ['x' => 0.1, 'y' => 0.18, 'w' => 0.8, 'h' => 0.12, 'fontSize' => 16, 'lineHeight' => 1.4, 'align' => 'center'], 'qr' => ['x' => 0.35, 'y' => 0.4, 'w' => 0.3, 'h' => 0.3], 'instructions' => ['x' => 0.12, 'y' => 0.72, 'w' => 0.76, 'h' => 0.16, 'fontSize' => 12, 'lineHeight' => 1.3, 'align' => 'center'], ]; private const SLOTS_FOLDABLE = [ 'headline' => ['x' => 0.1, 'y' => 0.1, 'w' => 0.8, 'h' => 0.12, 'fontSize' => 28, 'fontWeight' => 800, 'align' => 'center'], 'subtitle' => ['x' => 0.12, 'y' => 0.18, 'w' => 0.76, 'h' => 0.08, 'fontSize' => 18, 'fontWeight' => 600, 'align' => 'center'], 'description' => ['x' => 0.12, 'y' => 0.24, 'w' => 0.76, 'h' => 0.12, 'fontSize' => 15, 'lineHeight' => 1.4, 'align' => 'center'], 'qr' => ['x' => 0.36, 'y' => 0.4, 'w' => 0.28, 'h' => 0.28], 'instructions' => ['x' => 0.14, 'y' => 0.72, 'w' => 0.72, 'h' => 0.16, 'fontSize' => 12, 'lineHeight' => 1.3, 'align' => 'center'], ]; /** * Layout definitions for printable invite cards. * * @var array */ private const LAYOUTS = [ 'foldable-table-a5' => [ 'id' => 'foldable-table-a5', 'name' => 'Foldable Table Card (A5)', 'subtitle' => 'Doppelseitige Tischkarte zum Falten – QR vorn & hinten.', 'description' => self::DEFAULT_DESCRIPTION, 'paper' => 'a4', 'orientation' => 'landscape', 'panel_mode' => 'double-mirror', 'format_hint' => 'foldable-a5', 'slots' => self::SLOTS_FOLDABLE, 'container_padding_px' => 28, 'background' => '#F8FAFC', 'background_gradient' => [ 'angle' => 180, 'stops' => ['#F8FAFC', '#EEF2FF', '#F8FAFC'], ], 'text' => '#0F172A', 'accent' => '#2563EB', 'secondary' => '#E0E7FF', 'badge' => '#1D4ED8', 'badge_label' => 'Digitale Gästebox', 'instructions_heading' => "So funktioniert's", 'link_heading' => 'Alternative zum Einscannen', 'cta_label' => 'Scan & loslegen', 'cta_caption' => 'Kein Login nötig', 'link_label' => 'fotospiel.app/DEINCODE', 'qr' => ['size_px' => 520], 'svg' => ['width' => 1754, 'height' => 1240], 'instructions' => self::DEFAULT_INSTRUCTIONS, ], 'evergreen-vows' => [ 'id' => 'evergreen-vows', 'name' => 'Evergreen Vows', 'subtitle' => 'Romantische Einladung für Trauung & Empfang.', 'description' => self::DEFAULT_DESCRIPTION, 'paper' => 'a4', 'orientation' => 'portrait', 'format_hint' => 'poster-a4', 'slots' => self::SLOTS_PORTRAIT, 'background' => '#FBF7F2', 'background_gradient' => [ 'angle' => 165, 'stops' => ['#FBF7F2', '#FDECEF', '#F4F0FF'], ], 'text' => '#2C1A27', 'accent' => '#B85C76', 'secondary' => '#E7D6DC', 'badge' => '#7A9375', 'badge_label' => 'Digitale Gästebox', 'instructions_heading' => 'So läuft\'s für eure Gäste', 'link_heading' => 'Kein Scanner? Einfach Kurzlink öffnen', 'link_label' => 'fotospiel.app/DEINCODE', 'cta_label' => 'Fotos & Grüße teilen', 'cta_caption' => 'Sofort starten', 'qr' => ['size_px' => 640], 'svg' => ['width' => 1240, 'height' => 1754], 'instructions' => self::DEFAULT_INSTRUCTIONS, ], 'midnight-gala' => [ 'id' => 'midnight-gala', 'name' => 'Midnight Gala', 'subtitle' => 'Eleganter Auftritt für Corporate Events & Galas.', 'description' => self::DEFAULT_DESCRIPTION, 'paper' => 'a4', 'orientation' => 'portrait', 'format_hint' => 'poster-a4', 'slots' => self::SLOTS_PORTRAIT, 'background' => '#0B132B', 'background_gradient' => [ 'angle' => 200, 'stops' => ['#0B132B', '#1C2541', '#274690'], ], 'text' => '#F8FAFC', 'accent' => '#F9C74F', 'secondary' => '#4E5D8F', 'badge' => '#F94144', 'badge_label' => 'Digitale Gästebox', 'instructions_heading' => 'So läuft\'s für eure Gäste', 'link_heading' => 'Kein Scanner? Einfach Kurzlink öffnen', 'link_label' => 'fotospiel.app/DEINCODE', 'cta_label' => 'Scan & losknipsen', 'cta_caption' => 'Keine App nötig', 'qr' => ['size_px' => 640], 'svg' => ['width' => 1240, 'height' => 1754], 'instructions' => self::DEFAULT_INSTRUCTIONS, ], 'garden-brunch' => [ 'id' => 'garden-brunch', 'name' => 'Garden Brunch', 'subtitle' => 'Luftiges Layout für Tages-Events & Familienfeiern.', 'description' => self::DEFAULT_DESCRIPTION, 'paper' => 'a4', 'orientation' => 'portrait', 'format_hint' => 'poster-a4', 'slots' => self::SLOTS_PORTRAIT, 'background' => '#F6F9F4', 'background_gradient' => [ 'angle' => 120, 'stops' => ['#F6F9F4', '#EEF5E7', '#F8FAF0'], ], 'text' => '#2F4030', 'accent' => '#6BAA75', 'secondary' => '#DDE9D8', 'badge' => '#F1C376', 'badge_label' => 'Digitale Gästebox', 'instructions_heading' => 'So läuft\'s für eure Gäste', 'link_heading' => 'Kein Scanner? Einfach Kurzlink öffnen', 'link_label' => 'fotospiel.app/DEINCODE', 'cta_label' => 'Jetzt Erinnerungen hochladen', 'cta_caption' => 'Los geht’s', 'qr' => ['size_px' => 660], 'svg' => ['width' => 1240, 'height' => 1754], 'instructions' => self::DEFAULT_INSTRUCTIONS, ], 'sparkler-soiree' => [ 'id' => 'sparkler-soiree', 'name' => 'Sparkler Soirée', 'subtitle' => 'Abendliches Layout mit funkelndem Verlauf.', 'description' => self::DEFAULT_DESCRIPTION, 'paper' => 'a4', 'orientation' => 'portrait', 'format_hint' => 'poster-a4', 'slots' => self::SLOTS_PORTRAIT, 'background' => '#1B1A44', 'background_gradient' => [ 'angle' => 205, 'stops' => ['#1B1A44', '#42275A', '#734B8F'], ], 'text' => '#FDF7FF', 'accent' => '#F9A826', 'secondary' => '#DDB7FF', 'badge' => '#FF6F61', 'badge_label' => 'Digitale Gästebox', 'instructions_heading' => 'So läuft\'s für eure Gäste', 'link_heading' => 'Kein Scanner? Einfach Kurzlink öffnen', 'link_label' => 'fotospiel.app/DEINCODE', 'cta_label' => 'Galerie öffnen', 'cta_caption' => 'Challenges spielen', 'qr' => ['size_px' => 680], 'svg' => ['width' => 1240, 'height' => 1754], 'instructions' => self::DEFAULT_INSTRUCTIONS, ], 'confetti-bash' => [ 'id' => 'confetti-bash', 'name' => 'Confetti Bash', 'subtitle' => 'Verspielter Look für Geburtstage & Jubiläen.', 'description' => self::DEFAULT_DESCRIPTION, 'paper' => 'a4', 'orientation' => 'portrait', 'format_hint' => 'poster-a4', 'slots' => self::SLOTS_PORTRAIT, 'background' => '#FFF9F0', 'background_gradient' => [ 'angle' => 145, 'stops' => ['#FFF9F0', '#FFEFEF', '#FFF5D6'], ], 'text' => '#31291F', 'accent' => '#FF6F61', 'secondary' => '#F9D6A5', 'badge' => '#4E88FF', 'badge_label' => 'Digitale Gästebox', 'instructions_heading' => 'So läuft\'s für eure Gäste', 'link_heading' => 'Kein Scanner? Einfach Kurzlink öffnen', 'link_label' => 'fotospiel.app/DEINCODE', 'cta_label' => 'Uploads beginnen', 'cta_caption' => 'Likes vergeben', 'qr' => ['size_px' => 680], 'svg' => ['width' => 1240, 'height' => 1754], 'instructions' => self::DEFAULT_INSTRUCTIONS, ], ]; /** * Get layout definitions. * * @return array> */ public static function all(): array { return array_values(array_map(fn ($layout) => self::normalize($layout), self::LAYOUTS)); } /** * Find a layout definition. */ public static function find(string $id): ?array { $layout = self::LAYOUTS[$id] ?? null; return $layout ? self::normalize($layout) : null; } /** * Normalize and merge default values. */ private static function normalize(array $layout): array { $defaults = [ 'subtitle' => '', 'description' => '', 'paper' => 'a4', 'orientation' => 'portrait', 'panel_mode' => null, 'canvas_width' => null, 'canvas_height' => null, 'container_padding_px' => 48, 'background' => '#F9FAFB', 'text' => '#0F172A', 'accent' => '#6366F1', 'secondary' => '#CBD5F5', 'badge' => '#2563EB', 'badge_label' => 'Digitale Gästebox', 'instructions_heading' => "So funktioniert's", 'link_heading' => 'Alternative zum Einscannen', 'cta_label' => 'Scan mich & starte direkt', 'cta_caption' => 'Scan mich & starte direkt', 'link_label' => null, 'logo_url' => null, 'qr' => [ 'size_px' => 640, ], 'svg' => [ 'width' => 1240, 'height' => 1754, ], 'background_gradient' => null, 'instructions' => self::DEFAULT_INSTRUCTIONS, 'formats' => ['pdf', 'png'], ]; $normalized = array_replace_recursive($defaults, $layout); if (($normalized['canvas_width'] === null || $normalized['canvas_height'] === null) && isset($normalized['svg']['width'], $normalized['svg']['height'])) { $normalized['canvas_width'] = $normalized['canvas_width'] ?? $normalized['svg']['width']; $normalized['canvas_height'] = $normalized['canvas_height'] ?? $normalized['svg']['height']; } $formats = $normalized['formats'] ?? ['pdf', 'png']; if (! is_array($formats)) { $formats = [$formats]; } $normalizedFormats = []; foreach ($formats as $format) { $value = strtolower((string) $format); if ($value === 'svg') { $value = 'png'; } if (in_array($value, ['pdf', 'png'], true) && ! in_array($value, $normalizedFormats, true)) { $normalizedFormats[] = $value; } } $normalized['formats'] = $normalizedFormats ?: ['pdf', 'png']; return $normalized; } private static function defaultSlotsPortrait(): array { return self::SLOTS_PORTRAIT; } private static function defaultSlotsFoldable(): array { return self::SLOTS_FOLDABLE; } private static function resolveFormatHint(?string $paper, ?string $orientation, ?string $panelMode): ?string { $paperVal = strtolower((string) $paper); $orientationVal = strtolower((string) $orientation); $panelVal = strtolower((string) $panelMode); if ($paperVal === 'a4' && $orientationVal === 'portrait' && $panelVal !== 'double-mirror') { return 'poster-a4'; } if ($paperVal === 'a4' && $orientationVal === 'landscape' && $panelVal === 'double-mirror') { return 'foldable-a5'; } return null; } /** * Map layouts into an API-ready response structure, attaching URLs. * * @param callable(string $layoutId, string $format): string $urlResolver * @return array> */ public static function toResponse(callable $urlResolver): array { return array_map(function (array $layout) use ($urlResolver) { $formats = $layout['formats'] ?? ['pdf', 'png']; return [ 'id' => $layout['id'], 'name' => $layout['name'], 'description' => $layout['description'], 'subtitle' => $layout['subtitle'], 'paper' => $layout['paper'] ?? 'a4', 'orientation' => $layout['orientation'] ?? 'portrait', 'panel_mode' => $layout['panel_mode'] ?? null, 'format_hint' => $layout['format_hint'] ?? self::resolveFormatHint($layout['paper'] ?? null, $layout['orientation'] ?? null, $layout['panel_mode'] ?? null), 'canvas_width' => $layout['canvas_width'] ?? ($layout['svg']['width'] ?? null), 'canvas_height' => $layout['canvas_height'] ?? ($layout['svg']['height'] ?? null), 'badge_label' => $layout['badge_label'] ?? null, 'instructions_heading' => $layout['instructions_heading'] ?? null, 'link_heading' => $layout['link_heading'] ?? null, 'cta_label' => $layout['cta_label'] ?? null, 'cta_caption' => $layout['cta_caption'] ?? null, 'instructions' => $layout['instructions'] ?? [], 'slots' => $layout['slots'] ?? null, 'elements' => $layout['elements'] ?? null, 'preview' => [ 'background' => $layout['background'], 'background_gradient' => $layout['background_gradient'], 'accent' => $layout['accent'], 'text' => $layout['text'], 'qr_size_px' => $layout['qr']['size_px'] ?? null, 'aspect_ratio' => isset($layout['svg']['width'], $layout['svg']['height']) && $layout['svg']['width'] && $layout['svg']['height'] ? (float) $layout['svg']['width'] / (float) $layout['svg']['height'] : null, ], 'formats' => $formats, 'download_urls' => collect($formats) ->mapWithKeys(fn ($format) => [$format => $urlResolver($layout['id'], $format)]) ->all(), ]; }, self::all()); } }