Files
fotospiel-app/app/Support/JoinTokenLayoutRegistry.php
2025-12-12 08:34:19 +01:00

425 lines
17 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Support;
use App\Models\InviteLayout;
class JoinTokenLayoutRegistry
{
private const DEFAULT_DESCRIPTION = 'Helft uns, diesen besonderen Tag mit euren schönen Momenten festzuhalten.';
private const DEFAULT_INSTRUCTIONS = [
'QR-Code scannen'."\n".'Kamera-App öffnen und auf den Code richten.',
'Webseite öffnen'."\n".'Der Link öffnet direkt das gemeinsame Jubiläumsalbum.',
'Fotos hochladen'."\n".'Zeigt eure Lieblingsmomente oder erfüllt kleine Fotoaufgaben, um besondere Erinnerungen beizusteuern.',
];
private const SLOTS_PORTRAIT = [
'headline' => ['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<string, 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 gehts',
'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<int, array<string, mixed>>
*/
public static function all(): array
{
$customLayouts = InviteLayout::query()
->where('is_active', true)
->orderBy('name')
->get();
if ($customLayouts->isNotEmpty()) {
return $customLayouts
->map(fn (InviteLayout $layout) => self::normalize(self::fromModel($layout)))
->values()
->all();
}
return array_values(array_map(fn ($layout) => self::normalize($layout), self::LAYOUTS));
}
/**
* Find a layout definition.
*/
public static function find(string $id): ?array
{
$custom = InviteLayout::query()
->where('slug', $id)
->where('is_active', true)
->first();
if ($custom) {
return self::normalize(self::fromModel($custom));
}
$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,
'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);
$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 fromModel(InviteLayout $layout): array
{
$preview = $layout->preview ?? [];
$options = $layout->layout_options ?? [];
$instructions = $layout->instructions ?? [];
$slots = $options['slots'] ?? null;
return array_filter([
'id' => $layout->slug,
'name' => $layout->name,
'subtitle' => $layout->subtitle,
'description' => $layout->description,
'paper' => $layout->paper,
'orientation' => $layout->orientation,
'format_hint' => self::resolveFormatHint($layout->paper, $layout->orientation, $layout->panel_mode),
'background' => $preview['background'] ?? null,
'background_gradient' => $preview['background_gradient'] ?? null,
'text' => $preview['text'] ?? null,
'accent' => $preview['accent'] ?? null,
'secondary' => $preview['secondary'] ?? null,
'badge' => $preview['badge'] ?? null,
'badge_label' => $options['badge_label'] ?? null,
'instructions_heading' => $options['instructions_heading'] ?? null,
'link_heading' => $options['link_heading'] ?? null,
'cta_label' => $options['cta_label'] ?? null,
'cta_caption' => $options['cta_caption'] ?? null,
'link_label' => $options['link_label'] ?? null,
'logo_url' => $options['logo_url'] ?? null,
'slots' => is_array($slots) ? $slots : null,
'qr' => array_filter([
'size_px' => $preview['qr']['size_px'] ?? $options['qr']['size_px'] ?? $preview['qr_size_px'] ?? $options['qr_size_px'] ?? null,
]),
'svg' => array_filter([
'width' => $preview['svg']['width'] ?? $options['svg']['width'] ?? $preview['svg_width'] ?? $options['svg_width'] ?? null,
'height' => $preview['svg']['height'] ?? $options['svg']['height'] ?? $preview['svg_height'] ?? $options['svg_height'] ?? null,
]),
'formats' => $options['formats'] ?? ['pdf', 'png'],
'instructions' => $instructions,
], fn ($value) => $value !== null && $value !== []);
}
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<int, array<string, mixed>>
*/
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),
'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,
'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());
}
}