further layout preview fixes

This commit is contained in:
Codex Agent
2025-12-12 08:34:19 +01:00
parent 57be7d0030
commit 7cf7c4b8df
8 changed files with 1767 additions and 438 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,7 @@ import React from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { ChevronRight, RefreshCcw, ArrowLeft } from 'lucide-react';
import { YStack, XStack, Stack } from '@tamagui/stacks';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite';
import { MobileShell } from './components/MobileShell';
@@ -14,11 +14,43 @@ import {
getEvent,
getEventQrInvites,
createQrInvite,
updateEventQrInvite,
} from '../api';
import { isAuthError } from '../auth/tokens';
import { getApiErrorMessage } from '../lib/apiError';
import toast from 'react-hot-toast';
import { ADMIN_BASE_PATH } from '../constants';
export function resolveLayoutForFormat(format: 'a4-poster' | 'a5-foldable', layouts: EventQrInviteLayout[]): string | null {
const formatKey = format === 'a4-poster' ? 'poster-a4' : 'foldable-a5';
const byHint = layouts.find((layout) => (layout as any)?.format_hint === formatKey);
if (byHint?.id) {
return byHint.id;
}
const match = layouts.find((layout) => {
const paper = (layout.paper || '').toLowerCase();
const orientation = (layout.orientation || '').toLowerCase();
const panel = (layout.panel_mode || '').toLowerCase();
if (format === 'a4-poster') {
return paper === 'a4' && orientation === 'portrait' && panel !== 'double-mirror';
}
return paper === 'a4' && orientation === 'landscape' && panel === 'double-mirror';
});
if (match?.id) {
return match.id;
}
if (format === 'a5-foldable') {
const fallback = layouts.find((layout) => (layout.id || '').includes('foldable'));
return fallback?.id ?? layouts[0]?.id ?? null;
}
return layouts[0]?.id ?? null;
}
export default function MobileQrPrintPage() {
const { slug: slugParam } = useParams<{ slug?: string }>();
@@ -29,24 +61,10 @@ export default function MobileQrPrintPage() {
const [event, setEvent] = React.useState<TenantEvent | null>(null);
const [selectedInvite, setSelectedInvite] = React.useState<EventQrInvite | null>(null);
const [selectedLayoutId, setSelectedLayoutId] = React.useState<string | null>(null);
const [selectedFormat, setSelectedFormat] = React.useState<'a4-poster' | 'a5-foldable' | null>(null);
const [error, setError] = React.useState<string | null>(null);
const [loading, setLoading] = React.useState(true);
const [qrUrl, setQrUrl] = React.useState<string>('');
const [wizardStep, setWizardStep] = React.useState<'select-layout' | 'background' | 'text' | 'preview'>('select-layout');
const [selectedBackgroundPreset, setSelectedBackgroundPreset] = React.useState<string | null>(null);
const [textFields, setTextFields] = React.useState({
headline: '',
subtitle: '',
description: '',
instructions: [''],
});
const [saving, setSaving] = React.useState(false);
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' },
];
React.useEffect(() => {
if (!slug) return;
@@ -58,18 +76,16 @@ export default function MobileQrPrintPage() {
setEvent(data);
const primaryInvite = invites.find((item) => item.is_active) ?? invites[0] ?? null;
setSelectedInvite(primaryInvite);
setSelectedLayoutId(primaryInvite?.layouts?.[0]?.id ?? null);
const backgroundPreset = (primaryInvite?.metadata as any)?.layout_customization?.background_preset ?? null;
setSelectedBackgroundPreset(typeof backgroundPreset === 'string' ? backgroundPreset : null);
const customization = (primaryInvite?.metadata as any)?.layout_customization ?? {};
setTextFields({
headline: customization.headline ?? '',
subtitle: customization.subtitle ?? '',
description: customization.description ?? '',
instructions: Array.isArray(customization.instructions) && customization.instructions.length
? customization.instructions.map((item: unknown) => String(item ?? '')).filter((item: string) => item.length > 0)
: [''],
});
const initialLayout = primaryInvite?.layouts?.[0];
const initialFormat =
initialLayout && ((initialLayout.panel_mode ?? '').toLowerCase() === 'double-mirror' || (initialLayout.orientation ?? '').toLowerCase() === 'landscape')
? 'a5-foldable'
: 'a4-poster';
setSelectedFormat(initialFormat);
const resolvedLayoutId = primaryInvite?.layouts
? resolveLayoutForFormat(initialFormat, primaryInvite.layouts) ?? initialLayout?.id ?? null
: initialLayout?.id ?? null;
setSelectedLayoutId(resolvedLayoutId);
setQrUrl(primaryInvite?.url ?? data.public_url ?? '');
setError(null);
} catch (err) {
@@ -134,6 +150,7 @@ export default function MobileQrPrintPage() {
<XStack space="$2" width="100%" marginTop="$2">
<CTAButton
label={t('events.qr.download', 'Download')}
fullWidth={false}
onPress={() => {
if (qrUrl) {
toast.success(t('events.qr.downloadStarted', 'Download gestartet'));
@@ -144,6 +161,7 @@ export default function MobileQrPrintPage() {
/>
<CTAButton
label={t('events.qr.share', 'Share')}
fullWidth={false}
onPress={async () => {
try {
const shareUrl = String(qrUrl || (event as any)?.public_url || '');
@@ -158,165 +176,53 @@ export default function MobileQrPrintPage() {
</MobileCard>
<MobileCard space="$2">
<Text fontSize="$xs" color="#6b7280">
{t('events.qr.step1', 'Schritt 1: Format wählen')}
</Text>
<Text fontSize="$md" fontWeight="800" color="#111827">
{t('events.qr.layouts', 'Print Layouts')}
</Text>
{(() => {
if (wizardStep === 'select-layout') {
return (
<LayoutSelection
layouts={selectedInvite?.layouts ?? []}
selectedLayoutId={selectedLayoutId}
onSelect={(layoutId) => {
setSelectedLayoutId(layoutId);
setWizardStep('background');
}}
/>
);
}
if (wizardStep === 'background') {
return (
<BackgroundStep
onBack={() => setWizardStep('select-layout')}
presets={BACKGROUND_PRESETS}
selectedPreset={selectedBackgroundPreset}
onSelectPreset={setSelectedBackgroundPreset}
selectedLayout={selectedInvite?.layouts.find((l) => l.id === selectedLayoutId) ?? null}
onSave={async () => {
if (!slug || !selectedInvite || !selectedLayoutId) {
toast.error(t('events.qr.missing', 'Kein QR-Link vorhanden'));
return;
}
setSaving(true);
try {
const payload = {
metadata: {
layout_customization: {
layout_id: selectedLayoutId,
background_preset: selectedBackgroundPreset,
headline: textFields.headline || undefined,
subtitle: textFields.subtitle || undefined,
description: textFields.description || undefined,
instructions: textFields.instructions.filter((item) => item.trim().length > 0),
},
},
};
const updated = await updateEventQrInvite(slug, selectedInvite.id, payload);
setSelectedInvite(updated);
toast.success(t('common.saved', 'Gespeichert'));
setWizardStep('text');
} catch (err) {
toast.error(getApiErrorMessage(err, t('events.errors.saveFailed', 'Speichern fehlgeschlagen.')));
} finally {
setSaving(false);
}
}}
saving={saving}
/>
);
}
if (wizardStep === 'text') {
return (
<TextStep
onBack={() => setWizardStep('background')}
textFields={textFields}
onChange={(fields) => setTextFields(fields)}
onSave={async () => {
if (!slug || !selectedInvite || !selectedLayoutId) {
toast.error(t('events.qr.missing', 'Kein QR-Link vorhanden'));
return;
}
setSaving(true);
try {
const payload = {
metadata: {
layout_customization: {
layout_id: selectedLayoutId,
background_preset: selectedBackgroundPreset,
headline: textFields.headline || null,
subtitle: textFields.subtitle || null,
description: textFields.description || null,
instructions: textFields.instructions.filter((item) => item.trim().length > 0),
},
},
};
const updated = await updateEventQrInvite(slug, selectedInvite.id, payload);
setSelectedInvite(updated);
toast.success(t('common.saved', 'Gespeichert'));
setWizardStep('preview');
} catch (err) {
toast.error(getApiErrorMessage(err, t('events.errors.saveFailed', 'Speichern fehlgeschlagen.')));
} finally {
setSaving(false);
}
}}
saving={saving}
/>
);
}
return (
<PreviewStep
onBack={() => setWizardStep('text')}
layout={selectedInvite?.layouts.find((l) => l.id === selectedLayoutId) ?? null}
backgroundPreset={selectedBackgroundPreset}
presets={BACKGROUND_PRESETS}
textFields={textFields}
qrUrl={qrUrl}
onExport={(format) => {
const layout = selectedInvite?.layouts.find((l) => l.id === selectedLayoutId);
const url = layout?.download_urls?.[format];
if (!url) {
toast.error(t('events.qr.missing', 'Kein QR-Link vorhanden'));
return;
}
window.open(url, '_blank', 'noopener');
}}
/>
);
})()}
<FormatSelection
layouts={selectedInvite?.layouts ?? []}
selectedFormat={selectedFormat}
onSelect={(format, layoutId) => {
setSelectedFormat(format);
setSelectedLayoutId(layoutId);
}}
/>
<CTAButton
label={t('events.qr.preview', 'Anpassen & Exportieren')}
onPress={() => {
if (!slug || !selectedInvite) {
toast.error(t('events.qr.missing', 'Kein QR-Link vorhanden'));
return;
}
const layouts = selectedInvite.layouts ?? [];
const targetLayout =
selectedLayoutId ??
(selectedFormat ? resolveLayoutForFormat(selectedFormat, layouts) : layouts[0]?.id ?? '');
if (!targetLayout) {
toast.error(t('events.qr.missing', 'Kein Layout verfügbar'));
return;
}
navigate(`${ADMIN_BASE_PATH}/mobile/events/${slug}/qr/customize/${selectedInvite.id}?layout=${encodeURIComponent(targetLayout)}`);
}}
/>
</MobileCard>
<MobileCard space="$2">
<Text fontSize="$sm" fontWeight="700" color="#111827">
{t('events.qr.templates', 'Templates')}
{t('events.qr.createLink', 'Neuen QR-Link erstellen')}
</Text>
<XStack justifyContent="space-between" alignItems="center" paddingVertical="$2" borderBottomWidth={1} borderColor="#e5e7eb">
<Text fontSize="$sm" color="#111827">
{t('events.qr.branding', 'Branding')}
</Text>
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input type="checkbox" defaultChecked />
<Text fontSize="$sm" color="#111827">
{t('common.enabled', 'Enabled')}
</Text>
</label>
</XStack>
<XStack justifyContent="space-between" alignItems="center" paddingVertical="$2">
<Text fontSize="$sm" color="#111827">
{t('events.qr.paper', 'Paper Size')}
</Text>
<XStack alignItems="center" space="$2">
<Text fontSize="$sm" color="#111827">
{t('events.qr.paperAuto', 'Auto (per layout)')}
</Text>
<ChevronRight size={16} color="#9ca3af" />
</XStack>
</XStack>
<CTAButton
label={t('events.qr.preview', 'Preview & Print')}
onPress={() => toast.success(t('events.qr.previewStarted', 'Preview gestartet (mock)'))}
/>
<CTAButton
label={t('events.qr.createLink', 'Neuen QR-Link erstellen')}
onPress={async () => {
if (!slug) return;
try {
if (!slug) return;
const invite = await createQrInvite(slug, { label: 'Mobile Link' });
const invite = await createQrInvite(slug, { label: 'Mobile Link' });
setQrUrl(invite.url);
setSelectedInvite(invite);
setSelectedLayoutId(invite.layouts?.[0]?.id ?? selectedLayoutId);
toast.success(t('events.qr.created', 'Neuer QR-Link erstellt'));
} catch (err) {
toast.error(getApiErrorMessage(err, t('events.qr.createFailed', 'Link konnte nicht erstellt werden.')));
@@ -328,31 +234,46 @@ export default function MobileQrPrintPage() {
);
}
function LayoutSelection({
function FormatSelection({
layouts,
selectedLayoutId,
selectedFormat,
onSelect,
}: {
layouts: EventQrInviteLayout[];
selectedLayoutId: string | null;
onSelect: (layoutId: string) => void;
selectedFormat: 'a4-poster' | 'a5-foldable' | null;
onSelect: (format: 'a4-poster' | 'a5-foldable', layoutId: string | null) => void;
}) {
const { t } = useTranslation('management');
if (!layouts.length) {
return (
<Text fontSize="$sm" color="#6b7280">
{t('events.qr.noLayouts', 'Keine Layouts verfügbar.')}
</Text>
);
}
const selectLayoutId = (format: 'a4-poster' | 'a5-foldable'): string | null => {
return resolveLayoutForFormat(format, layouts);
};
const cards: { key: 'a4-poster' | 'a5-foldable'; title: string; subtitle: string; badges: string[] }[] = [
{
key: 'a4-poster',
title: t('events.qr.format.poster', 'A4 Poster'),
subtitle: t('events.qr.format.posterSubtitle', 'Hochformat für Aushänge'),
badges: ['A4', 'Portrait'],
},
{
key: 'a5-foldable',
title: t('events.qr.format.table', 'A5 Tischkarte (faltbar)'),
subtitle: t('events.qr.format.tableSubtitle', 'Quer, doppelt & gespiegelt'),
badges: ['A4', 'Landscape', 'Double-Mirror'],
},
];
return (
<YStack space="$2" marginTop="$2">
{layouts.map((layout) => {
const isSelected = layout.id === selectedLayoutId;
{cards.map((card) => {
const isSelected = selectedFormat === card.key;
return (
<Pressable key={layout.id} onPress={() => onSelect(layout.id)} style={{ width: '100%' }}>
<Pressable
key={card.key}
onPress={() => onSelect(card.key, selectLayoutId(card.key))}
style={{ width: '100%' }}
>
<MobileCard
padding="$3"
borderColor={isSelected ? '#2563EB' : '#e5e7eb'}
@@ -362,17 +283,17 @@ function LayoutSelection({
<XStack alignItems="center" justifyContent="space-between" space="$3">
<YStack space="$1" flex={1}>
<Text fontSize="$sm" fontWeight="700" color="#111827">
{layout.name || layout.id}
{card.title}
</Text>
<Text fontSize="$xs" color="#6b7280">
{card.subtitle}
</Text>
{layout.description ? (
<Text fontSize="$xs" color="#6b7280">
{layout.description}
</Text>
) : null}
<XStack space="$2" alignItems="center" flexWrap="wrap">
<PillBadge tone="muted">{(layout.paper || 'A4').toUpperCase()}</PillBadge>
<PillBadge tone="muted">{(layout.orientation || 'portrait').toUpperCase()}</PillBadge>
{layout.panel_mode ? <PillBadge tone="muted">{layout.panel_mode}</PillBadge> : null}
{card.badges.map((badge) => (
<PillBadge tone="muted" key={badge}>
{badge}
</PillBadge>
))}
</XStack>
</YStack>
<ChevronRight size={16} color="#9ca3af" />
@@ -391,6 +312,7 @@ function BackgroundStep({
selectedPreset,
onSelectPreset,
selectedLayout,
layouts,
onSave,
saving,
}: {
@@ -399,10 +321,25 @@ function BackgroundStep({
selectedPreset: string | null;
onSelectPreset: (id: string) => void;
selectedLayout: EventQrInviteLayout | null;
layouts: EventQrInviteLayout[];
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 isPortrait = (resolvedLayout?.orientation ?? 'portrait').toLowerCase() === 'portrait';
const disablePresets = isFoldable;
const formatLabel = isFoldable ? 'A4 Landscape (Double/Mirror)' : 'A4 Portrait';
return (
<YStack space="$3" marginTop="$2">
@@ -422,41 +359,53 @@ function BackgroundStep({
<YStack space="$2">
<Text fontSize="$sm" fontWeight="700" color="#111827">
{t('events.qr.backgroundPicker', 'Hintergrund auswählen (A4 Portrait Presets)')}
</Text>
<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%' }}>
<Stack
height={120}
borderRadius={14}
overflow="hidden"
borderWidth={isSelected ? 2 : 1}
borderColor={isSelected ? '#2563EB' : '#e5e7eb'}
backgroundColor="#f8fafc"
>
<Stack
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>
</Stack>
</Pressable>
);
})}
</XStack>
<Text fontSize="$xs" color="#6b7280">
{t('events.qr.backgroundNote', 'Diese Presets sind für A4 Hochformat. Spiegelung erfolgt automatisch bei Tischkarten.')}
{t(
'events.qr.backgroundPicker',
disablePresets ? 'Hintergrund für A5 (Gradient/Farbe)' : `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>
<CTAButton
@@ -611,7 +560,7 @@ function PreviewStep({
<Text fontSize="$sm" fontWeight="700" color="#111827">
{t('events.qr.preview', 'Vorschau')}
</Text>
<Stack
<YStack
borderRadius={16}
borderWidth={1}
borderColor="#e5e7eb"
@@ -637,11 +586,11 @@ function PreviewStep({
{textFields.subtitle}
</Text>
) : null}
{textFields.description ? (
<Text fontSize="$sm" color={layout?.preview?.text ?? '#1f2937'}>
{textFields.description}
</Text>
) : null}
{textFields.description ? (
<Text fontSize="$sm" color={layout?.preview?.text ?? '#1f2937'}>
{textFields.description}
</Text>
) : null}
<YStack space="$1">
{textFields.instructions.filter((i) => i.trim().length > 0).map((item, idx) => (
<Text key={idx} fontSize="$xs" color={layout?.preview?.text ?? '#1f2937'}>
@@ -662,7 +611,7 @@ function PreviewStep({
</Text>
)}
</YStack>
</Stack>
</YStack>
</YStack>
<XStack space="$2">

View File

@@ -0,0 +1,55 @@
import { describe, it, expect } from 'vitest';
import type { EventQrInviteLayout } from '../../api';
import { buildInitialTextFields } from '../QrLayoutCustomizePage';
import { resolveLayoutForFormat } from '../QrPrintPage';
describe('buildInitialTextFields', () => {
it('prefers event name for headline and default copy when customization is missing', () => {
const layoutDefaults = {
id: 'evergreen-vows',
name: 'Evergreen Vows',
description: 'Old description',
} as EventQrInviteLayout;
const fields = buildInitialTextFields({
customization: null,
layoutDefaults,
eventName: 'Sommerfest 2025',
});
expect(fields.headline).toBe('Sommerfest 2025');
expect(fields.description).toBe('Helft uns, diesen besonderen Tag mit euren schönen Momenten festzuhalten.');
expect(fields.instructions).toHaveLength(3);
});
it('uses customization when provided', () => {
const fields = buildInitialTextFields({
customization: {
headline: 'Custom',
description: 'Desc',
instructions: ['One', 'Two'],
},
layoutDefaults: null,
eventName: 'Ignored',
});
expect(fields.headline).toBe('Custom');
expect(fields.description).toBe('Desc');
expect(fields.instructions).toEqual(['One', 'Two']);
});
});
describe('resolveLayoutForFormat', () => {
const layouts: EventQrInviteLayout[] = [
{ id: 'portrait-a4', paper: 'a4', orientation: 'portrait', panel_mode: 'single', format_hint: 'poster-a4' } as EventQrInviteLayout,
{ id: 'foldable', paper: 'a4', orientation: 'landscape', panel_mode: 'double-mirror', format_hint: 'foldable-a5' } as EventQrInviteLayout,
];
it('returns portrait layout for A4 poster', () => {
expect(resolveLayoutForFormat('a4-poster', layouts)).toBe('portrait-a4');
});
it('returns foldable layout for A5 foldable', () => {
expect(resolveLayoutForFormat('a5-foldable', layouts)).toBe('foldable');
});
});

View File

@@ -72,15 +72,17 @@ export function CTAButton({
label,
onPress,
tone = 'primary',
fullWidth = true,
}: {
label: string;
onPress: () => void;
tone?: 'primary' | 'ghost';
fullWidth?: boolean;
}) {
const theme = useTheme();
const isPrimary = tone === 'primary';
return (
<Pressable onPress={onPress} style={{ width: '100%' }}>
<Pressable onPress={onPress} style={{ width: fullWidth ? '100%' : undefined, flex: fullWidth ? undefined : 1 }}>
<XStack
height={56}
borderRadius={14}