Implement package limit notification system

This commit is contained in:
Codex Agent
2025-11-01 13:19:07 +01:00
parent 81cdee428e
commit 2c14493604
87 changed files with 4557 additions and 290 deletions

View File

@@ -34,19 +34,21 @@ import type { EventQrInvite, EventQrInviteLayout } from '../../api';
import { authorizedFetch } from '../../auth/tokens';
import {
CANVAS_HEIGHT,
CANVAS_WIDTH,
QrLayoutCustomization,
LayoutElement,
LayoutElementPayload,
LayoutElementType,
LayoutSerializationContext,
buildDefaultElements,
clamp,
clampElement,
elementsToPayload,
normalizeElements,
payloadToElements,
} from './invite-layout/schema';
import { DesignerCanvas } from './invite-layout/DesignerCanvas';
import { CANVAS_HEIGHT, CANVAS_WIDTH } from './invite-layout/schema';
import {
generatePdfBytes,
generatePngDataUrl,
@@ -181,6 +183,9 @@ type InviteLayoutCustomizerPanelProps = {
};
const MAX_INSTRUCTIONS = 5;
const ZOOM_MIN = 0.1;
const ZOOM_MAX = 2;
const ZOOM_STEP = 0.05;
export function InviteLayoutCustomizerPanel({
invite,
@@ -213,6 +218,10 @@ export function InviteLayoutCustomizerPanel({
const [elements, setElements] = React.useState<LayoutElement[]>([]);
const [activeElementId, setActiveElementId] = React.useState<string | null>(null);
const [showFloatingActions, setShowFloatingActions] = React.useState(false);
const [zoomScale, setZoomScale] = React.useState(1);
const [fitScale, setFitScale] = React.useState(1);
const fitScaleRef = React.useRef(1);
const manualZoomRef = React.useRef(false);
const actionsSentinelRef = React.useRef<HTMLDivElement | null>(null);
const historyRef = React.useRef<LayoutElement[][]>([]);
const historyIndexRef = React.useRef(-1);
@@ -223,6 +232,83 @@ export function InviteLayoutCustomizerPanel({
const canvasContainerRef = React.useRef<HTMLDivElement | null>(null);
const isAdvanced = true;
const clampZoom = React.useCallback(
(value: number) => clamp(Number.isFinite(value) ? value : 1, ZOOM_MIN, ZOOM_MAX),
[],
);
const recomputeFitScale = React.useCallback(() => {
const viewport = designerViewportRef.current;
if (!viewport) {
return;
}
const { clientWidth, clientHeight } = viewport;
if (!clientWidth || !clientHeight) {
return;
}
const style = window.getComputedStyle(viewport);
const paddingX = parseFloat(style.paddingLeft ?? '0') + parseFloat(style.paddingRight ?? '0');
const paddingY = parseFloat(style.paddingTop ?? '0') + parseFloat(style.paddingBottom ?? '0');
const availableWidth = clientWidth - paddingX;
const availableHeight = clientHeight - paddingY;
if (availableWidth <= 0 || availableHeight <= 0) {
return;
}
const widthScale = availableWidth / CANVAS_WIDTH;
const heightScale = availableHeight / CANVAS_HEIGHT;
const nextRaw = Math.min(widthScale, heightScale);
const baseScale = Number.isFinite(nextRaw) && nextRaw > 0 ? Math.min(nextRaw, 1) : 1;
const clamped = clampZoom(baseScale);
fitScaleRef.current = clamped;
setFitScale((prev) => (Math.abs(prev - clamped) < 0.001 ? prev : clamped));
if (!manualZoomRef.current) {
setZoomScale((prev) => (Math.abs(prev - clamped) < 0.001 ? prev : clamped));
}
console.debug('[Invites][Zoom] viewport size', {
availableWidth,
availableHeight,
widthScale,
heightScale,
clamped,
});
}, [clampZoom]);
React.useLayoutEffect(() => {
recomputeFitScale();
}, [recomputeFitScale]);
React.useEffect(() => {
const viewport = designerViewportRef.current;
const handleResize = () => {
recomputeFitScale();
};
window.addEventListener('resize', handleResize);
let observer: ResizeObserver | null = null;
if (viewport && typeof ResizeObserver === 'function') {
observer = new ResizeObserver(() => recomputeFitScale());
observer.observe(viewport);
}
recomputeFitScale();
return () => {
window.removeEventListener('resize', handleResize);
if (observer) {
observer.disconnect();
}
};
}, [recomputeFitScale]);
const cloneElements = React.useCallback(
(items: LayoutElement[]): LayoutElement[] => items.map((item) => ({ ...item })),
[]
@@ -355,6 +441,11 @@ export function InviteLayoutCustomizerPanel({
return availableLayouts[0];
}, [availableLayouts, selectedLayoutId]);
React.useEffect(() => {
manualZoomRef.current = false;
recomputeFitScale();
}, [recomputeFitScale, activeLayout?.id, invite?.id]);
const activeLayoutQrSize = React.useMemo(() => {
const qrElement = elements.find((element) => element.type === 'qr');
if (qrElement && typeof qrElement.width === 'number' && qrElement.width > 0) {
@@ -371,6 +462,12 @@ export function InviteLayoutCustomizerPanel({
return activeLayout?.preview?.qr_size_px ?? 500;
}, [elements, initialCustomization?.mode, initialCustomization?.elements, activeLayout?.preview?.qr_size_px]);
const effectiveScale = React.useMemo(
() => clampZoom(Number.isFinite(zoomScale) && zoomScale > 0 ? zoomScale : fitScale),
[clampZoom, zoomScale, fitScale],
);
const zoomPercent = Math.round(effectiveScale * 100);
const updateElement = React.useCallback(
(id: string, updater: Partial<LayoutElement> | ((element: LayoutElement) => Partial<LayoutElement>), options?: { silent?: boolean }) => {
commitElements(
@@ -1702,27 +1799,62 @@ export function InviteLayoutCustomizerPanel({
<div ref={actionsSentinelRef} className="h-1 w-full" />
</form>
<div className="flex flex-col gap-4 rounded-2xl border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface)] p-5 shadow-sm transition-colors">
<div className="flex flex-wrap items-center justify-end gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={handleUndo}
disabled={!canUndo}
>
<Undo2 className="mr-1 h-4 w-4" />
{t('invites.customizer.actions.undo', 'Rückgängig')}
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleRedo}
disabled={!canRedo}
>
<Redo2 className="mr-1 h-4 w-4" />
{t('invites.customizer.actions.redo', 'Wiederholen')}
</Button>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-3">
<span className="text-sm font-medium text-muted-foreground">
{t('invites.customizer.controls.zoom', 'Zoom')}
</span>
<input
type="range"
min={ZOOM_MIN}
max={ZOOM_MAX}
step={ZOOM_STEP}
value={effectiveScale}
onChange={(event) => {
manualZoomRef.current = true;
setZoomScale(clampZoom(Number(event.target.value)));
}}
className="h-1 w-36 overflow-hidden rounded-full"
disabled={false}
aria-label={t('invites.customizer.controls.zoom', 'Zoom')}
/>
<span className="tabular-nums text-sm text-muted-foreground">{zoomPercent}%</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
manualZoomRef.current = false;
const fitValue = clampZoom(fitScaleRef.current);
setZoomScale(fitValue);
}}
disabled={Math.abs(effectiveScale - clampZoom(fitScaleRef.current)) < 0.001}
>
{t('invites.customizer.actions.zoomFit', 'Auf Bildschirm')}
</Button>
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={handleUndo}
disabled={!canUndo}
>
<Undo2 className="mr-1 h-4 w-4" />
{t('invites.customizer.actions.undo', 'Rückgängig')}
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleRedo}
disabled={!canRedo}
>
<Redo2 className="mr-1 h-4 w-4" />
{t('invites.customizer.actions.redo', 'Wiederholen')}
</Button>
</div>
</div>
<div className="flex justify-center">
@@ -1744,6 +1876,7 @@ export function InviteLayoutCustomizerPanel({
badge={form.badge_color ?? form.accent_color ?? '#2563EB'}
qrCodeDataUrl={qrCodeDataUrl}
logoDataUrl={form.logo_data_url ?? form.logo_url ?? null}
scale={effectiveScale}
layoutKey={`designer:${invite?.id ?? 'unknown'}:${activeLayout?.id ?? 'default'}`}
/>
</div>

View File

@@ -22,6 +22,7 @@ type DesignerCanvasProps = {
badge: string;
qrCodeDataUrl: string | null;
logoDataUrl: string | null;
scale?: number;
layoutKey?: string;
readOnly?: boolean;
};
@@ -41,6 +42,7 @@ export function DesignerCanvas({
badge,
qrCodeDataUrl,
logoDataUrl,
scale = 1,
layoutKey,
readOnly = false,
}: DesignerCanvasProps): React.JSX.Element {
@@ -343,16 +345,43 @@ export function DesignerCanvas({
if (!canvas) {
return;
}
canvas.setZoom(1);
canvas.setDimensions(
{
width: CANVAS_WIDTH,
height: CANVAS_HEIGHT,
},
{ cssOnly: true },
);
const normalizedScale = Number.isFinite(scale) && scale > 0 ? scale : 1;
canvas.setZoom(normalizedScale);
const cssWidth = CANVAS_WIDTH * normalizedScale;
const cssHeight = CANVAS_HEIGHT * normalizedScale;
const element = canvas.getElement();
if (element) {
element.style.width = `${cssWidth}px`;
element.style.height = `${cssHeight}px`;
}
if (canvas.upperCanvasEl) {
canvas.upperCanvasEl.style.width = `${cssWidth}px`;
canvas.upperCanvasEl.style.height = `${cssHeight}px`;
}
if (canvas.lowerCanvasEl) {
canvas.lowerCanvasEl.style.width = `${cssWidth}px`;
canvas.lowerCanvasEl.style.height = `${cssHeight}px`;
}
if (canvas.wrapperEl) {
canvas.wrapperEl.style.width = `${cssWidth}px`;
canvas.wrapperEl.style.height = `${cssHeight}px`;
}
if (containerRef.current) {
containerRef.current.style.width = `${cssWidth}px`;
containerRef.current.style.height = `${cssHeight}px`;
}
canvas.calcOffset();
canvas.requestRenderAll();
}, []);
}, [scale]);
return (
<div ref={containerRef} className="relative inline-block max-w-full">

View File

@@ -133,217 +133,361 @@ export function clampElement(element: LayoutElement): LayoutElement {
}
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' },
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: 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: '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 - context.qrSize - 140,
x: (context) => context.canvasWidth - Math.min(context.qrSize, 680) - 180,
y: 360,
width: (context) => context.qrSize,
height: (context) => context.qrSize,
width: (context) => Math.min(context.qrSize, 680),
height: (context) => Math.min(context.qrSize, 680),
},
{
id: 'link',
type: 'link',
x: (context) => context.canvasWidth - 420,
y: (context) => 400 + context.qrSize,
width: 400,
height: 110,
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 - 420,
y: (context) => 420 + context.qrSize + 140,
width: 400,
height: 110,
fontSize: 26,
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: 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: '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 - context.qrSize - 160,
y: 460,
width: (context) => context.qrSize,
height: (context) => context.qrSize,
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 - 420,
y: (context) => 500 + context.qrSize,
width: 400,
height: 110,
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 - 420,
y: (context) => 520 + context.qrSize + 150,
width: 400,
height: 110,
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: 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: '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 - context.qrSize) / 2,
y: 700,
width: (context) => context.qrSize,
height: (context) => context.qrSize,
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 - 420) / 2,
y: (context) => 740 + context.qrSize,
width: 420,
height: 120,
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 - 420) / 2,
y: (context) => 770 + context.qrSize + 150,
width: 420,
height: 120,
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',
},
{ 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: '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: 160,
y: 840,
width: (context) => Math.min(context.qrSize, 520),
height: (context) => Math.min(context.qrSize, 520),
x: 180,
y: 1000,
width: (context) => Math.min(context.qrSize, 660),
height: (context) => Math.min(context.qrSize, 660),
},
{
id: 'link',
type: 'link',
x: 160,
y: (context) => 880 + Math.min(context.qrSize, 520),
width: 420,
height: 110,
x: 180,
y: (context) => 1060 + Math.min(context.qrSize, 660),
width: 520,
height: 140,
align: 'center',
},
{
id: 'cta',
type: 'cta',
x: 160,
y: (context) => 910 + Math.min(context.qrSize, 520) + 140,
width: 420,
height: 110,
x: 180,
y: (context) => 1100 + Math.min(context.qrSize, 660) + 190,
width: 520,
height: 140,
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' },
{ 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: 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: '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 - context.qrSize) / 2,
y: 960,
width: (context) => context.qrSize,
height: (context) => context.qrSize,
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 - 420) / 2,
y: (context) => 1000 + context.qrSize,
width: 420,
height: 110,
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 - 420) / 2,
y: (context) => 1030 + context.qrSize + 140,
width: 420,
height: 110,
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: 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: '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 - context.qrSize - 200,
y: 360,
width: (context) => context.qrSize,
height: (context) => context.qrSize,
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 - 420,
y: (context) => 400 + context.qrSize,
width: 400,
height: 110,
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 - 420,
y: (context) => 430 + context.qrSize + 140,
width: 400,
height: 110,
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: 140, y: 960, width: 860, height: 220, fontSize: 26, align: 'left' },
{
id: 'text-strip',
type: 'text',
x: 180,
y: 1220,
width: (context) => context.canvasWidth - 360,
height: 360,
fontSize: 30,
align: 'left',
},
];
const LAYOUT_PRESETS: Record<string, LayoutPreset> = {
@@ -513,6 +657,7 @@ export function elementsToPayload(elements: LayoutElement[]): LayoutElementPaylo
}));
}
export function normalizeElements(elements: LayoutElement[]): LayoutElement[] {
const seen = new Set<string>();
return elements