Files
fotospiel-app/resources/js/admin/mobile/QrLayoutCustomizePage.tsx
2025-12-12 08:34:19 +01:00

1247 lines
44 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { ArrowLeft, RefreshCcw, Plus } from 'lucide-react';
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,
getEvent,
getEventQrInvites,
updateEventQrInvite,
} from '../api';
import { isAuthError } from '../auth/tokens';
import { getApiErrorMessage } from '../lib/apiError';
import toast from 'react-hot-toast';
import {
generatePdfBytes,
generatePngDataUrl,
triggerDownloadFromBlob,
triggerDownloadFromDataUrl,
} from '../pages/components/invite-layout/export-utils';
import { LayoutElement, CANVAS_WIDTH, CANVAS_HEIGHT } from '../pages/components/invite-layout/schema';
type Step = 'background' | 'text' | 'preview';
const BACKGROUND_PRESETS = [
{ id: 'bg-blue-floral', src: '/storage/layouts/backgrounds-portrait/bg-blue-floral.png', label: 'Blue Floral' },
{ id: 'bg-goldframe', src: '/storage/layouts/backgrounds-portrait/bg-goldframe.png', label: 'Gold Frame' },
{ id: 'gr-green-floral', src: '/storage/layouts/backgrounds-portrait/gr-green-floral.png', label: 'Green Floral' },
];
export function buildInitialTextFields({
customization,
layoutDefaults,
eventName,
}: {
customization: Record<string, unknown> | 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();
const slug = slugParam ?? null;
const tokenId = tokenParam ? Number(tokenParam) : null;
const layoutParam = searchParams.get('layout');
const navigate = useNavigate();
const { t } = useTranslation('management');
const [event, setEvent] = React.useState<TenantEvent | null>(null);
const [invite, setInvite] = React.useState<EventQrInvite | null>(null);
const [selectedLayoutId, setSelectedLayoutId] = React.useState<string | null>(layoutParam);
const [backgroundPreset, setBackgroundPreset] = React.useState<string | null>(null);
const [backgroundGradient, setBackgroundGradient] = React.useState<{ angle: number; stops: string[] } | null>(null);
const [backgroundSolid, setBackgroundSolid] = React.useState<string | null>(null);
const [textFields, setTextFields] = React.useState({
headline: '',
subtitle: '',
description: '',
instructions: [''],
});
const [qrPngUrl, setQrPngUrl] = React.useState<string | null>(null);
const [step, setStep] = React.useState<Step>('background');
const [saving, setSaving] = React.useState(false);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const handleSelectPreset = React.useCallback((id: string | null) => {
setBackgroundPreset(id);
if (id) {
setBackgroundGradient(null);
setBackgroundSolid(null);
}
}, []);
const handleSelectGradient = React.useCallback((gradient: { angle: number; stops: string[] } | null) => {
setBackgroundGradient(gradient);
if (gradient) {
setBackgroundPreset(null);
setBackgroundSolid(null);
}
}, []);
const handleSelectSolid = React.useCallback((color: string | null) => {
setBackgroundSolid(color);
if (color) {
setBackgroundPreset(null);
setBackgroundGradient(null);
}
}, []);
React.useEffect(() => {
if (!slug) return;
(async () => {
setLoading(true);
try {
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;
setEvent(eventData);
setInvite(matchedInvite ?? null);
setQrPngUrl(matchedInvite?.qr_code_data_url ?? null);
const customization = (matchedInvite?.metadata as any)?.layout_customization ?? {};
const initialLayoutId = layoutParam ?? customization.layout_id ?? matchedInvite?.layouts?.[0]?.id ?? null;
const layoutDefaults = matchedInvite?.layouts?.find((l) => l.id === initialLayoutId) ?? matchedInvite?.layouts?.[0] ?? null;
setBackgroundPreset(typeof customization.background_preset === 'string' ? customization.background_preset : null);
setSelectedLayoutId(initialLayoutId);
setBackgroundGradient(customization.background_gradient ?? null);
setBackgroundSolid(customization.background_color ?? null);
setTextFields(
buildInitialTextFields({
customization,
layoutDefaults,
eventName: eventData?.name ?? null,
})
);
setError(null);
} catch (err) {
if (!isAuthError(err)) {
setError(getApiErrorMessage(err, t('events.errors.loadFailed', 'QR-Daten konnten nicht geladen werden.')));
}
} finally {
setLoading(false);
}
})();
}, [slug, tokenId, layoutParam, t]);
const selectedLayout = invite?.layouts.find((l) => l.id === selectedLayoutId) ?? invite?.layouts?.[0] ?? null;
const isFoldable = (selectedLayout?.panel_mode ?? '').toLowerCase() === 'double-mirror';
const handleSave = async () => {
if (!slug || !invite || !selectedLayout) {
toast.error(t('events.qr.missing', 'Kein QR-Link vorhanden'));
return;
}
setSaving(true);
try {
const payload = {
metadata: {
layout_customization: {
layout_id: selectedLayout.id,
background_preset: backgroundPreset,
background_gradient: backgroundGradient ?? undefined,
background_color: backgroundSolid ?? undefined,
headline: textFields.headline || null,
subtitle: textFields.subtitle || null,
description: textFields.description || null,
instructions: textFields.instructions.filter((item) => item.trim().length > 0),
},
},
};
const updated = await updateEventQrInvite(slug, invite.id, payload);
setInvite(updated);
toast.success(t('common.saved', 'Gespeichert'));
} catch (err) {
toast.error(getApiErrorMessage(err, t('events.errors.saveFailed', 'Speichern fehlgeschlagen.')));
} finally {
setSaving(false);
}
};
return (
<MobileShell
activeTab="home"
title={t('events.qr.customize', 'Layout anpassen')}
onBack={() => navigate(-1)}
headerActions={
<Pressable onPress={() => window.location.reload()}>
<RefreshCcw size={18} color="#0f172a" />
</Pressable>
}
>
{error ? (
<MobileCard>
<Text fontWeight="700" color="#b91c1c">
{error}
</Text>
</MobileCard>
) : null}
<Stepper current={step} onStepChange={setStep} />
<MobileCard space="$2" marginTop="$2">
{step === 'background' && (
<BackgroundStep
onBack={() => navigate(-1)}
presets={BACKGROUND_PRESETS}
selectedPreset={backgroundPreset}
onSelectPreset={handleSelectPreset}
selectedLayout={selectedLayout}
layouts={invite?.layouts ?? []}
onSelectGradient={handleSelectGradient}
onSelectSolid={handleSelectSolid}
selectedGradient={backgroundGradient}
selectedSolid={backgroundSolid}
onSave={() => {
handleSave();
setStep('text');
}}
saving={saving || loading}
/>
)}
{step === 'text' && (
<TextStep
onBack={() => setStep('background')}
textFields={textFields}
onChange={(fields) => setTextFields(fields)}
onSave={() => {
handleSave();
setStep('preview');
}}
saving={saving || loading}
/>
)}
{step === 'preview' && (
<PreviewStep
onBack={() => setStep('text')}
layout={selectedLayout}
backgroundPreset={backgroundPreset}
backgroundGradient={backgroundGradient}
backgroundSolid={backgroundSolid}
presets={BACKGROUND_PRESETS}
textFields={textFields}
qrUrl={qrPngUrl ?? invite?.url ?? ''}
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');
}}
/>
)}
</MobileCard>
</MobileShell>
);
}
function Stepper({ current, onStepChange }: { current: Step; onStepChange: (step: Step) => void }) {
const { t } = useTranslation('management');
const steps: { key: Step; label: string }[] = [
{ key: 'background', label: t('events.qr.background', 'Hintergrund') },
{ key: 'text', label: t('events.qr.text', 'Text') },
{ key: 'preview', label: t('events.qr.preview', 'Vorschau') },
];
const currentIndex = steps.findIndex((s) => s.key === current);
const progress = ((currentIndex + 1) / steps.length) * 100;
return (
<YStack space="$2" marginTop="$2">
<XStack space="$2" alignItems="center">
{steps.map((step, idx) => {
const active = step.key === current;
const completed = idx < currentIndex;
return (
<Pressable key={step.key} onPress={() => onStepChange(step.key)} style={{ flex: 1 }}>
<YStack
borderRadius={12}
borderWidth={1}
borderColor={active ? '#2563EB' : completed ? '#16a34a' : '#e5e7eb'}
backgroundColor={active ? '#eff6ff' : '#fff'}
padding="$2"
alignItems="center"
justifyContent="center"
>
<Text fontSize="$sm" fontWeight="700" color="#111827">
{step.label}
</Text>
<Text fontSize="$xs" color="#6b7280">
{t('events.qr.step', 'Schritt')} {idx + 1}
</Text>
</YStack>
</Pressable>
);
})}
</XStack>
<YStack height={8} borderRadius={999} backgroundColor="#e5e7eb" overflow="hidden">
<YStack height="100%" width={`${progress}%`} backgroundColor="#2563EB" />
</YStack>
</YStack>
);
}
type SlotDefinition = {
x: number;
y: number;
w: number;
h?: number;
fontSize?: number;
fontWeight?: string | number;
lineHeight?: number;
align?: 'left' | 'center' | 'right';
};
function getDefaultSlots(): Record<string, SlotDefinition> {
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 },
qr: { x: 0.35, y: 0.55, w: 0.3 },
};
}
function resolveSlots(layout: EventQrInviteLayout | null, isFoldable: boolean): Record<string, SlotDefinition> {
const fromLayout = (layout as any)?.slots;
if (fromLayout && typeof fromLayout === 'object') {
return Object.entries(fromLayout as Record<string, unknown>).reduce<Record<string, SlotDefinition>>((acc, [key, value]) => {
if (value && typeof value === 'object') {
const slot = value as Record<string, unknown>;
acc[key] = {
x: Number(slot.x ?? 0),
y: Number(slot.y ?? 0),
w: Number(slot.w ?? 0.8),
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,
lineHeight: typeof slot.lineHeight === 'number' ? slot.lineHeight : undefined,
align: typeof slot.align === 'string' ? (slot.align as SlotDefinition['align']) : undefined,
};
}
return acc;
}, {});
}
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 },
};
}
return getDefaultSlots();
}
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<HTMLCanvasElement | null>(null);
const slots = React.useMemo(() => resolveSlots(layout, isFoldable), [layout, isFoldable]);
const containerRef = React.useRef<HTMLDivElement | null>(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 });
};
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<void>((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 (
<YStack
ref={containerRef}
position="relative"
width="100%"
height="100%"
borderRadius={16}
overflow="hidden"
backgroundColor="transparent"
borderWidth={1}
borderColor="#e5e7eb"
minHeight={frame?.height ?? undefined}
>
<canvas
ref={canvasRef}
style={{
width: frame ? `${frame.width}px` : '100%',
height: frame ? `${frame.height}px` : '100%',
display: 'block',
backgroundColor: 'transparent',
position: 'absolute',
inset: 0,
pointerEvents: 'none',
}}
/>
</YStack>
);
}
function buildFabricOptions({
layout,
isFoldable,
backgroundGradient,
backgroundSolid,
backgroundImageUrl,
textFields,
qrUrl,
qrPngUrl,
baseWidth = CANVAS_WIDTH,
baseHeight = CANVAS_HEIGHT,
}: {
layout: EventQrInviteLayout | null;
isFoldable: boolean;
backgroundGradient: { angle: number; stops: string[] } | null;
backgroundSolid: string | null;
backgroundImageUrl: string | null;
textFields: { headline: string; subtitle: string; description: string; instructions: string[] };
qrUrl: string;
qrPngUrl?: string | null;
baseWidth?: number;
baseHeight?: number;
}) {
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 elements: LayoutElement[] = [];
const textColor = layout?.preview?.text ?? '#0f172a';
const accentColor = layout?.preview?.accent ?? '#2563EB';
const secondaryColor = layout?.preview?.secondary ?? '#1f2937';
const badgeColor = layout?.preview?.badge ?? accentColor;
const addTextElement = (
type: LayoutElement['type'],
content: string,
slot: SlotDefinition,
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;
elements.push({
id: `${type}-${opts.panelOffset}-${opts.mirrored ? 'm' : 'n'}`,
type,
x: xPx,
y: yPx,
width: widthPx,
height: heightPx,
fontSize: slot.fontSize ?? (type === 'headline' ? 30 : type === 'subtitle' ? 18 : 16),
align: slot.align ?? 'center',
lineHeight: slot.lineHeight ?? 1.35,
content,
fill: textColor,
});
};
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;
elements.push({
id: `qr-${opts.panelOffset}-${opts.mirrored ? 'm' : 'n'}`,
type: 'qr',
x: xPx,
y: yPx,
width: sizePx,
height: sizePx,
});
};
const instructions = textFields.instructions.filter((i) => i.trim().length > 0);
const instructionContent = instructions.map((line, idx) => `${idx + 1}. ${line}`).join('\n');
const drawPanelElements = (panelOffset: number, mirrored: boolean) => {
addTextElement('headline', textFields.headline, slots.headline, { panelOffset, mirrored });
addTextElement('subtitle', textFields.subtitle, slots.subtitle, { panelOffset, mirrored });
addTextElement('description', textFields.description, slots.description, { panelOffset, mirrored });
if (instructionContent) {
addTextElement('description', instructionContent, slots.instructions, { panelOffset, mirrored });
}
addQr(slots.qr, { panelOffset, mirrored });
};
drawPanelElements(0, false);
if (isFoldable) {
drawPanelElements(panelWidth, true);
}
return {
elements,
accentColor,
textColor,
secondaryColor,
badgeColor,
qrCodeDataUrl: qrPngUrl ?? (qrUrl ? `https://api.qrserver.com/v1/create-qr-code/?size=400x400&data=${encodeURIComponent(qrUrl)}` : null),
logoDataUrl: null,
backgroundColor: backgroundSolid ?? layout?.preview?.background ?? '#ffffff',
backgroundGradient,
backgroundImageUrl,
readOnly: true,
} as const;
}
function BackgroundStep({
onBack,
presets,
selectedPreset,
onSelectPreset,
selectedLayout,
layouts,
onSelectGradient,
onSelectSolid,
selectedGradient,
selectedSolid,
onSave,
saving,
}: {
onBack: () => void;
presets: { id: string; src: string; label: string }[];
selectedPreset: string | null;
onSelectPreset: (id: string) => void;
selectedLayout: EventQrInviteLayout | null;
layouts: EventQrInviteLayout[];
onSelectGradient: (gradient: { angle: number; stops: string[] } | null) => void;
onSelectSolid: (color: string | null) => void;
selectedGradient: { angle: number; stops: string[] } | null;
selectedSolid: string | null;
onSave: () => void;
saving: boolean;
}) {
const { t } = useTranslation('management');
const resolvedLayout =
selectedLayout ??
layouts.find((layout) => {
const panel = (layout.panel_mode ?? '').toLowerCase();
const orientation = (layout.orientation ?? '').toLowerCase();
return panel === 'double-mirror' || orientation === 'landscape';
}) ??
layouts[0] ??
null;
const isFoldable = (resolvedLayout?.panel_mode ?? '').toLowerCase() === 'double-mirror';
const formatLabel = isFoldable ? 'A4 Landscape (Double/Mirror)' : 'A4 Portrait';
const disablePresets = isFoldable;
const gradientPresets = [
{ angle: 180, stops: ['#F8FAFC', '#EEF2FF', '#F8FAFC'], label: 'Soft Lilac' },
{ angle: 135, stops: ['#FEE2E2', '#EFF6FF', '#ECFDF3'], label: 'Pastell' },
{ angle: 210, stops: ['#0B132B', '#1C2541', '#274690'], label: 'Midnight' },
];
const solidPresets = ['#FFFFFF', '#F8FAFC', '#0F172A', '#2563EB', '#F59E0B'];
return (
<YStack space="$3" marginTop="$2">
<XStack alignItems="center" space="$2">
<Pressable onPress={onBack}>
<XStack alignItems="center" space="$1">
<ArrowLeft size={16} color="#111827" />
<Text fontSize="$sm" color="#111827">
{t('common.back', 'Zurück')}
</Text>
</XStack>
</Pressable>
<PillBadge tone="muted">{formatLabel}</PillBadge>
</XStack>
<YStack space="$2">
<Text fontSize="$sm" fontWeight="700" color="#111827">
{disablePresets
? t('events.qr.backgroundPicker', 'Hintergrund für A5 (Gradient/Farbe)')
: t('events.qr.backgroundPicker', `Hintergrund auswählen (${formatLabel})`)}
</Text>
{!disablePresets ? (
<>
<XStack flexWrap="wrap" gap="$2">
{presets.map((preset) => {
const isSelected = selectedPreset === preset.id;
return (
<Pressable key={preset.id} onPress={() => onSelectPreset(preset.id)} style={{ width: '48%' }}>
<YStack
aspectRatio={210 / 297}
maxHeight={220}
borderRadius={14}
overflow="hidden"
borderWidth={isSelected ? 2 : 1}
borderColor={isSelected ? '#2563EB' : '#e5e7eb'}
backgroundColor="#f8fafc"
>
<YStack
flex={1}
backgroundImage={`url(${preset.src})`}
backgroundSize="cover"
backgroundPosition="center"
/>
<XStack padding="$2" justifyContent="space-between" alignItems="center" backgroundColor="rgba(255,255,255,0.92)">
<Text fontSize="$xs" color="#111827">
{preset.label}
</Text>
{isSelected ? <PillBadge tone="muted">{t('common.selected', 'Ausgewählt')}</PillBadge> : null}
</XStack>
</YStack>
</Pressable>
);
})}
</XStack>
<Text fontSize="$xs" color="#6b7280">
{t('events.qr.backgroundNote', 'Diese Presets sind für A4 Hochformat. Spiegelung erfolgt automatisch bei Tischkarten.')}
</Text>
</>
) : (
<Text fontSize="$xs" color="#6b7280">
{t('events.qr.foldableBackgroundNote', 'Für A5 Tischkarten bitte Gradient oder Vollfarbe wählen.')}
</Text>
)}
<YStack space="$2" marginTop="$2">
<Text fontSize="$sm" fontWeight="700" color="#111827">
{t('events.qr.gradients', 'Gradienten')}
</Text>
<XStack flexWrap="wrap" gap="$2">
{gradientPresets.map((gradient, idx) => {
const isSelected =
selectedGradient &&
selectedGradient.angle === gradient.angle &&
JSON.stringify(selectedGradient.stops) === JSON.stringify(gradient.stops);
const style = {
backgroundImage: `linear-gradient(${gradient.angle}deg, ${gradient.stops.join(',')})`,
} as React.CSSProperties;
return (
<Pressable key={idx} onPress={() => onSelectGradient(gradient)} style={{ width: '30%' }}>
<YStack
height={70}
borderRadius={12}
overflow="hidden"
borderWidth={isSelected ? 2 : 1}
borderColor={isSelected ? '#2563EB' : '#e5e7eb'}
style={style}
/>
</Pressable>
);
})}
</XStack>
</YStack>
<YStack space="$2" marginTop="$2">
<Text fontSize="$sm" fontWeight="700" color="#111827">
{t('events.qr.colors', 'Vollfarbe')}
</Text>
<XStack flexWrap="wrap" gap="$2">
{solidPresets.map((color) => {
const isSelected = selectedSolid === color;
return (
<Pressable key={color} onPress={() => onSelectSolid(color)} style={{ width: '20%' }}>
<YStack
height={50}
borderRadius={12}
overflow="hidden"
borderWidth={isSelected ? 2 : 1}
borderColor={isSelected ? '#2563EB' : '#e5e7eb'}
backgroundColor={color}
/>
</Pressable>
);
})}
</XStack>
</YStack>
</YStack>
<CTAButton
label={saving ? t('common.saving', 'Speichern …') : t('common.next', 'Weiter')}
disabled={saving || !resolvedLayout}
onPress={onSave}
/>
</YStack>
);
}
function TextStep({
onBack,
textFields,
onChange,
onSave,
saving,
}: {
onBack: () => void;
textFields: { headline: string; subtitle: string; description: string; instructions: string[] };
onChange: (fields: { headline: string; subtitle: string; description: string; instructions: string[] }) => void;
onSave: () => void;
saving: boolean;
}) {
const { t } = useTranslation('management');
const updateField = (key: 'headline' | 'subtitle' | 'description', value: string) => {
onChange({ ...textFields, [key]: value });
};
const updateInstruction = (idx: number, value: string) => {
const next = [...textFields.instructions];
next[idx] = value;
onChange({ ...textFields, instructions: next });
};
const addInstruction = () => {
onChange({ ...textFields, instructions: [...textFields.instructions, ''] });
};
const removeInstruction = (idx: number) => {
const next = textFields.instructions.filter((_, i) => i !== idx);
onChange({ ...textFields, instructions: next.length ? next : [''] });
};
return (
<YStack space="$3" marginTop="$2">
<XStack alignItems="center" space="$2">
<Pressable onPress={onBack}>
<XStack alignItems="center" space="$1">
<ArrowLeft size={16} color="#111827" />
<Text fontSize="$sm" color="#111827">
{t('common.back', 'Zurück')}
</Text>
</XStack>
</Pressable>
<PillBadge tone="muted">{t('events.qr.textStep', 'Texte & Hinweise')}</PillBadge>
</XStack>
<YStack space="$2">
<Text fontSize="$sm" fontWeight="700" color="#111827">
{t('events.qr.textFields', 'Texte')}
</Text>
<Input
placeholder={t('events.qr.headline', 'Headline')}
value={textFields.headline}
onChangeText={(val) => updateField('headline', val)}
size="$4"
/>
<Input
placeholder={t('events.qr.subtitle', 'Subtitle')}
value={textFields.subtitle}
onChangeText={(val) => updateField('subtitle', val)}
size="$4"
/>
<TextArea
placeholder={t('events.qr.description', 'Beschreibung')}
value={textFields.description}
onChangeText={(val) => updateField('description', val)}
size="$4"
numberOfLines={3}
/>
</YStack>
<YStack space="$2">
<Text fontSize="$sm" fontWeight="700" color="#111827">
{t('events.qr.instructions', 'Anleitung')}
</Text>
{textFields.instructions.map((item, idx) => (
<XStack key={idx} alignItems="center" space="$2">
<TextArea
value={item}
onChangeText={(val) => updateInstruction(idx, val)}
placeholder={t('events.qr.instructionsPlaceholder', 'Schritt hinzufügen')}
numberOfLines={2}
flex={1}
size="$4"
/>
<Pressable onPress={() => removeInstruction(idx)} disabled={textFields.instructions.length === 1}>
<XStack
width={44}
height={44}
borderRadius={12}
alignItems="center"
justifyContent="center"
borderWidth={1}
borderColor="#e5e7eb"
backgroundColor="#fff"
>
<Text fontSize="$md" fontWeight="800" color="#111827">
</Text>
</XStack>
</Pressable>
{idx === textFields.instructions.length - 1 ? (
<Pressable onPress={addInstruction}>
<XStack
width={44}
height={44}
borderRadius={12}
alignItems="center"
justifyContent="center"
borderWidth={1}
borderColor="#e5e7eb"
backgroundColor="#fff"
>
<Plus size={18} color="#111827" />
</XStack>
</Pressable>
) : null}
</XStack>
))}
</YStack>
<CTAButton
label={saving ? t('common.saving', 'Speichern …') : t('events.qr.preview', 'Vorschau')}
disabled={saving}
onPress={onSave}
/>
</YStack>
);
}
function PreviewStep({
onBack,
layout,
backgroundPreset,
backgroundGradient,
backgroundSolid,
presets,
textFields,
qrUrl,
qrPngUrl,
isFoldable,
onExport,
}: {
onBack: () => void;
layout: EventQrInviteLayout | null;
backgroundPreset: string | null;
backgroundGradient: { angle: number; stops: string[] } | null;
backgroundSolid: string | null;
presets: { id: string; src: string; label: string }[];
textFields: { headline: string; subtitle: string; description: string; instructions: string[] };
qrUrl: string;
qrPngUrl?: string | null;
isFoldable: boolean;
onExport: (format: 'pdf' | 'png') => void;
}) {
const { t } = useTranslation('management');
const presetSrc = backgroundPreset ? presets.find((p) => p.id === backgroundPreset)?.src ?? null : null;
const resolvedBgGradient =
backgroundGradient ??
layout?.preview?.background_gradient ??
(layout?.preview?.background
? { 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 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,
backgroundGradient: resolvedBgGradient,
backgroundSolid: resolvedBgSolid,
backgroundImageUrl,
textFields,
qrUrl,
qrPngUrl,
baseWidth: svgWidth,
baseHeight: svgHeight,
});
}, [backgroundImageUrl, isFoldable, layout, qrUrl, qrPngUrl, resolvedBgGradient, resolvedBgSolid, 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;
}
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]);
const qrImageSrc = qrPngUrl ?? qrUrl ?? '';
const qrSlot = React.useMemo(() => resolveSlots(layout, isFoldable).qr, [layout, isFoldable]);
const Panel = () => (
<YStack width="100%" maxWidth={380} aspectRatio={aspectRatio} borderRadius={16} overflow="hidden" position="relative" style={cssBg}>
{presetSrc ? (
<img
src={presetSrc.startsWith('http') || presetSrc.startsWith('data:') ? presetSrc : `${window.location.origin}${presetSrc}`}
alt=""
aria-hidden
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover', zIndex: 0 }}
/>
) : null}
{qrImageSrc ? (
<>
<img
src={qrImageSrc}
alt="QR"
style={{
position: 'absolute',
left: `${qrSlot.x * 100}%`,
top: `${qrSlot.y * 100}%`,
width: `${qrSlot.w * 100}%`,
height: 'auto',
objectFit: 'contain',
zIndex: 5,
}}
/>
{isFoldable ? (
<img
src={qrImageSrc}
alt="QR"
style={{
position: 'absolute',
left: `${50 + qrSlot.x * 50}%`,
top: `${qrSlot.y * 100}%`,
width: `${qrSlot.w * 50}%`,
height: 'auto',
objectFit: 'contain',
transform: 'scaleX(-1)',
zIndex: 5,
}}
/>
) : null}
</>
) : null}
<FabricCanvasPreview
layout={layout}
isFoldable={isFoldable}
backgroundPresetSrc={presetSrc}
backgroundGradient={resolvedBgGradient}
backgroundSolid={resolvedBgSolid}
textFields={textFields}
qrUrl={qrPngUrl ?? qrUrl}
/>
</YStack>
);
return (
<YStack space="$3" marginTop="$2">
<XStack alignItems="center" space="$2">
<Pressable onPress={onBack}>
<XStack alignItems="center" space="$1">
<ArrowLeft size={16} color="#111827" />
<Text fontSize="$sm" color="#111827">
{t('common.back', 'Zurück')}
</Text>
</XStack>
</Pressable>
<PillBadge tone="muted">{isFoldable ? 'A5 Foldable' : 'A4 Poster'}</PillBadge>
</XStack>
<YStack space="$2">
<Text fontSize="$sm" fontWeight="700" color="#111827">
{t('events.qr.preview', 'Vorschau')}
</Text>
{isFoldable ? (
<Panel />
) : (
<Panel />
)}
</YStack>
<XStack space="$2">
<CTAButton
label={t('events.qr.exportPdf', 'Export PDF')}
onPress={async () => {
try {
const pdfBytes = await generatePdfBytes(exportOptions, 'a4', isFoldable ? 'landscape' : 'portrait');
const blob = new Blob([pdfBytes], { type: 'application/pdf' });
triggerDownloadFromBlob(blob, 'qr-layout.pdf');
} catch (err) {
toast.error(t('events.qr.exportFailed', 'Export fehlgeschlagen'));
console.error(err);
}
}}
/>
<CTAButton
label={t('events.qr.exportPng', 'Export PNG')}
onPress={async () => {
try {
const dataUrl = await generatePngDataUrl(exportOptions);
await triggerDownloadFromDataUrl(dataUrl, 'qr-layout.png');
} catch (err) {
toast.error(t('events.qr.exportFailed', 'Export fehlgeschlagen'));
console.error(err);
}
}}
/>
</XStack>
</YStack>
);
}