From a35b56f09d37e683e98bf148ec67067cb14ad027 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Fri, 12 Dec 2025 23:19:23 +0100 Subject: [PATCH] improved layout customize page --- .../js/admin/i18n/locales/de/management.json | 25 + .../js/admin/i18n/locales/en/management.json | 25 + .../js/admin/mobile/QrLayoutCustomizePage.tsx | 978 ++++++++++-------- resources/js/admin/mobile/QrPrintPage.tsx | 31 +- .../mobile/invite-layout/DesignerCanvas.tsx | 6 + .../mobile/invite-layout/export-utils.ts | 1 + 6 files changed, 626 insertions(+), 440 deletions(-) diff --git a/resources/js/admin/i18n/locales/de/management.json b/resources/js/admin/i18n/locales/de/management.json index a3e46ee..bfcf8b3 100644 --- a/resources/js/admin/i18n/locales/de/management.json +++ b/resources/js/admin/i18n/locales/de/management.json @@ -1842,6 +1842,31 @@ "notificationsLoading": "Lade Einstellungen ...", "pref": {} }, + "events": { + "qr": { + "title": "QR-Code & Druck-Layouts", + "heroTitle": "Einlass-QR-Code", + "description": "Scannen, um zur Gäste-App zu gelangen.", + "missing": "Kein QR-Link vorhanden", + "download": "Download", + "downloadStarted": "Download gestartet", + "share": "Teilen", + "shareSuccess": "Link kopiert", + "shareFailed": "Link konnte nicht kopiert werden", + "step1": "Schritt 1: Format wählen", + "layouts": "Druck-Layouts", + "preview": "Anpassen & Exportieren", + "createLink": "Neuen QR-Link erstellen", + "created": "Neuer QR-Link erstellt", + "createFailed": "Link konnte nicht erstellt werden.", + "format": { + "poster": "A4 Poster", + "posterSubtitle": "Hochformat für Aushänge", + "table": "A5 Tischkarte (faltbar)", + "tableSubtitle": "Quer, doppelt & gespiegelt" + } + } + }, "settings": { "notifications": { "keys": { diff --git a/resources/js/admin/i18n/locales/en/management.json b/resources/js/admin/i18n/locales/en/management.json index 93574df..f837ab6 100644 --- a/resources/js/admin/i18n/locales/en/management.json +++ b/resources/js/admin/i18n/locales/en/management.json @@ -1863,6 +1863,31 @@ "notificationsLoading": "Loading settings ...", "pref": {} }, + "events": { + "qr": { + "title": "QR Code & Print Layouts", + "heroTitle": "Entrance QR Code", + "description": "Scan to access the event guest app.", + "missing": "No QR link available", + "download": "Download", + "downloadStarted": "Download started", + "share": "Share", + "shareSuccess": "Link copied", + "shareFailed": "Could not copy link", + "step1": "Step 1: Choose format", + "layouts": "Print Layouts", + "preview": "Customize & Export", + "createLink": "Create new QR link", + "created": "New QR link created", + "createFailed": "Could not create link.", + "format": { + "poster": "A4 Poster", + "posterSubtitle": "Portrait for posters", + "table": "A5 Table card (foldable)", + "tableSubtitle": "Landscape, double-mirror" + } + } + }, "settings": { "notifications": { "keys": { diff --git a/resources/js/admin/mobile/QrLayoutCustomizePage.tsx b/resources/js/admin/mobile/QrLayoutCustomizePage.tsx index 2c6b75a..bef238d 100644 --- a/resources/js/admin/mobile/QrLayoutCustomizePage.tsx +++ b/resources/js/admin/mobile/QrLayoutCustomizePage.tsx @@ -6,19 +6,20 @@ import { YStack, XStack } from '@tamagui/stacks'; import { SizableText as Text } from '@tamagui/text'; import { Pressable } from '@tamagui/react-native-web-lite'; import { Input, TextArea } from 'tamagui'; -import type { fabric as FabricNS } from 'fabric'; import { MobileShell } from './components/MobileShell'; import { MobileCard, CTAButton, PillBadge } from './components/Primitives'; import { TenantEvent, EventQrInvite, EventQrInviteLayout, + TenantFont, getEvent, getEventQrInvites, updateEventQrInvite, } from '../api'; import { isAuthError } from '../auth/tokens'; import { getApiErrorMessage } from '../lib/apiError'; +import { useTenantFonts, ensureFontLoaded } from '../lib/fonts'; import toast from 'react-hot-toast'; import { generatePdfBytes, @@ -37,41 +38,6 @@ const BACKGROUND_PRESETS = [ { id: 'gr-green-floral', src: '/storage/layouts/backgrounds-portrait/gr-green-floral.png', label: 'Green Floral' }, ]; -export function buildInitialTextFields({ - customization, - layoutDefaults, - eventName, -}: { - customization: Record | null | undefined; - layoutDefaults: EventQrInviteLayout | null | undefined; - eventName?: string | null; -}): { headline: string; subtitle: string; description: string; instructions: string[] } { - const initialInstructions = - Array.isArray(customization?.instructions) && customization.instructions.length - ? customization.instructions - .map((item: unknown) => String(item ?? '')) - .filter((item: string) => item.length > 0) - : [ - 'QR-Code scannen\nKamera-App öffnen und auf den Code richten.', - 'Webseite öffnen\nDer Link öffnet direkt das gemeinsame Jubiläumsalbum.', - 'Fotos hochladen\nZeigt eure Lieblingsmomente oder erfüllt kleine Fotoaufgaben, um besondere Erinnerungen beizusteuern.', - ]; - - return { - headline: - (typeof customization?.headline === 'string' && customization.headline.length ? customization.headline : null) ?? - (typeof eventName === 'string' && eventName.length ? eventName : null) ?? - (typeof layoutDefaults?.name === 'string' ? layoutDefaults.name : '') ?? - '', - subtitle: typeof customization?.subtitle === 'string' ? customization.subtitle : '', - description: - (typeof customization?.description === 'string' && customization.description.length ? customization.description : null) ?? - (typeof layoutDefaults?.description === 'string' ? layoutDefaults.description : null) ?? - 'Helft uns, diesen besonderen Tag mit euren schönen Momenten festzuhalten.', - instructions: initialInstructions, - }; -} - export default function MobileQrLayoutCustomizePage() { const { slug: slugParam, tokenId: tokenParam } = useParams<{ slug?: string; tokenId?: string }>(); const [searchParams] = useSearchParams(); @@ -93,11 +59,13 @@ export default function MobileQrLayoutCustomizePage() { description: '', instructions: [''], }); + const [slotOverrides, setSlotOverrides] = React.useState>>({}); const [qrPngUrl, setQrPngUrl] = React.useState(null); const [step, setStep] = React.useState('background'); const [saving, setSaving] = React.useState(false); const [loading, setLoading] = React.useState(true); const [error, setError] = React.useState(null); + const { fonts: tenantFonts } = useTenantFonts(); const handleSelectPreset = React.useCallback((id: string | null) => { setBackgroundPreset(id); @@ -123,6 +91,16 @@ export default function MobileQrLayoutCustomizePage() { } }, []); + const handleUpdateSlot = React.useCallback((slotKey: string, patch: Partial) => { + setSlotOverrides((prev) => ({ + ...prev, + [slotKey]: { + ...(prev[slotKey] ?? {}), + ...patch, + }, + })); + }, []); + React.useEffect(() => { if (!slug) return; (async () => { @@ -131,6 +109,7 @@ export default function MobileQrLayoutCustomizePage() { const eventData = await getEvent(slug); const invites = await getEventQrInvites(slug); const matchedInvite = tokenId ? invites.find((item) => item.id === tokenId) : invites.find((i) => i.is_active) ?? invites[0] ?? null; + const eventName = renderEventName(eventData?.name); setEvent(eventData); setInvite(matchedInvite ?? null); setQrPngUrl(matchedInvite?.qr_code_data_url ?? null); @@ -145,9 +124,10 @@ export default function MobileQrLayoutCustomizePage() { buildInitialTextFields({ customization, layoutDefaults, - eventName: eventData?.name ?? null, + eventName, }) ); + setSlotOverrides(normalizeSlotOverrides((customization as any)?.slots)); setError(null); } catch (err) { if (!isAuthError(err)) { @@ -180,6 +160,7 @@ export default function MobileQrLayoutCustomizePage() { subtitle: textFields.subtitle || null, description: textFields.description || null, instructions: textFields.instructions.filter((item) => item.trim().length > 0), + slots: slotOverrides, }, }, }; @@ -258,16 +239,11 @@ export default function MobileQrLayoutCustomizePage() { presets={BACKGROUND_PRESETS} textFields={textFields} qrUrl={qrPngUrl ?? invite?.url ?? ''} + qrPngUrl={qrPngUrl} isFoldable={isFoldable} - onExport={(format) => { - const layout = selectedLayout; - const url = layout?.download_urls?.[format]; - if (!url) { - toast.error(t('events.qr.missing', 'Kein QR-Link vorhanden')); - return; - } - window.open(url, '_blank', 'noopener'); - }} + slotOverrides={slotOverrides} + onUpdateSlot={handleUpdateSlot} + tenantFonts={tenantFonts} /> )} @@ -327,24 +303,107 @@ type SlotDefinition = { h?: number; fontSize?: number; fontWeight?: string | number; + fontFamily?: string; lineHeight?: number; align?: 'left' | 'center' | 'right'; }; +type SlotOverrides = Record>; +type PaperSize = 'a4' | 'a5'; + +const PAPER_MM: Record = { + a4: { width: 210, height: 297 }, + a5: { width: 148, height: 210 }, +}; + +function toNumber(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + const parsed = typeof value === 'string' ? Number(value) : NaN; + return Number.isFinite(parsed) ? parsed : undefined; +} + +function clampValue(value: number, min: number, max: number): number { + if (!Number.isFinite(value)) return min; + return Math.min(Math.max(value, min), max); +} + +function normalizeSlotOverrides(raw: unknown): SlotOverrides { + if (!raw || typeof raw !== 'object') { + return {}; + } + return Object.entries(raw as Record).reduce((acc, [key, value]) => { + if (!value || typeof value !== 'object') return acc; + const slot = value as Record; + const align = typeof slot.align === 'string' ? (slot.align as SlotDefinition['align']) : undefined; + acc[key] = { + x: toNumber(slot.x), + y: toNumber(slot.y), + w: toNumber(slot.w), + h: toNumber(slot.h), + fontSize: toNumber(slot.fontSize), + lineHeight: toNumber(slot.lineHeight), + fontFamily: typeof slot.fontFamily === 'string' ? slot.fontFamily : undefined, + align, + }; + return acc; + }, {}); +} + +function resolvePaper(layout: EventQrInviteLayout | null): PaperSize { + const paper = (layout?.paper ?? 'a4').toString().toLowerCase(); + return paper === 'a5' ? 'a5' : 'a4'; +} + +function resolveCanvasBase(layout: EventQrInviteLayout | null, isFoldable: boolean): { width: number; height: number } { + const svgWidth = (layout as any)?.preview?.svg?.width; + const svgHeight = (layout as any)?.preview?.svg?.height; + if (svgWidth && svgHeight) { + return { width: svgWidth, height: svgHeight }; + } + + const paper = resolvePaper(layout); + const orientation = ((layout?.orientation ?? (isFoldable ? 'landscape' : 'portrait')) || '').toString().toLowerCase(); + const mm = PAPER_MM[paper]; + // Keep the existing pixel density consistent with the A4 canvas width. + const pxPerMm = CANVAS_WIDTH / PAPER_MM.a4.width; + let width = Math.round(mm.width * pxPerMm); + let height = Math.round(mm.height * pxPerMm); + + if (orientation === 'landscape') { + [width, height] = [height, width]; + } + + return { width, height }; +} + +function renderEventName(name: TenantEvent['name'] | null | undefined): string | null { + if (!name) return null; + if (typeof name === 'string' && name.trim()) return name.trim(); + if (typeof name === 'object') { + const values = Object.values(name).filter((val) => typeof val === 'string' && val.trim()) as string[]; + if (values.length) { + return values[0].trim(); + } + } + return null; +} + function getDefaultSlots(): Record { return { - headline: { x: 0.08, y: 0.08, w: 0.84, fontSize: 30, fontWeight: 800, align: 'center' }, - subtitle: { x: 0.1, y: 0.16, w: 0.8, fontSize: 18, fontWeight: 600, align: 'center' }, - description: { x: 0.1, y: 0.22, w: 0.8, fontSize: 16, lineHeight: 1.4, align: 'center' }, - instructions: { x: 0.1, y: 0.34, w: 0.8, fontSize: 14, lineHeight: 1.35 }, + headline: { x: 0.08, y: 0.08, w: 0.84, fontSize: 30, fontWeight: 800, fontFamily: 'Playfair Display', align: 'center' }, + subtitle: { x: 0.1, y: 0.16, w: 0.8, fontSize: 18, fontWeight: 600, fontFamily: 'Montserrat', align: 'center' }, + description: { x: 0.1, y: 0.22, w: 0.8, fontSize: 16, fontFamily: 'Lora', lineHeight: 1.4, align: 'center' }, + instructions: { x: 0.1, y: 0.34, w: 0.8, fontSize: 14, fontFamily: 'Lora', lineHeight: 1.35 }, qr: { x: 0.35, y: 0.55, w: 0.3 }, }; } -function resolveSlots(layout: EventQrInviteLayout | null, isFoldable: boolean): Record { +function resolveSlots(layout: EventQrInviteLayout | null, isFoldable: boolean, overrides: SlotOverrides = {}): Record { const fromLayout = (layout as any)?.slots; if (fromLayout && typeof fromLayout === 'object') { - return Object.entries(fromLayout as Record).reduce>((acc, [key, value]) => { + const base = Object.entries(fromLayout as Record).reduce>((acc, [key, value]) => { if (value && typeof value === 'object') { const slot = value as Record; acc[key] = { @@ -354,293 +413,48 @@ function resolveSlots(layout: EventQrInviteLayout | null, isFoldable: boolean): h: typeof slot.h === 'number' ? slot.h : undefined, fontSize: typeof slot.fontSize === 'number' ? slot.fontSize : undefined, fontWeight: typeof slot.fontWeight === 'string' || typeof slot.fontWeight === 'number' ? slot.fontWeight : undefined, + fontFamily: typeof slot.fontFamily === 'string' ? slot.fontFamily : undefined, lineHeight: typeof slot.lineHeight === 'number' ? slot.lineHeight : undefined, align: typeof slot.align === 'string' ? (slot.align as SlotDefinition['align']) : undefined, }; } return acc; }, {}); + return applySlotOverrides(base, overrides); } - if (isFoldable) { - return { - headline: { x: 0.1, y: 0.1, w: 0.8, fontSize: 28, fontWeight: 800, align: 'center' }, - subtitle: { x: 0.12, y: 0.18, w: 0.76, fontSize: 18, fontWeight: 600, align: 'center' }, - description: { x: 0.12, y: 0.24, w: 0.76, fontSize: 15, lineHeight: 1.4, align: 'center' }, - instructions: { x: 0.12, y: 0.36, w: 0.76, fontSize: 13, lineHeight: 1.3 }, - qr: { x: 0.36, y: 0.56, w: 0.28 }, - }; - } + const baseSlots = isFoldable + ? { + headline: { x: 0.1, y: 0.1, w: 0.8, fontSize: 28, fontWeight: 800, fontFamily: 'Playfair Display', align: 'center' }, + subtitle: { x: 0.12, y: 0.18, w: 0.76, fontSize: 18, fontWeight: 600, fontFamily: 'Montserrat', align: 'center' }, + description: { x: 0.12, y: 0.24, w: 0.76, fontSize: 15, fontFamily: 'Lora', lineHeight: 1.4, align: 'center' }, + instructions: { x: 0.12, y: 0.36, w: 0.76, fontSize: 13, fontFamily: 'Lora', lineHeight: 1.3 }, + qr: { x: 0.36, y: 0.56, w: 0.28 }, + } + : getDefaultSlots(); - return getDefaultSlots(); + return applySlotOverrides(baseSlots, overrides); } -function getCanvasSize(layout: EventQrInviteLayout | null, isFoldable: boolean): { width: number; height: number } { - const svgWidth = (layout as any)?.preview?.svg?.width; - const svgHeight = (layout as any)?.preview?.svg?.height; - if (svgWidth && svgHeight) { - return { width: svgWidth, height: svgHeight }; - } - if ((layout?.orientation || '').toLowerCase() === 'landscape' || isFoldable) { - return { width: 1754, height: 1240 }; // A4 landscape fallback - } - return { width: 1240, height: 1754 }; // A4 portrait fallback -} - -function FabricCanvasPreview({ - layout, - isFoldable, - backgroundPresetSrc, - backgroundGradient, - backgroundSolid, - textFields, - qrUrl, -}: { - layout: EventQrInviteLayout | null; - isFoldable: boolean; - backgroundPresetSrc: string | null; - backgroundGradient: { angle: number; stops: string[] } | null; - backgroundSolid: string | null; - textFields: { headline: string; subtitle: string; description: string; instructions: string[] }; - qrUrl: string; -}) { - const canvasRef = React.useRef(null); - const slots = React.useMemo(() => resolveSlots(layout, isFoldable), [layout, isFoldable]); - const containerRef = React.useRef(null); - const [frame, setFrame] = React.useState<{ width: number; height: number } | null>(null); - - React.useLayoutEffect(() => { - const measure = () => { - if (!containerRef.current) return; - const rect = containerRef.current.getBoundingClientRect(); - const width = rect.width || 320; - const aspect = - layout?.preview?.aspect_ratio ?? - ((layout?.orientation || '').toLowerCase() === 'landscape' || isFoldable ? 297 / 210 : 210 / 297); - const height = width / aspect; - setFrame({ width, height }); +function applySlotOverrides(baseSlots: Record, overrides: SlotOverrides): Record { + const keys = new Set([...Object.keys(baseSlots), ...Object.keys(overrides)]); + return Array.from(keys).reduce>((acc, key) => { + const base = baseSlots[key] ?? baseSlots.headline ?? { x: 0, y: 0, w: 1 }; + const override = overrides[key] ?? {}; + acc[key] = { + ...base, + x: override.x ?? base.x, + y: override.y ?? base.y, + w: override.w ?? base.w, + h: override.h ?? base.h, + fontSize: override.fontSize ?? base.fontSize, + fontWeight: override.fontWeight ?? base.fontWeight, + fontFamily: override.fontFamily ?? base.fontFamily, + lineHeight: override.lineHeight ?? base.lineHeight, + align: override.align ?? base.align, }; - measure(); - window.addEventListener('resize', measure); - return () => window.removeEventListener('resize', measure); - }, [layout?.orientation, layout?.preview?.aspect_ratio, isFoldable]); - - React.useEffect(() => { - if (!frame) return; - const baseWidth = frame.width; - const baseHeight = frame.height; - let canvas: FabricNS.Canvas | null = null; - let disposed = false; - - async function render() { - const mod = await import('fabric'); - const fabricLib: typeof FabricNS | undefined = (mod as any)?.fabric ?? (mod as any)?.default ?? (mod as any); - const canvasEl = canvasRef.current; - const fallbackColor = backgroundSolid ?? '#f8fafc'; - if (canvasEl) { - const ctx = canvasEl.getContext('2d'); - if (ctx) { - ctx.fillStyle = fallbackColor; - ctx.fillRect(0, 0, baseWidth, baseHeight); - } - } - if (!fabricLib || !fabricLib.Canvas || !canvasEl) { - return; - } - if (disposed || !canvasRef.current) return; - const fabric: typeof FabricNS = fabricLib as unknown as typeof FabricNS; - - canvasEl.width = baseWidth; - canvasEl.height = baseHeight; - - canvas = new fabric.Canvas(canvasRef.current, { - selection: false, - hoverCursor: 'default', - }); - canvas.setWidth(baseWidth); - canvas.setHeight(baseHeight); - canvas.clear(); - const lower = (canvas as any).lowerCanvasEl as HTMLCanvasElement | undefined; - const upper = (canvas as any).upperCanvasEl as HTMLCanvasElement | undefined; - [lower, upper].forEach((el, idx) => { - if (!el) return; - el.style.position = 'absolute'; - el.style.left = '0'; - el.style.top = '0'; - el.style.width = '100%'; - el.style.height = '100%'; - el.style.setProperty('background-color', 'transparent', 'important'); - el.style.pointerEvents = 'none'; - el.style.zIndex = idx === 0 ? '1' : '2'; - }); - canvas.set('backgroundColor', 'transparent'); - canvas.renderAll(); - - const textColor = layout?.preview?.text ?? '#0f172a'; - const panelWidth = isFoldable ? baseWidth / 2 : baseWidth; - const panelHeight = baseHeight; - - const drawPanel = async (offset: number, mirrored: boolean) => { - const placeX = (slotX: number, slotW: number) => - mirrored ? offset + (panelWidth - (slotX + slotW) * panelWidth) : offset + slotX * panelWidth; - - // Headline - if (textFields.headline) { - const slot = slots.headline; - const txt = new fabric.Textbox(textFields.headline, { - left: placeX(slot.x, slot.w), - top: slot.y * panelHeight, - width: slot.w * panelWidth, - fontSize: slot.fontSize ?? 28, - fontWeight: slot.fontWeight ?? '800', - fill: textColor, - textAlign: slot.align ?? 'center', - selectable: false, - evented: false, - }); - canvas!.add(txt); - } - - if (textFields.subtitle) { - const slot = slots.subtitle; - const txt = new fabric.Textbox(textFields.subtitle, { - left: placeX(slot.x, slot.w), - top: slot.y * panelHeight, - width: slot.w * panelWidth, - fontSize: slot.fontSize ?? 18, - fontWeight: slot.fontWeight ?? '600', - fill: textColor, - textAlign: slot.align ?? 'center', - selectable: false, - evented: false, - }); - canvas!.add(txt); - } - - if (textFields.description) { - const slot = slots.description; - const txt = new fabric.Textbox(textFields.description, { - left: placeX(slot.x, slot.w), - top: slot.y * panelHeight, - width: slot.w * panelWidth, - fontSize: slot.fontSize ?? 16, - lineHeight: slot.lineHeight ?? 1.35, - fill: textColor, - textAlign: slot.align ?? 'center', - selectable: false, - evented: false, - }); - canvas!.add(txt); - } - - const instructions = textFields.instructions.filter((i) => i.trim().length > 0); - if (instructions.length) { - const slot = slots.instructions; - const txt = new fabric.Textbox( - instructions - .map((line, idx) => `${idx + 1}. ${line}`) - .join('\n'), - { - left: placeX(slot.x, slot.w), - top: slot.y * panelHeight, - width: slot.w * panelWidth, - fontSize: slot.fontSize ?? 14, - lineHeight: slot.lineHeight ?? 1.3, - fill: textColor, - textAlign: slot.align ?? 'left', - selectable: false, - evented: false, - } - ); - canvas!.add(txt); - } - - if (qrUrl) { - const slot = slots.qr; - const size = slot.w * panelWidth; - const left = placeX(slot.x, slot.w); - const top = slot.y * panelHeight; - await new Promise((resolve) => { - fabric.Image.fromURL( - qrUrl, - (img) => { - if (!img) return resolve(); - img.set({ - left, - top, - selectable: false, - evented: false, - objectCaching: false, - }); - img.scaleToWidth(size); - img.scaleToHeight(size); - canvas!.add(img); - resolve(); - }, - { crossOrigin: qrUrl.startsWith(window.location.origin) ? undefined : 'anonymous' } - ); - }); - } - }; - - await drawPanel(0, false); - if (isFoldable) { - await drawPanel(panelWidth, true); - } - - canvas.renderAll(); - } - - render(); - - return () => { - disposed = true; - if (canvas) { - canvas.dispose(); - } - }; - }, [ - backgroundGradient, - backgroundPresetSrc, - backgroundSolid, - frame, - isFoldable, - layout?.preview?.text, - qrUrl, - slots, - textFields.description, - textFields.headline, - textFields.instructions, - textFields.subtitle, - ]); - - return ( - - - - ); + return acc; + }, {}); } function buildFabricOptions({ @@ -654,6 +468,7 @@ function buildFabricOptions({ qrPngUrl, baseWidth = CANVAS_WIDTH, baseHeight = CANVAS_HEIGHT, + slotOverrides = {}, }: { layout: EventQrInviteLayout | null; isFoldable: boolean; @@ -665,12 +480,13 @@ function buildFabricOptions({ qrPngUrl?: string | null; baseWidth?: number; baseHeight?: number; + slotOverrides?: SlotOverrides; }) { const scaleX = CANVAS_WIDTH / baseWidth; const scaleY = CANVAS_HEIGHT / baseHeight; const panelWidth = isFoldable ? baseWidth / 2 : baseWidth; const panelHeight = baseHeight; - const slots = resolveSlots(layout, isFoldable); + const slots = resolveSlots(layout, isFoldable, slotOverrides); const elements: LayoutElement[] = []; const textColor = layout?.preview?.text ?? '#0f172a'; @@ -678,6 +494,18 @@ function buildFabricOptions({ const secondaryColor = layout?.preview?.secondary ?? '#1f2937'; const badgeColor = layout?.preview?.badge ?? accentColor; + const rotatePoint = (cx: number, cy: number, x: number, y: number, angleDeg: number) => { + const rad = (angleDeg * Math.PI) / 180; + const cos = Math.cos(rad); + const sin = Math.sin(rad); + const dx = x - cx; + const dy = y - cy; + return { + x: cx + dx * cos - dy * sin, + y: cy + dx * sin + dy * cos, + }; + }; + const addTextElement = ( type: LayoutElement['type'], content: string, @@ -685,19 +513,31 @@ function buildFabricOptions({ opts: { panelOffset: number; mirrored: boolean } ) => { if (!content) return; - const widthPx = slot.w * panelWidth * scaleX; - const heightPx = (slot.h ?? 0.12) * panelHeight * scaleY; - const xBase = opts.mirrored - ? opts.panelOffset + (panelWidth - (slot.x + slot.w) * panelWidth) - : opts.panelOffset + slot.x * panelWidth; - const xPx = xBase * scaleX; - const yPx = slot.y * panelHeight * scaleY; + const localWidth = slot.w * panelWidth; + const localHeight = (slot.h ?? 0.12) * panelHeight; + const widthPx = localWidth * scaleX; + const heightPx = localHeight * scaleY; + + const baseCenterX = opts.panelOffset + slot.x * panelWidth + localWidth / 2; + const baseCenterY = slot.y * panelHeight + localHeight / 2; + let targetCenterX = baseCenterX; + let targetCenterY = baseCenterY; + let rotation = 0; + + if (isFoldable) { + const panelCenterX = opts.panelOffset + panelWidth / 2; + const panelCenterY = panelHeight / 2; + rotation = opts.panelOffset === 0 ? 90 : -90; + const rotated = rotatePoint(panelCenterX, panelCenterY, baseCenterX, baseCenterY, rotation); + targetCenterX = rotated.x; + targetCenterY = rotated.y; + } elements.push({ id: `${type}-${opts.panelOffset}-${opts.mirrored ? 'm' : 'n'}`, type, - x: xPx, - y: yPx, + x: targetCenterX * scaleX, + y: targetCenterY * scaleY, width: widthPx, height: heightPx, fontSize: slot.fontSize ?? (type === 'headline' ? 30 : type === 'subtitle' ? 18 : 16), @@ -705,24 +545,38 @@ function buildFabricOptions({ lineHeight: slot.lineHeight ?? 1.35, content, fill: textColor, + fontFamily: slot.fontFamily, + rotation, }); }; const addQr = (slot: SlotDefinition, opts: { panelOffset: number; mirrored: boolean }) => { if (!qrUrl) return; - const sizePx = slot.w * panelWidth * scaleX; - const xBase = opts.mirrored - ? opts.panelOffset + (panelWidth - (slot.x + slot.w) * panelWidth) - : opts.panelOffset + slot.x * panelWidth; - const xPx = xBase * scaleX; - const yPx = slot.y * panelHeight * scaleY; + const localSize = slot.w * panelWidth; + const sizePx = localSize * scaleX; + const baseCenterX = opts.panelOffset + slot.x * panelWidth + localSize / 2; + const baseCenterY = slot.y * panelHeight + localSize / 2; + let targetCenterX = baseCenterX; + let targetCenterY = baseCenterY; + let rotation = 0; + + if (isFoldable) { + const panelCenterX = opts.panelOffset + panelWidth / 2; + const panelCenterY = panelHeight / 2; + rotation = opts.panelOffset === 0 ? 90 : -90; + const rotated = rotatePoint(panelCenterX, panelCenterY, baseCenterX, baseCenterY, rotation); + targetCenterX = rotated.x; + targetCenterY = rotated.y; + } + elements.push({ id: `qr-${opts.panelOffset}-${opts.mirrored ? 'm' : 'n'}`, type: 'qr', - x: xPx, - y: yPx, + x: targetCenterX * scaleX, + y: targetCenterY * scaleY, width: sizePx, height: sizePx, + rotation, }); }; @@ -1075,7 +929,9 @@ function PreviewStep({ qrUrl, qrPngUrl, isFoldable, - onExport, + slotOverrides, + onUpdateSlot, + tenantFonts, }: { onBack: () => void; layout: EventQrInviteLayout | null; @@ -1087,7 +943,9 @@ function PreviewStep({ qrUrl: string; qrPngUrl?: string | null; isFoldable: boolean; - onExport: (format: 'pdf' | 'png') => void; + slotOverrides: SlotOverrides; + onUpdateSlot: (slot: string, patch: Partial) => void; + tenantFonts: TenantFont[]; }) { const { t } = useTranslation('management'); const presetSrc = backgroundPreset ? presets.find((p) => p.id === backgroundPreset)?.src ?? null : null; @@ -1098,13 +956,10 @@ function PreviewStep({ ? { angle: 180, stops: [layout.preview.background, layout.preview.background, layout.preview.background] } : null); const resolvedBgSolid = backgroundSolid ?? layout?.preview?.background ?? null; - const aspectRatio = - layout?.preview?.aspect_ratio ?? - ((layout?.orientation || '').toLowerCase() === 'landscape' ? 297 / 210 : 210 / 297); const backgroundImageUrl = presetSrc ?? null; + const canvasBase = React.useMemo(() => resolveCanvasBase(layout, isFoldable), [layout, isFoldable]); + const resolvedSlots = React.useMemo(() => resolveSlots(layout, isFoldable, slotOverrides), [isFoldable, layout, slotOverrides]); const exportOptions = React.useMemo(() => { - const svgWidth = (layout as any)?.preview?.svg?.width ?? CANVAS_WIDTH; - const svgHeight = (layout as any)?.preview?.svg?.height ?? CANVAS_HEIGHT; return buildFabricOptions({ layout, isFoldable, @@ -1114,81 +969,63 @@ function PreviewStep({ textFields, qrUrl, qrPngUrl, - baseWidth: svgWidth, - baseHeight: svgHeight, + baseWidth: canvasBase.width, + baseHeight: canvasBase.height, + slotOverrides, }); - }, [backgroundImageUrl, isFoldable, layout, qrUrl, qrPngUrl, resolvedBgGradient, resolvedBgSolid, textFields]); + }, [backgroundImageUrl, canvasBase.height, canvasBase.width, isFoldable, layout, qrPngUrl, qrUrl, resolvedBgGradient, resolvedBgSolid, slotOverrides, textFields]); - const cssBg = React.useMemo(() => { - if (presetSrc) { - const url = presetSrc.startsWith('http') || presetSrc.startsWith('data:') ? presetSrc : `${window.location.origin}${presetSrc}`; - return { backgroundImage: `url(${url})`, backgroundSize: 'cover', backgroundPosition: 'center' } as React.CSSProperties; + const [previewUrl, setPreviewUrl] = React.useState(null); + const [previewLoading, setPreviewLoading] = React.useState(false); + const loadFonts = React.useCallback(async () => { + const families = Array.from( + new Set( + Object.values(resolvedSlots) + .map((slot) => slot.fontFamily) + .filter(Boolean) as string[] + ) + ); + for (const family of families) { + const font = tenantFonts.find((item) => item.family === family); + if (font) { + await ensureFontLoaded(font); + } else if (typeof document !== 'undefined' && document.fonts?.load) { + await document.fonts.load(`400 1rem "${family}"`); + } } - if (resolvedBgGradient) { - return { - backgroundImage: `linear-gradient(${resolvedBgGradient.angle}deg, ${resolvedBgGradient.stops.join(',')})`, - } as React.CSSProperties; - } - return { backgroundColor: resolvedBgSolid ?? '#f8fafc' } as React.CSSProperties; - }, [presetSrc, resolvedBgGradient, resolvedBgSolid]); + }, [resolvedSlots, tenantFonts]); + React.useEffect(() => { + let cancelled = false; + async function renderPreview() { + setPreviewLoading(true); + try { + await loadFonts(); + const url = await generatePngDataUrl(exportOptions, 1); + if (!cancelled) { + setPreviewUrl(url); + } + } catch (error) { + console.error('[QR Preview] failed to render', error); + if (!cancelled) { + setPreviewUrl(null); + } + } finally { + if (!cancelled) { + setPreviewLoading(false); + } + } + } + renderPreview(); + return () => { + cancelled = true; + }; + }, [exportOptions, loadFonts]); + + const aspectRatio = `${canvasBase.width}/${canvasBase.height}`; + const paper = resolvePaper(layout); + const orientation = ((layout?.orientation ?? (isFoldable ? 'landscape' : 'portrait')) || '').toLowerCase() === 'landscape' ? 'landscape' : 'portrait'; const qrImageSrc = qrPngUrl ?? qrUrl ?? ''; - const qrSlot = React.useMemo(() => resolveSlots(layout, isFoldable).qr, [layout, isFoldable]); - - const Panel = () => ( - - {presetSrc ? ( - - ) : null} - {qrImageSrc ? ( - <> - QR - {isFoldable ? ( - QR - ) : null} - - ) : null} - - - ); return ( @@ -1201,26 +1038,50 @@ function PreviewStep({ - {isFoldable ? 'A5 Foldable' : 'A4 Poster'} + + {paper.toUpperCase()} {orientation === 'landscape' ? 'Landscape' : 'Portrait'} + {t('events.qr.preview', 'Vorschau')} - {isFoldable ? ( - - ) : ( - - )} + + {previewLoading ? ( + {t('common.loading', 'Lädt Vorschau …')} + ) : previewUrl ? ( + QR Layout Preview + ) : ( + {t('events.qr.missing', 'Kein QR-Link vorhanden')} + )} + + + { try { - const pdfBytes = await generatePdfBytes(exportOptions, 'a4', isFoldable ? 'landscape' : 'portrait'); + await loadFonts(); + const pdfBytes = await generatePdfBytes(exportOptions, paper, orientation); const blob = new Blob([pdfBytes], { type: 'application/pdf' }); triggerDownloadFromBlob(blob, 'qr-layout.pdf'); } catch (err) { @@ -1233,6 +1094,7 @@ function PreviewStep({ label={t('events.qr.exportPng', 'Export PNG')} onPress={async () => { try { + await loadFonts(); const dataUrl = await generatePngDataUrl(exportOptions); await triggerDownloadFromDataUrl(dataUrl, 'qr-layout.png'); } catch (err) { @@ -1245,3 +1107,261 @@ function PreviewStep({ ); } + +function LayoutControls({ + slots, + slotOverrides, + onUpdateSlot, + tenantFonts, + qrUrl, +}: { + slots: Record; + slotOverrides: SlotOverrides; + onUpdateSlot: (slot: string, patch: Partial) => void; + tenantFonts: TenantFont[]; + qrUrl: string | null; +}) { + const { t } = useTranslation('management'); + const fontOptions = React.useMemo(() => { + const preset = ['Playfair Display', 'Lora', 'Montserrat', 'Inter', 'Roboto']; + const tenant = tenantFonts.map((font) => font.family); + return Array.from(new Set([...tenant, ...preset])); + }, [tenantFonts]); + + const numberInputStyle: React.CSSProperties = { + width: '100%', + padding: '10px 12px', + border: '1px solid #e5e7eb', + borderRadius: 10, + fontSize: 14, + background: '#fff', + }; + + const selectStyle: React.CSSProperties = { + width: '100%', + padding: '10px 12px', + border: '1px solid #e5e7eb', + borderRadius: 10, + fontSize: 14, + background: '#fff', + }; + + const renderTextSlot = (slotKey: string, label: string) => { + const slot = slots[slotKey]; + if (!slot) return null; + const override = slotOverrides[slotKey] ?? {}; + + const onPercentChange = (field: 'x' | 'y' | 'w') => (value: number) => { + const pct = clampValue(value, 0, 100); + onUpdateSlot(slotKey, { [field]: pct / 100 }); + }; + + const onFontSizeChange = (value: number) => { + const next = clampValue(value, 8, 200); + onUpdateSlot(slotKey, { fontSize: next }); + }; + + const onLineHeightChange = (value: number) => { + const next = clampValue(value, 0.8, 3); + onUpdateSlot(slotKey, { lineHeight: next }); + }; + + const onFontFamilyChange = (value: string) => { + onUpdateSlot(slotKey, { fontFamily: value || undefined }); + }; + + return ( + + + {label} + + + + + X (%) + + onPercentChange('x')(Number(event.target.value))} + style={numberInputStyle} + /> + + + + Y (%) + + onPercentChange('y')(Number(event.target.value))} + style={numberInputStyle} + /> + + + + Breite (%) + + onPercentChange('w')(Number(event.target.value))} + style={numberInputStyle} + /> + + + + + + + Font Size (px) + + onFontSizeChange(Number(event.target.value))} + style={numberInputStyle} + /> + + + + Line Height + + onLineHeightChange(Number(event.target.value))} + style={numberInputStyle} + /> + + + + Align + + + + + + + + Font Family + + + + + ); + }; + + const qrSlot = slots.qr; + const qrOverride = slotOverrides.qr ?? {}; + + const onQrPercentChange = (field: 'x' | 'y' | 'w') => (value: number) => { + const pct = clampValue(value, 0, 100); + onUpdateSlot('qr', { [field]: pct / 100 }); + }; + + return ( + + + {t('events.qr.layoutControls', 'Layout & Schrift')} + + + {renderTextSlot('headline', t('events.qr.headline', 'Headline'))} + {renderTextSlot('subtitle', t('events.qr.subtitle', 'Subtitle'))} + {renderTextSlot('description', t('events.qr.description', 'Beschreibung'))} + {renderTextSlot('instructions', t('events.qr.instructions', 'Anleitung'))} + + + {qrSlot ? ( + + + {t('events.qr.qr_code_label', 'QR‑Code')} + + + + + X (%) + + onQrPercentChange('x')(Number(event.target.value))} + style={numberInputStyle} + /> + + + + Y (%) + + onQrPercentChange('y')(Number(event.target.value))} + style={numberInputStyle} + /> + + + + Größe (%) + + onQrPercentChange('w')(Number(event.target.value))} + style={numberInputStyle} + /> + + + {!qrUrl ? ( + + {t('events.qr.missing', 'Kein QR-Link vorhanden')} + + ) : null} + + ) : null} + + ); +} diff --git a/resources/js/admin/mobile/QrPrintPage.tsx b/resources/js/admin/mobile/QrPrintPage.tsx index 32d4f8d..2c9d18b 100644 --- a/resources/js/admin/mobile/QrPrintPage.tsx +++ b/resources/js/admin/mobile/QrPrintPage.tsx @@ -34,6 +34,7 @@ export default function MobileQrPrintPage() { const [error, setError] = React.useState(null); const [loading, setLoading] = React.useState(true); const [qrUrl, setQrUrl] = React.useState(''); + const [qrImage, setQrImage] = React.useState(''); React.useEffect(() => { if (!slug) return; @@ -56,6 +57,7 @@ export default function MobileQrPrintPage() { : initialLayout?.id ?? null; setSelectedLayoutId(resolvedLayoutId); setQrUrl(primaryInvite?.url ?? data.public_url ?? ''); + setQrImage(primaryInvite?.qr_code_data_url ?? ''); setError(null); } catch (err) { if (!isAuthError(err)) { @@ -103,7 +105,7 @@ export default function MobileQrPrintPage() { > {qrUrl ? ( QR @@ -118,16 +120,22 @@ export default function MobileQrPrintPage() { { - if (qrUrl) { - toast.success(t('events.qr.downloadStarted', 'Download gestartet')); - } else { - toast.error(t('events.qr.missing', 'Kein QR-Link vorhanden')); - } - }} - /> + label={t('events.qr.download', 'Download')} + fullWidth={false} + onPress={() => { + if (!qrUrl) { + toast.error(t('events.qr.missing', 'Kein QR-Link vorhanden')); + return; + } + const link = document.createElement('a'); + link.href = qrImage || `https://api.qrserver.com/v1/create-qr-code/?size=220x220&data=${encodeURIComponent(qrUrl)}`; + link.download = 'event-qr.png'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + toast.success(t('events.qr.downloadStarted', 'Download gestartet')); + }} + /> { if (resolved) return; console.debug('[Invites][Fabric] image loaded', { diff --git a/resources/js/admin/mobile/invite-layout/export-utils.ts b/resources/js/admin/mobile/invite-layout/export-utils.ts index 49f93fc..b9bc96d 100644 --- a/resources/js/admin/mobile/invite-layout/export-utils.ts +++ b/resources/js/admin/mobile/invite-layout/export-utils.ts @@ -6,6 +6,7 @@ import { FabricRenderOptions, renderFabricLayout } from './DesignerCanvas'; const PDF_PAGE_SIZES: Record = { a4: { width: 595.28, height: 841.89 }, + a5: { width: 419.53, height: 595.28 }, letter: { width: 612, height: 792 }, };