further layout preview fixes
This commit is contained in:
1246
resources/js/admin/mobile/QrLayoutCustomizePage.tsx
Normal file
1246
resources/js/admin/mobile/QrLayoutCustomizePage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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">
|
||||
|
||||
55
resources/js/admin/mobile/__tests__/qrLayoutUtils.test.ts
Normal file
55
resources/js/admin/mobile/__tests__/qrLayoutUtils.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user