1248 lines
44 KiB
TypeScript
1248 lines
44 KiB
TypeScript
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 './invite-layout/export-utils';
|
||
import { LayoutElement, CANVAS_WIDTH, CANVAS_HEIGHT } from './invite-layout/schema';
|
||
import { buildInitialTextFields } from './qr/utils';
|
||
|
||
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>
|
||
);
|
||
}
|