Hintergründe zum EventInvitePage Layout Customizer hinzugefügt. Badge und CTA entfernt, Textfelder zu Textareas gemacht. Geschenkgutscheine verbessert, E-Mail-Versand ergänzt + Resend + Confirmationseite mit Code-Copy und Link zur Package-Seite, die den Code als URL-Parameter enthält.

This commit is contained in:
Codex Agent
2025-12-08 16:20:04 +01:00
parent 046e2fe3ec
commit 4784c23e70
35 changed files with 1503 additions and 136 deletions

View File

@@ -22,6 +22,7 @@ type DesignerCanvasProps = {
badge: string;
qrCodeDataUrl: string | null;
logoDataUrl: string | null;
backgroundImageUrl?: string | null;
scale?: number;
readOnly?: boolean;
layoutKey?: string;
@@ -36,6 +37,7 @@ export function DesignerCanvas({
onChange,
background,
gradient,
backgroundImageUrl = null,
accent,
text,
secondary,
@@ -233,6 +235,8 @@ export function DesignerCanvas({
return;
}
const elementId = target.elementId;
const action = event.transform?.action ?? null;
const isScalingAction = action?.startsWith('scale') || action === 'resize';
const bounds = target.getBoundingRect();
const nextPatch: Partial<LayoutElement> = {
@@ -240,61 +244,55 @@ export function DesignerCanvas({
y: clamp(Math.round(bounds.top ?? 0), 20, CANVAS_HEIGHT - 20),
};
// Manual collision check: Calculate overlap and push vertically
const otherObjects = canvas
.getObjects()
.filter((obj): obj is FabricObjectWithId => obj !== target && Boolean((obj as FabricObjectWithId).elementId));
otherObjects.forEach((other) => {
const otherBounds = other.getBoundingRect();
const overlapX = Math.max(0, Math.min(bounds.left + bounds.width, otherBounds.left + otherBounds.width) - Math.max(bounds.left, otherBounds.left));
const overlapY = Math.max(0, Math.min(bounds.top + bounds.height, otherBounds.top + otherBounds.height) - Math.max(bounds.top, otherBounds.top));
if (overlapX > 0 && overlapY > 0) {
// Push down by 120px if overlap (massive spacing für größeren QR-Code)
nextPatch.y = Math.max(nextPatch.y ?? 0, (Number(otherBounds.top || 0)) + (Number(otherBounds.height || 0)) + 120);
}
});
const isImage = target.type === 'image';
if (isImage) {
const currentScaleX = target.scaleX ?? 1;
const currentScaleY = target.scaleY ?? 1;
const naturalWidth = target.width ?? 0;
const naturalHeight = target.height ?? 0;
if (elementId === 'qr') {
// For QR: Enforce uniform scale, cap size, padding=0
const avgScale = (currentScaleX + currentScaleY) / 2;
const cappedSize = Math.min(Math.round(naturalWidth * avgScale), 800); // Cap at 800px for massive QR
nextPatch.width = cappedSize;
nextPatch.height = cappedSize;
nextPatch.scaleX = cappedSize / naturalWidth;
nextPatch.scaleY = cappedSize / naturalHeight;
if (isScalingAction) {
if (isImage) {
const currentScaleX = target.scaleX ?? 1;
const currentScaleY = target.scaleY ?? 1;
const naturalWidth = target.width ?? 0;
const naturalHeight = target.height ?? 0;
if (elementId === 'qr') {
// For QR: Enforce uniform scale, cap size, padding=0
const avgScale = (currentScaleX + currentScaleY) / 2;
const cappedSize = Math.min(Math.round(naturalWidth * avgScale), 800); // Cap at 800px for massive QR
nextPatch.width = cappedSize;
nextPatch.height = cappedSize;
nextPatch.scaleX = cappedSize / naturalWidth;
nextPatch.scaleY = cappedSize / naturalHeight;
target.set({
left: nextPatch.x,
top: nextPatch.y,
scaleX: nextPatch.scaleX,
scaleY: nextPatch.scaleY,
padding: 12, // Increased padding for better frame visibility
uniformScaling: true, // Lock aspect ratio
lockScalingFlip: true,
});
} else {
nextPatch.width = Math.round(naturalWidth * currentScaleX);
nextPatch.height = Math.round(naturalHeight * currentScaleY);
nextPatch.scaleX = currentScaleX;
nextPatch.scaleY = currentScaleY;
target.set({ left: nextPatch.x, top: nextPatch.y, padding: 10 });
}
} else {
nextPatch.width = clamp(Math.round(bounds.width ?? target.width ?? 0), 40, CANVAS_WIDTH - 40);
nextPatch.height = clamp(Math.round(bounds.height ?? target.height ?? 0), 40, CANVAS_HEIGHT - 40);
target.set({
scaleX: 1,
scaleY: 1,
left: nextPatch.x,
top: nextPatch.y,
scaleX: nextPatch.scaleX,
scaleY: nextPatch.scaleY,
padding: 12, // Increased padding for better frame visibility
uniformScaling: true, // Lock aspect ratio
lockScalingFlip: true,
width: nextPatch.width,
height: nextPatch.height,
padding: 10, // Default padding for text
});
} else {
nextPatch.width = Math.round(naturalWidth * currentScaleX);
nextPatch.height = Math.round(naturalHeight * currentScaleY);
nextPatch.scaleX = currentScaleX;
nextPatch.scaleY = currentScaleY;
target.set({ left: nextPatch.x, top: nextPatch.y, padding: 10 });
}
} else {
nextPatch.width = clamp(Math.round(bounds.width ?? target.width ?? 0), 40, CANVAS_WIDTH - 40);
nextPatch.height = clamp(Math.round(bounds.height ?? target.height ?? 0), 40, CANVAS_HEIGHT - 40);
// Dragging: keep size, only move
target.set({
scaleX: 1,
scaleY: 1,
left: nextPatch.x,
top: nextPatch.y,
width: nextPatch.width,
height: nextPatch.height,
padding: 10, // Default padding for text
});
}
@@ -349,6 +347,7 @@ export function DesignerCanvas({
logoDataUrl,
background,
gradient,
backgroundImageUrl,
readOnly,
});
@@ -367,6 +366,7 @@ export function DesignerCanvas({
logoDataUrl,
backgroundColor: background,
backgroundGradient: gradient,
backgroundImageUrl,
readOnly,
}).catch((error) => {
console.error('[Fabric] Failed to render layout', error);
@@ -381,6 +381,7 @@ export function DesignerCanvas({
logoDataUrl,
background,
gradient,
backgroundImageUrl,
readOnly,
]);
@@ -456,6 +457,7 @@ export type FabricRenderOptions = {
logoDataUrl: string | null;
backgroundColor: string;
backgroundGradient: { angle?: number; stops?: string[] } | null;
backgroundImageUrl?: string | null;
readOnly: boolean;
};
@@ -473,13 +475,21 @@ export async function renderFabricLayout(
logoDataUrl,
backgroundColor,
backgroundGradient,
backgroundImageUrl,
readOnly,
} = options;
canvas.discardActiveObject();
// Aggressively clear previous objects/state to avoid stacking duplicates between renders.
try {
const existing = canvas.getObjects();
existing.forEach((obj) => canvas.remove(obj));
} catch (error) {
console.warn('[Invites][Fabric] failed to remove existing objects', error);
}
canvas.clear();
applyBackground(canvas, backgroundColor, backgroundGradient);
await applyBackground(canvas, backgroundColor, backgroundGradient, backgroundImageUrl);
console.debug('[Invites][Fabric] render', {
elementCount: elements.length,
@@ -543,11 +553,70 @@ export async function renderFabricLayout(
canvas.renderAll();
}
export function applyBackground(
export async function applyBackground(
canvas: fabric.Canvas,
color: string,
gradient: { angle?: number; stops?: string[] } | null,
): void {
backgroundImageUrl?: string | null,
): Promise<void> {
try {
if (typeof canvas.setBackgroundImage === 'function') {
canvas.setBackgroundImage(null, canvas.requestRenderAll.bind(canvas));
} else {
// Fallback for environments where setBackgroundImage is not present
(canvas as fabric.StaticCanvas).backgroundImage = null;
canvas.requestRenderAll();
}
if (backgroundImageUrl) {
try {
const resolvedUrl = backgroundImageUrl.startsWith('http')
? backgroundImageUrl
: `${window.location.origin}${backgroundImageUrl.startsWith('/') ? '' : '/'}${backgroundImageUrl}`;
const image = await new Promise<fabric.Image | null>((resolve) => {
const imgEl = new Image();
imgEl.crossOrigin = 'anonymous';
const timeoutId = window.setTimeout(() => resolve(null), 3000);
imgEl.onload = () => {
window.clearTimeout(timeoutId);
resolve(new fabric.Image(imgEl, { crossOrigin: 'anonymous' }));
};
imgEl.onerror = () => {
window.clearTimeout(timeoutId);
resolve(null);
};
imgEl.src = resolvedUrl;
});
if (image) {
const scaleX = CANVAS_WIDTH / (image.width || CANVAS_WIDTH);
const scaleY = CANVAS_HEIGHT / (image.height || CANVAS_HEIGHT);
const scale = Math.max(scaleX, scaleY);
image.set({
originX: 'left',
originY: 'top',
left: 0,
top: 0,
scaleX: scale,
scaleY: scale,
selectable: false,
evented: false,
});
if (typeof canvas.setBackgroundImage === 'function') {
canvas.setBackgroundImage(image, canvas.requestRenderAll.bind(canvas));
} else {
(canvas as fabric.StaticCanvas).backgroundImage = image;
canvas.requestRenderAll();
}
return;
}
} catch (error) {
console.warn('[Fabric] Failed to load background image', error);
}
}
} catch (error) {
console.warn('[Fabric] applyBackground failed', error);
}
let background: string | fabric.Gradient<'linear'> = color;
if (gradient?.stops?.length) {
@@ -821,15 +890,34 @@ export async function loadImageObject(
const intrinsicWidth = image.width ?? element.width;
const intrinsicHeight = image.height ?? element.height;
const scaleX = element.width / intrinsicWidth;
const scaleY = element.height / intrinsicHeight;
const safeIntrinsicWidth = intrinsicWidth || 1;
const safeIntrinsicHeight = intrinsicHeight || 1;
let targetLeft = element.x;
let targetTop = element.y;
let scaleX = element.width / safeIntrinsicWidth;
let scaleY = element.height / safeIntrinsicHeight;
if (options?.objectFit === 'contain') {
const ratio = Math.min(scaleX, scaleY);
scaleX = ratio;
scaleY = ratio;
const renderedWidth = safeIntrinsicWidth * ratio;
const renderedHeight = safeIntrinsicHeight * ratio;
targetLeft = element.x + (element.width - renderedWidth) / 2;
targetTop = element.y + (element.height - renderedHeight) / 2;
}
image.set({
...baseConfig,
width: element.width,
height: element.height,
originX: 'left',
originY: 'top',
width: safeIntrinsicWidth,
height: safeIntrinsicHeight,
scaleX,
scaleY,
left: targetLeft,
top: targetTop,
padding: options?.padding ?? 0,
});
@@ -837,16 +925,6 @@ export async function loadImageObject(
image.set('shadow', options.shadow);
}
if (options?.objectFit === 'contain') {
const ratio = Math.min(scaleX, scaleY);
image.set({
scaleX: ratio,
scaleY: ratio,
left: element.x + (element.width - intrinsicWidth * ratio) / 2,
top: element.y + (element.height - intrinsicHeight * ratio) / 2,
});
}
resolveSafely(image);
};

View File

@@ -0,0 +1,44 @@
export type BackgroundImageOption = {
id: string;
url: string;
label: string;
};
// Preload background assets from public/storage/layouts/backgrounds.
// Vite does not process the public directory, so we try a glob (for cases where assets are in src)
// and fall back to known public URLs.
const backgroundImports: Record<string, string> = {
...import.meta.glob('../../../../../public/storage/layouts/backgrounds/*.{jpg,jpeg,png,webp,avif}', {
eager: true,
as: 'url',
}),
...import.meta.glob('/storage/layouts/backgrounds/*.{jpg,jpeg,png,webp,avif}', {
eager: true,
as: 'url',
}),
};
const fallbackFiles = ['bg-blue-floral.png', 'bg-goldframe.png', 'gr-green-floral.png'];
const importedBackgrounds: BackgroundImageOption[] = Object.entries(backgroundImports).map(([path, url]) => {
const filename = path.split('/').pop() ?? path;
const id = filename.replace(/\.[^.]+$/, '');
return { id, url: url as string, label: filename };
});
const fallbackBackgrounds: BackgroundImageOption[] = fallbackFiles.map((filename) => ({
id: filename.replace(/\.[^.]+$/, ''),
url: `/storage/layouts/backgrounds/${filename}`,
label: filename,
}));
const merged = [...importedBackgrounds, ...fallbackBackgrounds];
export const preloadedBackgrounds: BackgroundImageOption[] = Array.from(
merged.reduce((map, item) => {
if (!map.has(item.id)) {
map.set(item.id, item);
}
return map;
}, new Map<string, BackgroundImageOption>()),
).map(([, value]) => value);

View File

@@ -127,6 +127,7 @@ export type QrLayoutCustomization = {
secondary_color?: string;
badge_color?: string;
background_gradient?: { angle?: number; stops?: string[] } | null;
background_image?: string | null;
logo_data_url?: string | null;
logo_url?: string | null;
mode?: 'standard' | 'advanced';
@@ -172,7 +173,6 @@ const DEFAULT_TYPE_STYLES: Record<LayoutElementType, { width: number; height: nu
const DEFAULT_PRESET: LayoutPreset = [
// Basierend auf dem zentrierten, modernen "confetti-bash"-Layout
{ id: 'logo', type: 'logo', x: (c) => (c.canvasWidth - 280) / 2, y: 80, width: 280, height: 140, align: 'center' },
{ id: 'badge', type: 'badge', x: (c) => (c.canvasWidth - 520) / 2, y: 240, width: 520, height: 90, align: 'center', fontSize: 28, lineHeight: 1.4, letterSpacing: 0.5, fontFamily: 'Montserrat' },
{
id: 'headline',
type: 'headline',
@@ -188,13 +188,11 @@ const DEFAULT_PRESET: LayoutPreset = [
{ id: 'subtitle', type: 'subtitle', x: (c) => (c.canvasWidth - 800) / 2, y: 580, width: 800, height: 120, fontSize: 42, align: 'center', fontFamily: 'Montserrat', lineHeight: 1.4 },
{ id: 'description', type: 'description', x: (c) => (c.canvasWidth - 900) / 2, y: 720, width: 900, height: 180, fontSize: 34, align: 'center', fontFamily: 'Lora', lineHeight: 1.5 },
{ id: 'qr', type: 'qr', x: (c) => (c.canvasWidth - 500) / 2, y: 940, width: (c) => Math.min(c.qrSize, 500), height: (c) => Math.min(c.qrSize, 500) },
{ id: 'cta', type: 'cta', x: (c) => (c.canvasWidth - 600) / 2, y: (c) => 940 + Math.min(c.qrSize, 500) + 40, width: 600, height: 100, align: 'center', fontSize: 32, fontFamily: 'Montserrat', lineHeight: 1.4 },
{ id: 'link', type: 'link', x: (c) => (c.canvasWidth - 700) / 2, y: (c) => 940 + Math.min(c.qrSize, 500) + 160, width: 700, height: 80, align: 'center', fontSize: 26, fontFamily: 'Montserrat', lineHeight: 1.5 },
];
const evergreenVowsPreset: LayoutPreset = [
// Elegant, linksbündig mit verbesserter Balance
{ id: 'logo', type: 'logo', x: 120, y: 100, width: 300, height: 150, align: 'left' },
{ id: 'badge', type: 'badge', x: (c) => c.canvasWidth - 520 - 120, y: 125, width: 520, height: 90, align: 'right', fontSize: 28, lineHeight: 1.4, fontFamily: 'Montserrat' },
{
id: 'headline',
type: 'headline',
@@ -239,13 +237,11 @@ const evergreenVowsPreset: LayoutPreset = [
width: (c) => Math.min(c.qrSize, 440),
height: (c) => Math.min(c.qrSize, 440),
},
{ id: 'cta', type: 'cta', x: (c) => c.canvasWidth - 440 - 120, y: (c) => 920 + Math.min(c.qrSize, 440) + 40, width: 440, height: 100, align: 'center', fontSize: 30, fontFamily: 'Montserrat', lineHeight: 1.4 },
{ id: 'link', type: 'link', x: (c) => c.canvasWidth - 440 - 120, y: (c) => 920 + Math.min(c.qrSize, 440) + 160, width: 440, height: 80, align: 'center', fontSize: 24, fontFamily: 'Montserrat' },
];
const midnightGalaPreset: LayoutPreset = [
// Zentriert, premium, mehr vertikaler Abstand
{ id: 'badge', type: 'badge', x: (c) => (c.canvasWidth - 560) / 2, y: 120, width: 560, height: 90, align: 'center', fontSize: 28, fontFamily: 'Montserrat' },
{
id: 'headline',
type: 'headline',
@@ -268,13 +264,11 @@ const midnightGalaPreset: LayoutPreset = [
width: (c) => Math.min(c.qrSize, 480),
height: (c) => Math.min(c.qrSize, 480),
},
{ id: 'cta', type: 'cta', x: (c) => (c.canvasWidth - 520) / 2, y: (c) => 880 + Math.min(c.qrSize, 480) + 40, width: 520, height: 100, align: 'center', fontSize: 30, fontFamily: 'Montserrat', lineHeight: 1.4 },
{ id: 'link', type: 'link', x: (c) => (c.canvasWidth - 600) / 2, y: (c) => 880 + Math.min(c.qrSize, 480) + 160, width: 600, height: 80, align: 'center', fontSize: 24, fontFamily: 'Montserrat' },
];
const gardenBrunchPreset: LayoutPreset = [
// Verspielt, asymmetrisch, aber ausbalanciert
{ id: 'badge', type: 'badge', x: 120, y: 120, width: 500, height: 90, align: 'left', fontSize: 28, fontFamily: 'Montserrat' },
{ id: 'headline', type: 'headline', x: 120, y: 240, width: 900, height: 200, fontSize: 90, align: 'left', fontFamily: 'Playfair Display', lineHeight: 1.3 },
{ id: 'subtitle', type: 'subtitle', x: 120, y: 450, width: 700, height: 120, fontSize: 40, align: 'left', fontFamily: 'Montserrat', lineHeight: 1.4 },
{
@@ -285,7 +279,6 @@ const gardenBrunchPreset: LayoutPreset = [
width: (c) => Math.min(c.qrSize, 460),
height: (c) => Math.min(c.qrSize, 460),
},
{ id: 'cta', type: 'cta', x: 120, y: (c) => 880 + Math.min(c.qrSize, 460) + 40, width: 460, height: 100, align: 'center', fontSize: 30, fontFamily: 'Montserrat' },
{
id: 'description',
type: 'description',
@@ -303,7 +296,6 @@ const gardenBrunchPreset: LayoutPreset = [
const sparklerSoireePreset: LayoutPreset = [
// Festlich, zentriert, klar
{ id: 'badge', type: 'badge', x: (c) => (c.canvasWidth - 560) / 2, y: 120, width: 560, height: 90, align: 'center', fontSize: 28, fontFamily: 'Montserrat' },
{
id: 'headline',
type: 'headline',
@@ -326,14 +318,12 @@ const sparklerSoireePreset: LayoutPreset = [
width: (c) => Math.min(c.qrSize, 480),
height: (c) => Math.min(c.qrSize, 480),
},
{ id: 'cta', type: 'cta', x: (c) => (c.canvasWidth - 520) / 2, y: (c) => 880 + Math.min(c.qrSize, 480) + 40, width: 520, height: 100, align: 'center', fontSize: 30, fontFamily: 'Montserrat' },
{ id: 'link', type: 'link', x: (c) => (c.canvasWidth - 600) / 2, y: (c) => 880 + Math.min(c.qrSize, 480) + 160, width: 600, height: 80, align: 'center', fontSize: 24, fontFamily: 'Montserrat' },
];
const confettiBashPreset: LayoutPreset = [
// Zentriertes, luftiges Layout mit klarer Hierarchie.
{ id: 'logo', type: 'logo', x: (c) => (c.canvasWidth - 280) / 2, y: 80, width: 280, height: 140, align: 'center' },
{ id: 'badge', type: 'badge', x: (c) => (c.canvasWidth - 520) / 2, y: 240, width: 520, height: 90, align: 'center', fontSize: 28, lineHeight: 1.4, letterSpacing: 0.5, fontFamily: 'Montserrat' },
{
id: 'headline',
type: 'headline',
@@ -378,18 +368,6 @@ const confettiBashPreset: LayoutPreset = [
width: (c) => Math.min(c.qrSize, 500),
height: (c) => Math.min(c.qrSize, 500),
},
{
id: 'cta',
type: 'cta',
x: (c) => (c.canvasWidth - 600) / 2,
y: (c) => 940 + Math.min(c.qrSize, 500) + 40,
width: 600,
height: 100,
align: 'center',
fontSize: 32,
fontFamily: 'Montserrat',
lineHeight: 1.4,
},
{
id: 'link',
type: 'link',
@@ -407,7 +385,6 @@ const confettiBashPreset: LayoutPreset = [
const balancedModernPreset: LayoutPreset = [
// Wahrhaftig balanciert: Text links, QR rechts
{ id: 'logo', type: 'logo', x: 120, y: 100, width: 300, height: 150, align: 'left' },
{ id: 'badge', type: 'badge', x: 120, y: 270, width: 500, height: 90, align: 'left', fontSize: 28, fontFamily: 'Montserrat' },
{
id: 'headline',
type: 'headline',
@@ -452,7 +429,6 @@ const balancedModernPreset: LayoutPreset = [
width: 480,
height: 480,
},
{ id: 'cta', type: 'cta', x: (c) => c.canvasWidth - 480 - 120, y: 880, width: 480, height: 100, align: 'center', fontSize: 30, fontFamily: 'Montserrat' },
{ id: 'link', type: 'link', x: (c) => c.canvasWidth - 480 - 120, y: 1000, width: 480, height: 80, align: 'center', fontSize: 24, fontFamily: 'Montserrat' },
];
@@ -501,9 +477,7 @@ export function buildDefaultElements(
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,
};
@@ -541,15 +515,9 @@ export function buildDefaultElements(
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;