1368 lines
48 KiB
TypeScript
1368 lines
48 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 { 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,
|
||
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 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 [slotOverrides, setSlotOverrides] = React.useState<Record<string, Partial<SlotDefinition>>>({});
|
||
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 { fonts: tenantFonts } = useTenantFonts();
|
||
|
||
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);
|
||
}
|
||
}, []);
|
||
|
||
const handleUpdateSlot = React.useCallback((slotKey: string, patch: Partial<SlotDefinition>) => {
|
||
setSlotOverrides((prev) => ({
|
||
...prev,
|
||
[slotKey]: {
|
||
...(prev[slotKey] ?? {}),
|
||
...patch,
|
||
},
|
||
}));
|
||
}, []);
|
||
|
||
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;
|
||
const eventName = renderEventName(eventData?.name);
|
||
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,
|
||
})
|
||
);
|
||
setSlotOverrides(normalizeSlotOverrides((customization as any)?.slots));
|
||
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),
|
||
slots: slotOverrides,
|
||
},
|
||
},
|
||
};
|
||
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 ?? ''}
|
||
qrPngUrl={qrPngUrl}
|
||
isFoldable={isFoldable}
|
||
slotOverrides={slotOverrides}
|
||
onUpdateSlot={handleUpdateSlot}
|
||
tenantFonts={tenantFonts}
|
||
/>
|
||
)}
|
||
</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;
|
||
fontFamily?: string;
|
||
lineHeight?: number;
|
||
align?: 'left' | 'center' | 'right';
|
||
};
|
||
|
||
type SlotOverrides = Record<string, Partial<SlotDefinition>>;
|
||
type PaperSize = 'a4' | 'a5';
|
||
|
||
const PAPER_MM: Record<PaperSize, { width: number; height: number }> = {
|
||
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<string, unknown>).reduce<SlotOverrides>((acc, [key, value]) => {
|
||
if (!value || typeof value !== 'object') return acc;
|
||
const slot = value as Record<string, unknown>;
|
||
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<string, SlotDefinition> {
|
||
return {
|
||
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, overrides: SlotOverrides = {}): Record<string, SlotDefinition> {
|
||
const fromLayout = (layout as any)?.slots;
|
||
if (fromLayout && typeof fromLayout === 'object') {
|
||
const base = 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,
|
||
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);
|
||
}
|
||
|
||
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 applySlotOverrides(baseSlots, overrides);
|
||
}
|
||
|
||
function applySlotOverrides(baseSlots: Record<string, SlotDefinition>, overrides: SlotOverrides): Record<string, SlotDefinition> {
|
||
const keys = new Set([...Object.keys(baseSlots), ...Object.keys(overrides)]);
|
||
return Array.from(keys).reduce<Record<string, SlotDefinition>>((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,
|
||
};
|
||
return acc;
|
||
}, {});
|
||
}
|
||
|
||
function buildFabricOptions({
|
||
layout,
|
||
isFoldable,
|
||
backgroundGradient,
|
||
backgroundSolid,
|
||
backgroundImageUrl,
|
||
textFields,
|
||
qrUrl,
|
||
qrPngUrl,
|
||
baseWidth = CANVAS_WIDTH,
|
||
baseHeight = CANVAS_HEIGHT,
|
||
slotOverrides = {},
|
||
}: {
|
||
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;
|
||
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, slotOverrides);
|
||
|
||
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 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,
|
||
slot: SlotDefinition,
|
||
opts: { panelOffset: number; mirrored: boolean }
|
||
) => {
|
||
if (!content) return;
|
||
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: targetCenterX * scaleX,
|
||
y: targetCenterY * scaleY,
|
||
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,
|
||
fontFamily: slot.fontFamily,
|
||
rotation,
|
||
});
|
||
};
|
||
|
||
const addQr = (slot: SlotDefinition, opts: { panelOffset: number; mirrored: boolean }) => {
|
||
if (!qrUrl) return;
|
||
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: targetCenterX * scaleX,
|
||
y: targetCenterY * scaleY,
|
||
width: sizePx,
|
||
height: sizePx,
|
||
rotation,
|
||
});
|
||
};
|
||
|
||
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,
|
||
slotOverrides,
|
||
onUpdateSlot,
|
||
tenantFonts,
|
||
}: {
|
||
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;
|
||
slotOverrides: SlotOverrides;
|
||
onUpdateSlot: (slot: string, patch: Partial<SlotDefinition>) => void;
|
||
tenantFonts: TenantFont[];
|
||
}) {
|
||
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 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(() => {
|
||
return buildFabricOptions({
|
||
layout,
|
||
isFoldable,
|
||
backgroundGradient: resolvedBgGradient,
|
||
backgroundSolid: resolvedBgSolid,
|
||
backgroundImageUrl,
|
||
textFields,
|
||
qrUrl,
|
||
qrPngUrl,
|
||
baseWidth: canvasBase.width,
|
||
baseHeight: canvasBase.height,
|
||
slotOverrides,
|
||
});
|
||
}, [backgroundImageUrl, canvasBase.height, canvasBase.width, isFoldable, layout, qrPngUrl, qrUrl, resolvedBgGradient, resolvedBgSolid, slotOverrides, textFields]);
|
||
|
||
const [previewUrl, setPreviewUrl] = React.useState<string | null>(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}"`);
|
||
}
|
||
}
|
||
}, [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 ?? '';
|
||
|
||
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">
|
||
{paper.toUpperCase()} {orientation === 'landscape' ? 'Landscape' : 'Portrait'}
|
||
</PillBadge>
|
||
</XStack>
|
||
|
||
<YStack space="$2">
|
||
<Text fontSize="$sm" fontWeight="700" color="#111827">
|
||
{t('events.qr.preview', 'Vorschau')}
|
||
</Text>
|
||
<YStack
|
||
width="100%"
|
||
maxWidth={420}
|
||
borderRadius={16}
|
||
overflow="hidden"
|
||
borderWidth={1}
|
||
borderColor="#e5e7eb"
|
||
backgroundColor="#fff"
|
||
style={{ aspectRatio }}
|
||
alignItems="center"
|
||
justifyContent="center"
|
||
>
|
||
{previewLoading ? (
|
||
<Text color="#6b7280">{t('common.loading', 'Lädt Vorschau …')}</Text>
|
||
) : previewUrl ? (
|
||
<img
|
||
src={previewUrl}
|
||
alt="QR Layout Preview"
|
||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||
/>
|
||
) : (
|
||
<Text color="#6b7280">{t('events.qr.missing', 'Kein QR-Link vorhanden')}</Text>
|
||
)}
|
||
</YStack>
|
||
</YStack>
|
||
|
||
<LayoutControls slots={resolvedSlots} slotOverrides={slotOverrides} onUpdateSlot={onUpdateSlot} tenantFonts={tenantFonts} qrUrl={qrImageSrc} />
|
||
|
||
<XStack space="$2">
|
||
<CTAButton
|
||
label={t('events.qr.exportPdf', 'Export PDF')}
|
||
onPress={async () => {
|
||
try {
|
||
await loadFonts();
|
||
const pdfBytes = await generatePdfBytes(exportOptions, paper, orientation);
|
||
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 {
|
||
await loadFonts();
|
||
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>
|
||
);
|
||
}
|
||
|
||
function LayoutControls({
|
||
slots,
|
||
slotOverrides,
|
||
onUpdateSlot,
|
||
tenantFonts,
|
||
qrUrl,
|
||
}: {
|
||
slots: Record<string, SlotDefinition>;
|
||
slotOverrides: SlotOverrides;
|
||
onUpdateSlot: (slot: string, patch: Partial<SlotDefinition>) => 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 (
|
||
<YStack key={slotKey} space="$2" padding="$2" borderWidth={1} borderColor="#e5e7eb" borderRadius={12}>
|
||
<Text fontSize="$sm" fontWeight="700" color="#111827">
|
||
{label}
|
||
</Text>
|
||
<XStack space="$2">
|
||
<YStack flex={1} space="$1">
|
||
<Text fontSize="$xs" color="#6b7280">
|
||
X (%)
|
||
</Text>
|
||
<input
|
||
type="number"
|
||
step="0.5"
|
||
min="0"
|
||
max="100"
|
||
value={((override.x ?? slot.x) * 100).toFixed(1)}
|
||
onChange={(event) => onPercentChange('x')(Number(event.target.value))}
|
||
style={numberInputStyle}
|
||
/>
|
||
</YStack>
|
||
<YStack flex={1} space="$1">
|
||
<Text fontSize="$xs" color="#6b7280">
|
||
Y (%)
|
||
</Text>
|
||
<input
|
||
type="number"
|
||
step="0.5"
|
||
min="0"
|
||
max="100"
|
||
value={((override.y ?? slot.y) * 100).toFixed(1)}
|
||
onChange={(event) => onPercentChange('y')(Number(event.target.value))}
|
||
style={numberInputStyle}
|
||
/>
|
||
</YStack>
|
||
<YStack flex={1} space="$1">
|
||
<Text fontSize="$xs" color="#6b7280">
|
||
Breite (%)
|
||
</Text>
|
||
<input
|
||
type="number"
|
||
step="0.5"
|
||
min="10"
|
||
max="100"
|
||
value={((override.w ?? slot.w) * 100).toFixed(1)}
|
||
onChange={(event) => onPercentChange('w')(Number(event.target.value))}
|
||
style={numberInputStyle}
|
||
/>
|
||
</YStack>
|
||
</XStack>
|
||
|
||
<XStack space="$2">
|
||
<YStack flex={1} space="$1">
|
||
<Text fontSize="$xs" color="#6b7280">
|
||
Font Size (px)
|
||
</Text>
|
||
<input
|
||
type="number"
|
||
step="1"
|
||
min="8"
|
||
max="200"
|
||
value={(override.fontSize ?? slot.fontSize ?? 16).toFixed(0)}
|
||
onChange={(event) => onFontSizeChange(Number(event.target.value))}
|
||
style={numberInputStyle}
|
||
/>
|
||
</YStack>
|
||
<YStack flex={1} space="$1">
|
||
<Text fontSize="$xs" color="#6b7280">
|
||
Line Height
|
||
</Text>
|
||
<input
|
||
type="number"
|
||
step="0.05"
|
||
min="0.8"
|
||
max="3"
|
||
value={(override.lineHeight ?? slot.lineHeight ?? 1.35).toFixed(2)}
|
||
onChange={(event) => onLineHeightChange(Number(event.target.value))}
|
||
style={numberInputStyle}
|
||
/>
|
||
</YStack>
|
||
<YStack flex={1} space="$1">
|
||
<Text fontSize="$xs" color="#6b7280">
|
||
Align
|
||
</Text>
|
||
<select
|
||
value={override.align ?? slot.align ?? 'left'}
|
||
onChange={(event) => onUpdateSlot(slotKey, { align: event.target.value as SlotDefinition['align'] })}
|
||
style={selectStyle}
|
||
>
|
||
<option value="left">{t('common.left', 'Links')}</option>
|
||
<option value="center">{t('common.center', 'Zentriert')}</option>
|
||
<option value="right">{t('common.right', 'Rechts')}</option>
|
||
</select>
|
||
</YStack>
|
||
</XStack>
|
||
|
||
<YStack space="$1">
|
||
<Text fontSize="$xs" color="#6b7280">
|
||
Font Family
|
||
</Text>
|
||
<select
|
||
value={override.fontFamily ?? slot.fontFamily ?? ''}
|
||
onChange={(event) => onFontFamilyChange(event.target.value)}
|
||
style={selectStyle}
|
||
>
|
||
<option value="">{t('events.qr.defaultFont', 'Standard')}</option>
|
||
{fontOptions.map((family) => (
|
||
<option key={family} value={family}>
|
||
{family}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</YStack>
|
||
</YStack>
|
||
);
|
||
};
|
||
|
||
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 (
|
||
<YStack space="$3" marginTop="$2">
|
||
<Text fontSize="$sm" fontWeight="700" color="#111827">
|
||
{t('events.qr.layoutControls', 'Layout & Schrift')}
|
||
</Text>
|
||
<YStack space="$2">
|
||
{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'))}
|
||
</YStack>
|
||
|
||
{qrSlot ? (
|
||
<YStack space="$2" padding="$2" borderWidth={1} borderColor="#e5e7eb" borderRadius={12}>
|
||
<Text fontSize="$sm" fontWeight="700" color="#111827">
|
||
{t('events.qr.qr_code_label', 'QR‑Code')}
|
||
</Text>
|
||
<XStack space="$2">
|
||
<YStack flex={1} space="$1">
|
||
<Text fontSize="$xs" color="#6b7280">
|
||
X (%)
|
||
</Text>
|
||
<input
|
||
type="number"
|
||
step="0.5"
|
||
min="0"
|
||
max="100"
|
||
value={((qrOverride.x ?? qrSlot.x) * 100).toFixed(1)}
|
||
onChange={(event) => onQrPercentChange('x')(Number(event.target.value))}
|
||
style={numberInputStyle}
|
||
/>
|
||
</YStack>
|
||
<YStack flex={1} space="$1">
|
||
<Text fontSize="$xs" color="#6b7280">
|
||
Y (%)
|
||
</Text>
|
||
<input
|
||
type="number"
|
||
step="0.5"
|
||
min="0"
|
||
max="100"
|
||
value={((qrOverride.y ?? qrSlot.y) * 100).toFixed(1)}
|
||
onChange={(event) => onQrPercentChange('y')(Number(event.target.value))}
|
||
style={numberInputStyle}
|
||
/>
|
||
</YStack>
|
||
<YStack flex={1} space="$1">
|
||
<Text fontSize="$xs" color="#6b7280">
|
||
Größe (%)
|
||
</Text>
|
||
<input
|
||
type="number"
|
||
step="0.5"
|
||
min="10"
|
||
max="100"
|
||
value={((qrOverride.w ?? qrSlot.w) * 100).toFixed(1)}
|
||
onChange={(event) => onQrPercentChange('w')(Number(event.target.value))}
|
||
style={numberInputStyle}
|
||
/>
|
||
</YStack>
|
||
</XStack>
|
||
{!qrUrl ? (
|
||
<Text fontSize="$xs" color="#b91c1c">
|
||
{t('events.qr.missing', 'Kein QR-Link vorhanden')}
|
||
</Text>
|
||
) : null}
|
||
</YStack>
|
||
) : null}
|
||
</YStack>
|
||
);
|
||
}
|