qr code layouts im mobile admin perfektioniert.

This commit is contained in:
Codex Agent
2025-12-14 22:14:30 +01:00
parent c8b149d887
commit a8b6e5d9c4
7 changed files with 369 additions and 230 deletions

11
package-lock.json generated
View File

@@ -60,6 +60,7 @@
"lucide-react": "^0.475.0", "lucide-react": "^0.475.0",
"pdf-lib": "^1.17.1", "pdf-lib": "^1.17.1",
"react": "^19.2.1", "react": "^19.2.1",
"react-colorful": "^5.6.1",
"react-dom": "^19.2.1", "react-dom": "^19.2.1",
"react-hot-toast": "^2.6.0", "react-hot-toast": "^2.6.0",
"react-i18next": "^16.4.1", "react-i18next": "^16.4.1",
@@ -16276,6 +16277,16 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/react-colorful": {
"version": "5.6.1",
"resolved": "https://registry.npmjs.org/react-colorful/-/react-colorful-5.6.1.tgz",
"integrity": "sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw==",
"license": "MIT",
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/react-devtools-core": { "node_modules/react-devtools-core": {
"version": "6.1.5", "version": "6.1.5",
"resolved": "https://registry.npmjs.org/react-devtools-core/-/react-devtools-core-6.1.5.tgz", "resolved": "https://registry.npmjs.org/react-devtools-core/-/react-devtools-core-6.1.5.tgz",

View File

@@ -95,7 +95,8 @@
"i18next-http-backend": "^3.0.2", "i18next-http-backend": "^3.0.2",
"laravel-vite-plugin": "^2.0.1", "laravel-vite-plugin": "^2.0.1",
"lucide-react": "^0.475.0", "lucide-react": "^0.475.0",
"pdf-lib": "^1.17.1", "pdf-lib": "^1.17.1",
"react-colorful": "^5.6.1",
"react": "^19.2.1", "react": "^19.2.1",
"react-dom": "^19.2.1", "react-dom": "^19.2.1",
"react-hot-toast": "^2.6.0", "react-hot-toast": "^2.6.0",

View File

@@ -1847,6 +1847,7 @@
"title": "QR-Code & Druck-Layouts", "title": "QR-Code & Druck-Layouts",
"heroTitle": "Einlass-QR-Code", "heroTitle": "Einlass-QR-Code",
"description": "Scannen, um zur Gäste-App zu gelangen.", "description": "Scannen, um zur Gäste-App zu gelangen.",
"bottomNote": "Unterer Hinweistext",
"missing": "Kein QR-Link vorhanden", "missing": "Kein QR-Link vorhanden",
"download": "Download", "download": "Download",
"downloadStarted": "Download gestartet", "downloadStarted": "Download gestartet",
@@ -1859,6 +1860,13 @@
"createLink": "Neuen QR-Link erstellen", "createLink": "Neuen QR-Link erstellen",
"created": "Neuer QR-Link erstellt", "created": "Neuer QR-Link erstellt",
"createFailed": "Link konnte nicht erstellt werden.", "createFailed": "Link konnte nicht erstellt werden.",
"headline": "Headline",
"subtitle": "Untertitel",
"align": "Ausrichtung",
"lineHeight": "Zeilenhöhe",
"fontFamily": "Schriftfamilie",
"exportPdf": "PDF exportieren",
"exportPng": "PNG exportieren",
"format": { "format": {
"poster": "A4 Poster", "poster": "A4 Poster",
"posterSubtitle": "Hochformat für Aushänge", "posterSubtitle": "Hochformat für Aushänge",

View File

@@ -1868,6 +1868,7 @@
"title": "QR Code & Print Layouts", "title": "QR Code & Print Layouts",
"heroTitle": "Entrance QR Code", "heroTitle": "Entrance QR Code",
"description": "Scan to access the event guest app.", "description": "Scan to access the event guest app.",
"bottomNote": "Bottom note text",
"missing": "No QR link available", "missing": "No QR link available",
"download": "Download", "download": "Download",
"downloadStarted": "Download started", "downloadStarted": "Download started",
@@ -1880,6 +1881,13 @@
"createLink": "Create new QR link", "createLink": "Create new QR link",
"created": "New QR link created", "created": "New QR link created",
"createFailed": "Could not create link.", "createFailed": "Could not create link.",
"headline": "Headline",
"subtitle": "Subtitle",
"align": "Align",
"lineHeight": "Line height",
"fontFamily": "Font family",
"exportPdf": "Export PDF",
"exportPng": "Export PNG",
"format": { "format": {
"poster": "A4 Poster", "poster": "A4 Poster",
"posterSubtitle": "Portrait for posters", "posterSubtitle": "Portrait for posters",

View File

@@ -1,11 +1,14 @@
import React from 'react'; import React from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { ArrowLeft, RefreshCcw, Plus } from 'lucide-react'; import { ArrowLeft, RefreshCcw, Plus, ChevronDown } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks'; import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text'; import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite'; import { Pressable } from '@tamagui/react-native-web-lite';
import { Input, TextArea } from 'tamagui'; import { Input, TextArea } from 'tamagui';
import { Accordion } from '@tamagui/accordion';
import { HexColorPicker } from 'react-colorful';
import { Portal } from '@tamagui/portal';
import { MobileShell } from './components/MobileShell'; import { MobileShell } from './components/MobileShell';
import { MobileCard, CTAButton, PillBadge } from './components/Primitives'; import { MobileCard, CTAButton, PillBadge } from './components/Primitives';
import { import {
@@ -394,11 +397,11 @@ function renderEventName(name: TenantEvent['name'] | null | undefined): string |
function getDefaultSlots(): Record<string, SlotDefinition> { function getDefaultSlots(): Record<string, SlotDefinition> {
return { return {
headline: { x: 0.08, y: 0.15, w: 0.84, fontSize: 50, fontWeight: 800, fontFamily: 'Playfair Display', align: 'center' }, headline: { x: 0.08, y: 0.12, w: 0.84, fontSize: 90, fontWeight: 800, fontFamily: 'Playfair Display', align: 'center' },
subtitle: { x: 0.1, y: 0.21, w: 0.8, fontSize: 37, fontWeight: 600, fontFamily: 'Montserrat', align: 'center' }, subtitle: { x: 0.1, y: 0.21, w: 0.8, fontSize: 37, fontWeight: 600, fontFamily: 'Montserrat', align: 'center' },
description: { x: 0.1, y: 0.66, w: 0.8, fontSize: 13, fontFamily: 'Lora', lineHeight: 1.4, align: 'center' }, description: { x: 0.1, y: 0.66, w: 0.8, fontSize: 26, fontFamily: 'Lora', lineHeight: 1.4, align: 'center' },
instructions: { x: 0.1, y: 0.34, w: 0.8, fontSize: 14, fontFamily: 'Lora', lineHeight: 1.35 }, instructions: { x: 0.1, y: 0.7, w: 0.8, fontSize: 24, fontFamily: 'Lora', lineHeight: 1.5 },
qr: { x: 0.3, y: 0.3, w: 0.28 }, qr: { x: 0.39, y: 0.37, w: 0.27 },
}; };
} }
@@ -521,26 +524,28 @@ function buildFabricOptions({
const widthPx = localWidth * scaleX; const widthPx = localWidth * scaleX;
const heightPx = localHeight * scaleY; const heightPx = localHeight * scaleY;
const baseCenterX = opts.panelOffset + slot.x * panelWidth + localWidth / 2; // Default: top-left positioning for non-rotated panels
const baseCenterY = slot.y * panelHeight + localHeight / 2; let targetX = opts.panelOffset + slot.x * panelWidth;
let targetCenterX = baseCenterX; let targetY = slot.y * panelHeight;
let targetCenterY = baseCenterY;
let rotation = 0; let rotation = 0;
// Foldable layouts rotate each panel around its own center; Fabric expects center-based coords when rotated.
if (isFoldable) { 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 panelCenterX = opts.panelOffset + panelWidth / 2;
const panelCenterY = panelHeight / 2; const panelCenterY = panelHeight / 2;
rotation = opts.panelOffset === 0 ? 90 : -90; rotation = opts.panelOffset === 0 ? 90 : -90;
const rotated = rotatePoint(panelCenterX, panelCenterY, baseCenterX, baseCenterY, rotation); const rotated = rotatePoint(panelCenterX, panelCenterY, baseCenterX, baseCenterY, rotation);
targetCenterX = rotated.x; targetX = rotated.x;
targetCenterY = rotated.y; targetY = rotated.y;
} }
elements.push({ elements.push({
id: `${type}-${opts.panelOffset}-${opts.mirrored ? 'm' : 'n'}`, id: `${type}-${opts.panelOffset}-${opts.mirrored ? 'm' : 'n'}`,
type, type,
x: targetCenterX * scaleX, x: targetX * scaleX,
y: targetCenterY * scaleY, y: targetY * scaleY,
width: widthPx, width: widthPx,
height: heightPx, height: heightPx,
fontSize: slot.fontSize ?? (type === 'headline' ? 30 : type === 'subtitle' ? 18 : 16), fontSize: slot.fontSize ?? (type === 'headline' ? 30 : type === 'subtitle' ? 18 : 16),
@@ -557,26 +562,26 @@ function buildFabricOptions({
if (!qrUrl) return; if (!qrUrl) return;
const localSize = slot.w * panelWidth; const localSize = slot.w * panelWidth;
const sizePx = localSize * scaleX; const sizePx = localSize * scaleX;
const baseCenterX = opts.panelOffset + slot.x * panelWidth + localSize / 2; let targetX = opts.panelOffset + slot.x * panelWidth;
const baseCenterY = slot.y * panelHeight + localSize / 2; let targetY = slot.y * panelHeight;
let targetCenterX = baseCenterX;
let targetCenterY = baseCenterY;
let rotation = 0; let rotation = 0;
if (isFoldable) { 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 panelCenterX = opts.panelOffset + panelWidth / 2;
const panelCenterY = panelHeight / 2; const panelCenterY = panelHeight / 2;
rotation = opts.panelOffset === 0 ? 90 : -90; rotation = opts.panelOffset === 0 ? 90 : -90;
const rotated = rotatePoint(panelCenterX, panelCenterY, baseCenterX, baseCenterY, rotation); const rotated = rotatePoint(panelCenterX, panelCenterY, baseCenterX, baseCenterY, rotation);
targetCenterX = rotated.x; targetX = rotated.x;
targetCenterY = rotated.y; targetY = rotated.y;
} }
elements.push({ elements.push({
id: `qr-${opts.panelOffset}-${opts.mirrored ? 'm' : 'n'}`, id: `qr-${opts.panelOffset}-${opts.mirrored ? 'm' : 'n'}`,
type: 'qr', type: 'qr',
x: targetCenterX * scaleX, x: targetX * scaleX,
y: targetCenterY * scaleY, y: targetY * scaleY,
width: sizePx, width: sizePx,
height: sizePx, height: sizePx,
rotation, rotation,
@@ -858,7 +863,7 @@ function TextStep({
size="$4" size="$4"
/> />
<TextArea <TextArea
placeholder={t('events.qr.description', 'Beschreibung')} placeholder={t('events.qr.bottomNote', 'Unterer Hinweistext')}
value={textFields.description} value={textFields.description}
onChangeText={(val) => updateField('description', val)} onChangeText={(val) => updateField('description', val)}
size="$4" size="$4"
@@ -1082,7 +1087,7 @@ function PreviewStep({
<LayoutControls slots={resolvedSlots} slotOverrides={slotOverrides} onUpdateSlot={onUpdateSlot} tenantFonts={tenantFonts} qrUrl={qrImageSrc} /> <LayoutControls slots={resolvedSlots} slotOverrides={slotOverrides} onUpdateSlot={onUpdateSlot} tenantFonts={tenantFonts} qrUrl={qrImageSrc} />
<XStack space="$2"> <XStack space="$2" width="100%" flexWrap="wrap">
<CTAButton <CTAButton
label={t('events.qr.exportPdf', 'Export PDF')} label={t('events.qr.exportPdf', 'Export PDF')}
onPress={async () => { onPress={async () => {
@@ -1096,6 +1101,7 @@ function PreviewStep({
console.error(err); console.error(err);
} }
}} }}
style={{ flex: 1, minWidth: 0 }}
/> />
<CTAButton <CTAButton
label={t('events.qr.exportPng', 'Export PNG')} label={t('events.qr.exportPng', 'Export PNG')}
@@ -1109,6 +1115,7 @@ function PreviewStep({
console.error(err); console.error(err);
} }
}} }}
style={{ flex: 1, minWidth: 0 }}
/> />
</XStack> </XStack>
</YStack> </YStack>
@@ -1129,6 +1136,7 @@ function LayoutControls({
qrUrl: string | null; qrUrl: string | null;
}) { }) {
const { t } = useTranslation('management'); const { t } = useTranslation('management');
const [openColorSlot, setOpenColorSlot] = React.useState<string | null>(null);
const fontOptions = React.useMemo(() => { const fontOptions = React.useMemo(() => {
const preset = ['Playfair Display', 'Lora', 'Montserrat', 'Inter', 'Roboto']; const preset = ['Playfair Display', 'Lora', 'Montserrat', 'Inter', 'Roboto'];
const tenant = tenantFonts.map((font) => font.family); const tenant = tenantFonts.map((font) => font.family);
@@ -1169,6 +1177,8 @@ function LayoutControls({
}) => { }) => {
const dec = () => onChange(clampValue(value - step, min, max)); const dec = () => onChange(clampValue(value - step, min, max));
const inc = () => 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 ( return (
<XStack space="$1" alignItems="center"> <XStack space="$1" alignItems="center">
<Pressable onPress={dec}> <Pressable onPress={dec}>
@@ -1183,8 +1193,13 @@ function LayoutControls({
inputMode="decimal" inputMode="decimal"
min={min} min={min}
max={max} max={max}
value={value.toFixed(step < 1 ? 1 : 0)} value={formatValue(value)}
onChange={(event) => onChange(Number(event.target.value))} onChange={(event) => {
const next = Number(event.target.value);
if (Number.isFinite(next)) {
onChange(next);
}
}}
style={numberInputStyle} style={numberInputStyle}
/> />
<Pressable onPress={inc}> <Pressable onPress={inc}>
@@ -1222,140 +1237,221 @@ function LayoutControls({
onUpdateSlot(slotKey, { fontFamily: value || undefined }); onUpdateSlot(slotKey, { fontFamily: value || undefined });
}; };
return ( const currentX = override.x ?? slot.x;
<YStack key={slotKey} space="$2" padding="$2" borderWidth={1} borderColor="#e5e7eb" borderRadius={12}> const currentY = override.y ?? slot.y;
<Text fontSize="$sm" fontWeight="700" color="#111827"> const currentW = override.w ?? slot.w;
{label} const centerX = () => {
</Text> const centered = clampValue((1 - currentW) / 2, 0, 1);
<XStack space="$3"> onUpdateSlot(slotKey, { x: centered });
<YStack flex={1} space="$1"> };
<Text fontSize="$xs" color="#6b7280">
X (%)
</Text>
<XStack space="$1" alignItems="center">
<Pressable onPress={() => onPercentChange('x')(((override.x ?? slot.x) * 100) - 0.5)}>
<XStack width={36} height={36} borderRadius={10} alignItems="center" justifyContent="center" borderWidth={1} borderColor="#e5e7eb" backgroundColor="#fff">
<Text fontSize="$md" fontWeight="800" color="#111827">
</Text>
</XStack>
</Pressable>
<input
type="text"
inputMode="decimal"
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}
/>
<Pressable onPress={() => onPercentChange('x')(((override.x ?? slot.x) * 100) + 0.5)}>
<XStack width={36} height={36} borderRadius={10} alignItems="center" justifyContent="center" borderWidth={1} borderColor="#e5e7eb" backgroundColor="#fff">
<Text fontSize="$md" fontWeight="800" color="#111827">
+
</Text>
</XStack>
</Pressable>
</XStack>
</YStack>
<YStack flex={1} space="$1">
<Text fontSize="$xs" color="#6b7280">
Y (%)
</Text>
<StepperInput
value={(override.y ?? slot.y) * 100}
min={0}
max={100}
step={0.5}
onChange={(val) => onPercentChange('y')(val)}
/>
</YStack>
<YStack flex={1} space="$1">
<Text fontSize="$xs" color="#6b7280">
Breite (%)
</Text>
<StepperInput
value={(override.w ?? slot.w) * 100}
min={10}
max={100}
step={0.5}
onChange={(val) => onPercentChange('w')(val)}
/>
</YStack>
</XStack>
<XStack space="$3"> return (
<YStack flex={1} space="$1"> <Accordion.Item value={slotKey} key={slotKey}>
<Text fontSize="$xs" color="#6b7280"> <Accordion.Trigger padding="$2" borderWidth={1} borderColor="#e5e7eb" borderRadius={12} backgroundColor="#f8fafc">
Font Size (px) <XStack justifyContent="space-between" alignItems="center" flex={1}>
<Text fontSize="$sm" fontWeight="700" color="#111827">
{label}
</Text> </Text>
<StepperInput value={override.fontSize ?? slot.fontSize ?? 16} min={8} max={200} step={1} onChange={(val) => onFontSizeChange(val)} /> <ChevronDown size={16} color="#6b7280" />
</YStack> </XStack>
<YStack flex={1} space="$1"> </Accordion.Trigger>
<Text fontSize="$xs" color="#6b7280"> <Accordion.Content paddingTop="$2">
Font Family <YStack space="$2" padding="$2" borderWidth={1} borderColor="#e5e7eb" borderRadius={12}>
</Text> <XStack space="$3">
<select <YStack flex={1} space="$1">
value={override.fontFamily ?? slot.fontFamily ?? ''} <Text fontSize="$xs" color="#6b7280">
onChange={(event) => onFontFamilyChange(event.target.value)} X (%)
style={selectStyle} </Text>
> <XStack space="$2" alignItems="center">
<option value="">{t('events.qr.defaultFont', 'Standard')}</option> <StepperInput
{fontOptions.map((family) => ( value={currentX * 100}
<option key={family} value={family}> min={0}
{family} max={100}
</option> step={0.5}
))} onChange={(val) => onPercentChange('x')(val)}
</select> />
</YStack> <Pressable onPress={centerX}>
<YStack flex={1} space="$1"> <XStack
<Text fontSize="$xs" color="#6b7280"> paddingHorizontal="$3"
{t('events.qr.fontColor', 'Schriftfarbe')} paddingVertical="$2"
</Text> borderRadius={10}
<XStack space="$2" alignItems="center"> borderWidth={1}
<input borderColor="#e5e7eb"
type="color" backgroundColor="#fff"
value={override.color ?? slot.color ?? '#0f172a'} >
onChange={(event) => onUpdateSlot(slotKey, { color: event.target.value })} <Text fontSize="$xs" color="#111827">
style={{ width: 48, height: 36, border: '1px solid #e5e7eb', borderRadius: 8, padding: 0, background: '#fff' }} {t('common.center', 'Zentrieren')}
/> </Text>
<input </XStack>
type="text" </Pressable>
value={override.color ?? slot.color ?? ''} </XStack>
placeholder="#0f172a" </YStack>
onChange={(event) => onUpdateSlot(slotKey, { color: event.target.value })} <YStack flex={1} space="$1">
style={numberInputStyle} <Text fontSize="$xs" color="#6b7280">
/> 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="#6b7280">
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="#6b7280">
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="#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 flex={1} space="$1">
<Text fontSize="$xs" color="#6b7280">
{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="#e5e7eb"
backgroundColor={override.color ?? slot.color ?? '#0f172a'}
/>
</Pressable>
<input
type="text"
value={override.color ?? slot.color ?? ''}
placeholder="#0f172a"
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="rgba(0,0,0,0.35)"
zIndex={9999}
onPress={() => setOpenColorSlot(null)}
>
<YStack
padding="$3"
borderRadius={16}
backgroundColor="#fff"
borderWidth={1}
borderColor="#e5e7eb"
elevation="$4"
shadowColor="rgba(0,0,0,0.08)"
shadowOffset={{ width: 0, height: 4 }}
shadowOpacity={0.2}
shadowRadius={12}
gap="$2"
>
<Text fontSize="$sm" fontWeight="700" color="#111827">
{t('events.qr.fontColor', 'Schriftfarbe')}
</Text>
<HexColorPicker
color={override.color ?? slot.color ?? '#0f172a'}
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="#e5e7eb"
backgroundColor="#fff"
>
<Text fontSize="$xs" color="#111827">
{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="#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>
<YStack flex={1} space="$1">
<Text fontSize="$xs" color="#6b7280">
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> </XStack>
</YStack> </YStack>
<YStack flex={1} space="$1"> </Accordion.Content>
<Text fontSize="$xs" color="#6b7280"> </Accordion.Item>
Line Height
</Text>
<StepperInput value={override.lineHeight ?? slot.lineHeight ?? 1.35} min={0.8} max={3} step={0.05} onChange={(val) => onLineHeightChange(val)} />
</YStack>
</XStack>
<XStack space="$2">
<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>
<YStack flex={1} />
<YStack flex={1} />
<YStack flex={1} />
</XStack>
</YStack>
); );
}; };
@@ -1367,74 +1463,79 @@ function LayoutControls({
onUpdateSlot('qr', { [field]: pct / 100 }); onUpdateSlot('qr', { [field]: pct / 100 });
}; };
const accordionDefaults = ['headline'];
return ( return (
<YStack space="$3" marginTop="$2"> <YStack space="$3" marginTop="$2">
<Text fontSize="$sm" fontWeight="700" color="#111827"> <Text fontSize="$sm" fontWeight="700" color="#111827">
{t('events.qr.layoutControls', 'Layout & Schrift')} {t('events.qr.layoutControls', 'Layout & Schrift')}
</Text> </Text>
<YStack space="$2"> <Accordion type="multiple" defaultValue={accordionDefaults}>
{renderTextSlot('headline', t('events.qr.headline', 'Headline'))} {renderTextSlot('headline', t('events.qr.headline', 'Headline'))}
{renderTextSlot('subtitle', t('events.qr.subtitle', 'Subtitle'))} {renderTextSlot('subtitle', t('events.qr.subtitle', 'Subtitle'))}
{renderTextSlot('description', t('events.qr.description', 'Beschreibung'))} {renderTextSlot('description', t('events.qr.bottomNote', 'Unterer Hinweistext'))}
{renderTextSlot('instructions', t('events.qr.instructions', 'Anleitung'))} {renderTextSlot('instructions', t('events.qr.instructions', 'Anleitung'))}
</YStack>
{qrSlot ? ( {qrSlot ? (
<YStack space="$2" padding="$2" borderWidth={1} borderColor="#e5e7eb" borderRadius={12}> <Accordion.Item value="qr">
<Text fontSize="$sm" fontWeight="700" color="#111827"> <Accordion.Trigger padding="$2" borderWidth={1} borderColor="#e5e7eb" borderRadius={12} backgroundColor="#f8fafc">
{t('events.qr.qr_code_label', 'QRCode')} <XStack justifyContent="space-between" alignItems="center" flex={1}>
</Text> <Text fontSize="$sm" fontWeight="700" color="#111827">
<XStack space="$2"> {t('events.qr.qr_code_label', 'QRCode')}
<YStack flex={1} space="$1"> </Text>
<Text fontSize="$xs" color="#6b7280"> <ChevronDown size={16} color="#6b7280" />
X (%) </XStack>
</Text> </Accordion.Trigger>
<input <Accordion.Content paddingTop="$2">
type="number" <YStack space="$2" padding="$2" borderWidth={1} borderColor="#e5e7eb" borderRadius={12}>
step="0.5" <XStack space="$2">
min="0" <YStack flex={1} space="$1">
max="100" <Text fontSize="$xs" color="#6b7280">
value={((qrOverride.x ?? qrSlot.x) * 100).toFixed(1)} X (%)
onChange={(event) => onQrPercentChange('x')(Number(event.target.value))} </Text>
style={numberInputStyle} <StepperInput
/> value={(qrOverride.x ?? qrSlot.x) * 100}
</YStack> min={0}
<YStack flex={1} space="$1"> max={100}
<Text fontSize="$xs" color="#6b7280"> step={0.5}
Y (%) onChange={(val) => onQrPercentChange('x')(val)}
</Text> />
<input </YStack>
type="number" <YStack flex={1} space="$1">
step="0.5" <Text fontSize="$xs" color="#6b7280">
min="0" Y (%)
max="100" </Text>
value={((qrOverride.y ?? qrSlot.y) * 100).toFixed(1)} <StepperInput
onChange={(event) => onQrPercentChange('y')(Number(event.target.value))} value={(qrOverride.y ?? qrSlot.y) * 100}
style={numberInputStyle} min={0}
/> max={100}
</YStack> step={0.5}
<YStack flex={1} space="$1"> onChange={(val) => onQrPercentChange('y')(val)}
<Text fontSize="$xs" color="#6b7280"> />
Größe (%) </YStack>
</Text> <YStack flex={1} space="$1">
<input <Text fontSize="$xs" color="#6b7280">
type="number" Größe (%)
step="0.5" </Text>
min="10" <StepperInput
max="100" value={(qrOverride.w ?? qrSlot.w) * 100}
value={((qrOverride.w ?? qrSlot.w) * 100).toFixed(1)} min={10}
onChange={(event) => onQrPercentChange('w')(Number(event.target.value))} max={100}
style={numberInputStyle} step={0.5}
/> onChange={(val) => onQrPercentChange('w')(val)}
</YStack> />
</XStack> </YStack>
{!qrUrl ? ( </XStack>
<Text fontSize="$xs" color="#b91c1c"> {!qrUrl ? (
{t('events.qr.missing', 'Kein QR-Link vorhanden')} <Text fontSize="$xs" color="#b91c1c">
</Text> {t('events.qr.missing', 'Kein QR-Link vorhanden')}
) : null} </Text>
</YStack> ) : null}
) : null} </YStack>
</Accordion.Content>
</Accordion.Item>
) : null}
</Accordion>
</YStack> </YStack>
); );
} }

View File

@@ -103,18 +103,23 @@ export default function MobileQrPrintPage() {
justifyContent="center" justifyContent="center"
overflow="hidden" overflow="hidden"
> >
{qrUrl ? ( {qrImage ? (
<img <img
src={qrImage || `https://api.qrserver.com/v1/create-qr-code/?size=220x220&data=${encodeURIComponent(qrUrl)}`} src={qrImage}
alt="QR" alt="QR"
style={{ width: '100%', height: '100%', objectFit: 'contain' }} style={{ width: '100%', height: '100%', objectFit: 'contain' }}
/> />
) : ( ) : (
<Text color="#9ca3af" fontSize="$sm"> <Text color="#9ca3af" fontSize="$sm">
{t('events.qr.missing', 'Kein QR-Link vorhanden')} {t('events.qr.missing', 'Kein QR-Link vorhanden')}
</Text> </Text>
)} )}
</YStack> </YStack>
{qrUrl ? (
<Text fontSize="$xs" color="#334155" textAlign="center" marginTop="$2" style={{ wordBreak: 'break-word' }}>
{qrUrl}
</Text>
) : null}
<Text fontSize="$xs" color="#6b7280"> <Text fontSize="$xs" color="#6b7280">
{t('events.qr.description', 'Scan to access the event guest app.')} {t('events.qr.description', 'Scan to access the event guest app.')}
</Text> </Text>
@@ -123,12 +128,12 @@ export default function MobileQrPrintPage() {
label={t('events.qr.download', 'Download')} label={t('events.qr.download', 'Download')}
fullWidth={false} fullWidth={false}
onPress={() => { onPress={() => {
if (!qrUrl) { if (!qrImage) {
toast.error(t('events.qr.missing', 'Kein QR-Link vorhanden')); toast.error(t('events.qr.missing', 'Kein QR-Link vorhanden'));
return; return;
} }
const link = document.createElement('a'); const link = document.createElement('a');
link.href = qrImage || `https://api.qrserver.com/v1/create-qr-code/?size=220x220&data=${encodeURIComponent(qrUrl)}`; link.href = qrImage;
link.download = 'event-qr.png'; link.download = 'event-qr.png';
document.body.appendChild(link); document.body.appendChild(link);
link.click(); link.click();
@@ -577,12 +582,17 @@ function PreviewStep({
))} ))}
</YStack> </YStack>
<YStack alignItems="center" justifyContent="center"> <YStack alignItems="center" justifyContent="center">
{qrUrl ? ( {qrImage ? (
<img <>
src={`https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(qrUrl)}`} <img
alt="QR" src={qrImage}
style={{ width: 140, height: 140, objectFit: 'contain' }} alt="QR"
/> style={{ width: 140, height: 140, objectFit: 'contain' }}
/>
<Text fontSize="$xs" color="#334155" textAlign="center" marginTop="$2" style={{ wordBreak: 'break-word' }}>
{qrUrl}
</Text>
</>
) : ( ) : (
<Text fontSize="$xs" color="#6b7280"> <Text fontSize="$xs" color="#6b7280">
{t('events.qr.missing', 'Kein QR-Link vorhanden')} {t('events.qr.missing', 'Kein QR-Link vorhanden')}

View File

@@ -705,7 +705,7 @@ export async function createFabricObject({
width: element.width, width: element.width,
height: element.height, height: element.height,
fontSize: element.fontSize ?? 36, fontSize: element.fontSize ?? 36,
fill: textColor, fill: element.fill ?? textColor,
fontFamily: element.fontFamily ?? 'Lora', fontFamily: element.fontFamily ?? 'Lora',
textAlign: mapTextAlign(element.align), textAlign: mapTextAlign(element.align),
lineHeight: element.lineHeight ?? 1.5, lineHeight: element.lineHeight ?? 1.5,
@@ -718,7 +718,7 @@ export async function createFabricObject({
width: element.width, width: element.width,
height: element.height, height: element.height,
fontSize: element.fontSize ?? 24, fontSize: element.fontSize ?? 24,
fill: accentColor, fill: element.fill ?? accentColor,
fontFamily: element.fontFamily ?? 'Montserrat', fontFamily: element.fontFamily ?? 'Montserrat',
underline: true, underline: true,
textAlign: mapTextAlign(element.align), textAlign: mapTextAlign(element.align),
@@ -732,7 +732,7 @@ export async function createFabricObject({
text: element.content ?? '', text: element.content ?? '',
width: element.width, width: element.width,
height: element.height, height: element.height,
backgroundColor: badgeColor, backgroundColor: element.fill ?? badgeColor,
textColor: '#ffffff', textColor: '#ffffff',
fontSize: element.fontSize ?? 22, fontSize: element.fontSize ?? 22,
lineHeight: element.lineHeight ?? 1.5, lineHeight: element.lineHeight ?? 1.5,
@@ -744,7 +744,7 @@ export async function createFabricObject({
text: element.content ?? '', text: element.content ?? '',
width: element.width, width: element.width,
height: element.height, height: element.height,
backgroundColor: accentColor, backgroundColor: element.fill ?? accentColor,
textColor: '#ffffff', textColor: '#ffffff',
fontSize: element.fontSize ?? 24, fontSize: element.fontSize ?? 24,
cornerRadius: 18, cornerRadius: 18,