Files
fotospiel-app/resources/js/admin/mobile/QrLayoutCustomizePage.tsx

1610 lines
59 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, ChevronDown } 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 { Accordion } from '@tamagui/accordion';
import { HexColorPicker } from 'react-colorful';
import { Portal } from '@tamagui/portal';
import { MobileShell, HeaderActionButton } 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';
import { adminPath } from '../constants';
import { useBackNavigation } from './hooks/useBackNavigation';
import { ADMIN_COLORS, ADMIN_GRADIENTS, useAdminTheme } from './theme';
type Step = 'background' | 'text' | 'preview';
const BACKGROUND_PRESETS = [
{
id: 'bg-blue-floral',
src: '/storage/layouts/backgrounds-portrait/bg-blue-floral.png',
labelKey: 'events.qr.backgroundPresets.blueFloral',
label: 'Blue Floral',
},
{
id: 'bg-goldframe',
src: '/storage/layouts/backgrounds-portrait/bg-goldframe.png',
labelKey: 'events.qr.backgroundPresets.goldFrame',
label: 'Gold Frame',
},
{
id: 'gr-green-floral',
src: '/storage/layouts/backgrounds-portrait/gr-green-floral.png',
labelKey: 'events.qr.backgroundPresets.greenFloral',
label: 'Green Floral',
},
];
const DEFAULT_BODY_FONT = 'Manrope';
const DEFAULT_DISPLAY_FONT = 'Fraunces';
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 { textStrong, danger } = useAdminTheme();
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 back = useBackNavigation(slug ? adminPath(`/mobile/events/${slug}/qr`) : adminPath('/mobile/events'));
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={back}
headerActions={
<HeaderActionButton onPress={() => window.location.reload()} ariaLabel={t('common.refresh', 'Refresh')}>
<RefreshCcw size={18} color={textStrong} />
</HeaderActionButton>
}
>
{error ? (
<MobileCard>
<Text fontWeight="700" color={danger}>
{error}
</Text>
</MobileCard>
) : null}
<Stepper current={step} onStepChange={setStep} />
<MobileCard space="$2" marginTop="$2">
{step === 'background' && (
<BackgroundStep
onBack={back}
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 { textStrong, muted, border, primary, accentSoft, surface, successText } = useAdminTheme();
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 ? primary : completed ? successText : border}
backgroundColor={active ? accentSoft : surface}
padding="$2"
alignItems="center"
justifyContent="center"
>
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{step.label}
</Text>
<Text fontSize="$xs" color={muted}>
{t('events.qr.step', 'Schritt')} {idx + 1}
</Text>
</YStack>
</Pressable>
);
})}
</XStack>
<YStack height={8} borderRadius={999} backgroundColor={border} overflow="hidden">
<YStack height="100%" width={`${progress}%`} backgroundColor={primary} />
</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';
color?: string;
};
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,
color: typeof slot.color === 'string' ? slot.color : 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.12, w: 0.84, fontSize: 90, fontWeight: 800, fontFamily: DEFAULT_DISPLAY_FONT, align: 'center' },
subtitle: { x: 0.1, y: 0.21, w: 0.8, fontSize: 37, fontWeight: 600, fontFamily: DEFAULT_BODY_FONT, align: 'center' },
description: { x: 0.1, y: 0.66, w: 0.8, fontSize: 26, fontFamily: DEFAULT_BODY_FONT, lineHeight: 1.4, align: 'center' },
instructions: { x: 0.1, y: 0.7, w: 0.8, fontSize: 24, fontFamily: DEFAULT_BODY_FONT, lineHeight: 1.5 },
qr: { x: 0.39, y: 0.37, w: 0.27 },
};
}
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.08, y: 0.15, w: 0.84, fontSize: 50, fontWeight: 800, fontFamily: DEFAULT_DISPLAY_FONT, align: 'center' },
subtitle: { x: 0.1, y: 0.21, w: 0.8, fontSize: 37, fontWeight: 600, fontFamily: DEFAULT_BODY_FONT, align: 'center' },
description: { x: 0.1, y: 0.66, w: 0.8, fontSize: 13, fontFamily: DEFAULT_BODY_FONT, lineHeight: 1.4, align: 'center' },
instructions: { x: 0.12, y: 0.36, w: 0.76, fontSize: 13, fontFamily: DEFAULT_BODY_FONT, lineHeight: 1.3 },
qr: { x: 0.3, y: 0.3, 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,
color: override.color ?? base.color,
};
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 ?? ADMIN_COLORS.backdrop;
const accentColor = layout?.preview?.accent ?? ADMIN_COLORS.primary;
const secondaryColor = layout?.preview?.secondary ?? ADMIN_COLORS.text;
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;
// Default: top-left positioning for non-rotated panels
let targetX = opts.panelOffset + slot.x * panelWidth;
let targetY = slot.y * panelHeight;
let rotation = 0;
// Foldable layouts rotate each panel around its own center; Fabric expects center-based coords when rotated.
if (isFoldable) {
const baseCenterX = opts.panelOffset + slot.x * panelWidth + localWidth / 2;
const baseCenterY = slot.y * panelHeight + localHeight / 2;
const panelCenterX = opts.panelOffset + panelWidth / 2;
const panelCenterY = panelHeight / 2;
rotation = opts.panelOffset === 0 ? 90 : -90;
const rotated = rotatePoint(panelCenterX, panelCenterY, baseCenterX, baseCenterY, rotation);
targetX = rotated.x;
targetY = rotated.y;
}
elements.push({
id: `${type}-${opts.panelOffset}-${opts.mirrored ? 'm' : 'n'}`,
type,
x: targetX * scaleX,
y: targetY * 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: slot.color ?? 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;
let targetX = opts.panelOffset + slot.x * panelWidth;
let targetY = slot.y * panelHeight;
let rotation = 0;
if (isFoldable) {
const baseCenterX = opts.panelOffset + slot.x * panelWidth + localSize / 2;
const baseCenterY = slot.y * panelHeight + localSize / 2;
const panelCenterX = opts.panelOffset + panelWidth / 2;
const panelCenterY = panelHeight / 2;
rotation = opts.panelOffset === 0 ? 90 : -90;
const rotated = rotatePoint(panelCenterX, panelCenterY, baseCenterX, baseCenterY, rotation);
targetX = rotated.x;
targetY = rotated.y;
}
elements.push({
id: `qr-${opts.panelOffset}-${opts.mirrored ? 'm' : 'n'}`,
type: 'qr',
x: targetX * scaleX,
y: targetY * 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 ?? ADMIN_COLORS.surface,
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; labelKey: 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 { textStrong, muted, border, primary, accentSoft, surface, surfaceMuted } = useAdminTheme();
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 formatPaper = resolvePaper(resolvedLayout).toUpperCase();
const isLandscape = ((resolvedLayout?.orientation ?? (isFoldable ? 'landscape' : 'portrait')) || '').toLowerCase() === 'landscape';
const orientationLabel = isLandscape
? t('events.qr.orientation.landscape', 'Landscape')
: t('events.qr.orientation.portrait', 'Portrait');
const formatLabel = isFoldable
? t('events.qr.formatLabel.foldable', '{{paper}} {{orientation}} (double A5/mirrored)', {
paper: formatPaper,
orientation: orientationLabel,
})
: t('events.qr.formatLabel.standard', '{{paper}} {{orientation}}', {
paper: formatPaper,
orientation: orientationLabel,
});
const disablePresets = isFoldable;
const gradientPresets = [
{
angle: 180,
stops: ['#F8FAFC', '#EEF2FF', '#F8FAFC'],
labelKey: 'events.qr.gradientPresets.softLilac',
label: 'Soft Lilac',
},
{
angle: 135,
stops: ['#FEE2E2', '#EFF6FF', '#ECFDF3'],
labelKey: 'events.qr.gradientPresets.pastel',
label: 'Pastel',
},
{
angle: 210,
stops: ['#0B132B', '#1C2541', '#274690'],
labelKey: 'events.qr.gradientPresets.midnight',
label: 'Midnight',
},
];
const solidPresets = [
ADMIN_COLORS.surface,
ADMIN_COLORS.surfaceMuted,
ADMIN_COLORS.backdrop,
ADMIN_COLORS.primary,
ADMIN_COLORS.warning,
];
return (
<YStack space="$3" marginTop="$2">
<XStack alignItems="center" space="$2">
<Pressable onPress={onBack}>
<XStack alignItems="center" space="$1">
<ArrowLeft size={16} color={textStrong} />
<Text fontSize="$sm" color={textStrong}>
{t('common.back', 'Zurück')}
</Text>
</XStack>
</Pressable>
<PillBadge tone="muted">{formatLabel}</PillBadge>
</XStack>
<YStack space="$2">
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{disablePresets
? t('events.qr.backgroundPickerFoldable', 'Hintergrund für A5 (Gradient/Farbe)')
: t('events.qr.backgroundPicker', 'Hintergrund auswählen ({{formatLabel}})', { 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 ? primary : border}
backgroundColor={surfaceMuted}
>
<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={textStrong}>
{t(preset.labelKey, preset.label)}
</Text>
{isSelected ? <PillBadge tone="muted">{t('common.selected', 'Ausgewählt')}</PillBadge> : null}
</XStack>
</YStack>
</Pressable>
);
})}
</XStack>
<Text fontSize="$xs" color={muted}>
{t('events.qr.backgroundNote', 'Diese Presets sind für A4 Hochformat. Spiegelung erfolgt automatisch bei Tischkarten.')}
</Text>
</>
) : (
<Text fontSize="$xs" color={muted}>
{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={textStrong}>
{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%' }}
aria-label={t(gradient.labelKey, gradient.label)}
>
<YStack
height={70}
borderRadius={12}
overflow="hidden"
borderWidth={isSelected ? 2 : 1}
borderColor={isSelected ? primary : border}
style={style}
/>
</Pressable>
);
})}
</XStack>
</YStack>
<YStack space="$2" marginTop="$2">
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{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 ? primary : border}
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={textStrong} />
<Text fontSize="$sm" color={textStrong}>
{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={textStrong}>
{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.bottomNote', 'Unterer Hinweistext')}
value={textFields.description}
onChangeText={(val) => updateField('description', val)}
size="$4"
numberOfLines={3}
/>
</YStack>
<YStack space="$2">
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{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={border}
backgroundColor={surface}
>
<Text fontSize="$md" fontWeight="800" color={textStrong}>
</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={border}
backgroundColor={surface}
>
<Plus size={18} color={textStrong} />
</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; labelKey: 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 { textStrong, muted, border, surface } = useAdminTheme();
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 isLandscape = ((layout?.orientation ?? (isFoldable ? 'landscape' : 'portrait')) || '').toLowerCase() === 'landscape';
const orientationLabel = isLandscape
? t('events.qr.orientation.landscape', 'Landscape')
: t('events.qr.orientation.portrait', '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={textStrong} />
<Text fontSize="$sm" color={textStrong}>
{t('common.back', 'Zurück')}
</Text>
</XStack>
</Pressable>
<PillBadge tone="muted">
{paper.toUpperCase()} {orientationLabel}
</PillBadge>
</XStack>
<YStack space="$2">
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{t('events.qr.preview', 'Vorschau')}
</Text>
<YStack
width="100%"
maxWidth={420}
borderRadius={16}
overflow="hidden"
borderWidth={1}
borderColor={border}
backgroundColor={surface}
style={{ aspectRatio }}
alignItems="center"
justifyContent="center"
>
{previewLoading ? (
<Text color={muted}>{t('common.loading', 'Lädt Vorschau …')}</Text>
) : previewUrl ? (
<img
src={previewUrl}
alt={t('events.qr.previewAlt', 'QR layout preview')}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
) : (
<Text color={muted}>{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" width="100%" flexWrap="wrap">
<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);
}
}}
style={{ flex: 1, minWidth: 0 }}
/>
<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);
}
}}
style={{ flex: 1, minWidth: 0 }}
/>
</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 [openColorSlot, setOpenColorSlot] = React.useState<string | null>(null);
const { border, surface, surfaceMuted, text, textStrong, muted, overlay, shadow, danger } = useAdminTheme();
const fontOptions = React.useMemo(() => {
const preset = ['Fraunces', 'Manrope', 'Inter', 'Roboto', 'Lora'];
const tenant = tenantFonts.map((font) => font.family);
return Array.from(new Set([...tenant, ...preset]));
}, [tenantFonts]);
const numberInputStyle: React.CSSProperties = {
width: 90,
padding: '10px 12px',
border: `1px solid ${border}`,
borderRadius: 12,
fontSize: 14,
fontFamily: DEFAULT_BODY_FONT,
background: surfaceMuted,
color: text,
appearance: 'textfield',
};
const selectStyle: React.CSSProperties = {
width: '100%',
padding: '10px 12px',
border: `1px solid ${border}`,
borderRadius: 12,
fontSize: 14,
fontFamily: DEFAULT_BODY_FONT,
background: surfaceMuted,
color: text,
};
const StepperInput = ({
value,
min,
max,
step,
onChange,
}: {
value: number;
min: number;
max: number;
step: number;
onChange: (value: number) => void;
}) => {
const dec = () => onChange(clampValue(value - step, min, max));
const inc = () => onChange(clampValue(value + step, min, max));
const decimals = step % 1 === 0 ? 0 : Math.min(4, `${step}`.split('.')[1]?.length ?? 1);
const formatValue = (val: number) => val.toFixed(decimals);
return (
<XStack space="$1" alignItems="center">
<Pressable onPress={dec}>
<XStack width={36} height={36} borderRadius={10} alignItems="center" justifyContent="center" borderWidth={1} borderColor={border} backgroundColor={surface}>
<Text fontSize="$md" fontWeight="800" color={textStrong}>
</Text>
</XStack>
</Pressable>
<input
type="text"
inputMode="decimal"
min={min}
max={max}
value={formatValue(value)}
onChange={(event) => {
const next = Number(event.target.value);
if (Number.isFinite(next)) {
onChange(next);
}
}}
style={numberInputStyle}
/>
<Pressable onPress={inc}>
<XStack width={36} height={36} borderRadius={10} alignItems="center" justifyContent="center" borderWidth={1} borderColor={border} backgroundColor={surface}>
<Text fontSize="$md" fontWeight="800" color={textStrong}>
+
</Text>
</XStack>
</Pressable>
</XStack>
);
};
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 });
};
const currentX = override.x ?? slot.x;
const currentY = override.y ?? slot.y;
const currentW = override.w ?? slot.w;
const centerX = () => {
const centered = clampValue((1 - currentW) / 2, 0, 1);
onUpdateSlot(slotKey, { x: centered });
};
return (
<Accordion.Item value={slotKey} key={slotKey}>
<Accordion.Trigger padding="$2" borderWidth={1} borderColor={border} borderRadius={12} backgroundColor={surfaceMuted}>
<XStack justifyContent="space-between" alignItems="center" flex={1}>
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{label}
</Text>
<ChevronDown size={16} color={muted} />
</XStack>
</Accordion.Trigger>
<Accordion.Content paddingTop="$2">
<YStack space="$2" padding="$2" borderWidth={1} borderColor={border} borderRadius={12}>
<XStack space="$3">
<YStack flex={1} space="$1">
<Text fontSize="$xs" color={muted}>
{t('events.qr.positionX', 'X (%)')}
</Text>
<XStack space="$2" alignItems="center">
<StepperInput
value={currentX * 100}
min={0}
max={100}
step={0.5}
onChange={(val) => onPercentChange('x')(val)}
/>
<Pressable onPress={centerX}>
<XStack
paddingHorizontal="$3"
paddingVertical="$2"
borderRadius={10}
borderWidth={1}
borderColor={border}
backgroundColor={surface}
>
<Text fontSize="$xs" color={textStrong}>
{t('common.center', 'Zentrieren')}
</Text>
</XStack>
</Pressable>
</XStack>
</YStack>
<YStack flex={1} space="$1">
<Text fontSize="$xs" color={muted}>
{t('events.qr.positionY', 'Y (%)')}
</Text>
<StepperInput
value={currentY * 100}
min={0}
max={100}
step={0.5}
onChange={(val) => onPercentChange('y')(val)}
/>
</YStack>
<YStack flex={1} space="$1">
<Text fontSize="$xs" color={muted}>
{t('events.qr.width', 'Breite (%)')}
</Text>
<StepperInput
value={currentW * 100}
min={10}
max={100}
step={0.5}
onChange={(val) => onPercentChange('w')(val)}
/>
</YStack>
</XStack>
<XStack space="$3">
<YStack flex={1} space="$1">
<Text fontSize="$xs" color={muted}>
{t('events.qr.fontSize', 'Font Size (px)')}
</Text>
<StepperInput value={override.fontSize ?? slot.fontSize ?? 16} min={8} max={200} step={1} onChange={(val) => onFontSizeChange(val)} />
</YStack>
<YStack flex={1} space="$1">
<Text fontSize="$xs" color={muted}>
{t('events.qr.fontFamily', '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 flex={1} space="$1">
<Text fontSize="$xs" color={muted}>
{t('events.qr.fontColor', 'Schriftfarbe')}
</Text>
<YStack space="$2">
<XStack space="$2" alignItems="center">
<Pressable onPress={() => setOpenColorSlot(openColorSlot === slotKey ? null : slotKey)}>
<XStack
width={48}
height={36}
borderRadius={10}
borderWidth={1}
borderColor={border}
backgroundColor={override.color ?? slot.color ?? textStrong}
/>
</Pressable>
<input
type="text"
value={override.color ?? slot.color ?? ''}
placeholder={ADMIN_COLORS.text}
onChange={(event) => onUpdateSlot(slotKey, { color: event.target.value })}
style={{ ...numberInputStyle, width: 110 }}
/>
</XStack>
{openColorSlot === slotKey ? (
<Portal>
<YStack
position="fixed"
top={0}
left={0}
right={0}
bottom={0}
alignItems="center"
justifyContent="center"
backgroundColor={overlay}
zIndex={9999}
onPress={() => setOpenColorSlot(null)}
>
<YStack
padding="$3"
borderRadius={16}
backgroundColor={surface}
borderWidth={1}
borderColor={border}
elevation="$4"
shadowColor={shadow}
shadowOffset={{ width: 0, height: 4 }}
shadowOpacity={0.2}
shadowRadius={12}
gap="$2"
>
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{t('events.qr.fontColor', 'Schriftfarbe')}
</Text>
<HexColorPicker
color={override.color ?? slot.color ?? ADMIN_COLORS.text}
onChange={(val) => onUpdateSlot(slotKey, { color: val })}
style={{ width: 240, height: 200 }}
/>
<XStack space="$2" justifyContent="flex-end">
<Pressable onPress={() => setOpenColorSlot(null)}>
<XStack
paddingHorizontal="$3"
paddingVertical="$2"
borderRadius={10}
borderWidth={1}
borderColor={border}
backgroundColor={surface}
>
<Text fontSize="$xs" color={textStrong}>
{t('common.close', 'Schließen')}
</Text>
</XStack>
</Pressable>
</XStack>
</YStack>
</YStack>
</Portal>
) : null}
</YStack>
</YStack>
</XStack>
<XStack space="$2">
<YStack flex={1} space="$1">
<Text fontSize="$xs" color={muted}>
{t('events.qr.align', '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>
<YStack flex={1} space="$1">
<Text fontSize="$xs" color={muted}>
{t('events.qr.lineHeight', 'Line Height')}
</Text>
<StepperInput
value={override.lineHeight ?? slot.lineHeight ?? 1.35}
min={0.8}
max={3}
step={0.05}
onChange={(val) => onLineHeightChange(val)}
/>
</YStack>
<YStack flex={1} />
<YStack flex={1} />
</XStack>
</YStack>
</Accordion.Content>
</Accordion.Item>
);
};
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 });
};
const accordionDefaults = ['headline'];
return (
<YStack space="$3" marginTop="$2">
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{t('events.qr.layoutControls', 'Layout & Schrift')}
</Text>
<Accordion type="multiple" defaultValue={accordionDefaults}>
{renderTextSlot('headline', t('events.qr.headline', 'Headline'))}
{renderTextSlot('subtitle', t('events.qr.subtitle', 'Subtitle'))}
{renderTextSlot('description', t('events.qr.bottomNote', 'Unterer Hinweistext'))}
{renderTextSlot('instructions', t('events.qr.instructions', 'Anleitung'))}
{qrSlot ? (
<Accordion.Item value="qr">
<Accordion.Trigger padding="$2" borderWidth={1} borderColor={border} borderRadius={12} backgroundColor={surfaceMuted}>
<XStack justifyContent="space-between" alignItems="center" flex={1}>
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{t('events.qr.qr_code_label', 'QRCode')}
</Text>
<ChevronDown size={16} color={muted} />
</XStack>
</Accordion.Trigger>
<Accordion.Content paddingTop="$2">
<YStack space="$2" padding="$2" borderWidth={1} borderColor={border} borderRadius={12}>
<XStack space="$2">
<YStack flex={1} space="$1">
<Text fontSize="$xs" color={muted}>
{t('events.qr.positionX', 'X (%)')}
</Text>
<StepperInput
value={(qrOverride.x ?? qrSlot.x) * 100}
min={0}
max={100}
step={0.5}
onChange={(val) => onQrPercentChange('x')(val)}
/>
</YStack>
<YStack flex={1} space="$1">
<Text fontSize="$xs" color={muted}>
{t('events.qr.positionY', 'Y (%)')}
</Text>
<StepperInput
value={(qrOverride.y ?? qrSlot.y) * 100}
min={0}
max={100}
step={0.5}
onChange={(val) => onQrPercentChange('y')(val)}
/>
</YStack>
<YStack flex={1} space="$1">
<Text fontSize="$xs" color={muted}>
{t('events.qr.size', 'Größe (%)')}
</Text>
<StepperInput
value={(qrOverride.w ?? qrSlot.w) * 100}
min={10}
max={100}
step={0.5}
onChange={(val) => onQrPercentChange('w')(val)}
/>
</YStack>
</XStack>
{!qrUrl ? (
<Text fontSize="$xs" color={danger}>
{t('events.qr.missing', 'Kein QR-Link vorhanden')}
</Text>
) : null}
</YStack>
</Accordion.Content>
</Accordion.Item>
) : null}
</Accordion>
</YStack>
);
}