Files
fotospiel-app/resources/js/admin/pages/components/invite-layout/schema.ts
2025-10-31 20:19:09 +01:00

531 lines
16 KiB
TypeScript

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<LayoutElementType, { width: number; height: number; fontSize?: number; align?: LayoutTextAlign; locked?: boolean }> = {
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<string, LayoutPreset> = {
'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<string, string | null> = {
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<string>();
return elements
.filter((element) => {
if (!element.id) {
return false;
}
if (seen.has(element.id)) {
return false;
}
seen.add(element.id);
return true;
})
.map(clampElement);
}