655 lines
24 KiB
TypeScript
655 lines
24 KiB
TypeScript
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 } from '@tamagui/stacks';
|
||
import { SizableText as Text } from '@tamagui/text';
|
||
import { Pressable } from '@tamagui/react-native-web-lite';
|
||
import { MobileShell, HeaderActionButton } from './components/MobileShell';
|
||
import { MobileCard, CTAButton, PillBadge } from './components/Primitives';
|
||
import { MobileInput, MobileTextArea } from './components/FormControls';
|
||
import {
|
||
TenantEvent,
|
||
EventQrInvite,
|
||
EventQrInviteLayout,
|
||
getEvent,
|
||
getEventQrInvites,
|
||
createQrInvite,
|
||
} from '../api';
|
||
import { isAuthError } from '../auth/tokens';
|
||
import { getApiErrorMessage } from '../lib/apiError';
|
||
import toast from 'react-hot-toast';
|
||
import { ADMIN_BASE_PATH, adminPath } from '../constants';
|
||
import { resolveLayoutForFormat } from './qr/utils';
|
||
import { useBackNavigation } from './hooks/useBackNavigation';
|
||
import { useAdminTheme } from './theme';
|
||
|
||
export default function MobileQrPrintPage() {
|
||
const { slug: slugParam } = useParams<{ slug?: string }>();
|
||
const slug = slugParam ?? null;
|
||
const navigate = useNavigate();
|
||
const { t } = useTranslation('management');
|
||
const { textStrong, text, muted, subtle, border, surfaceMuted, primary, danger, accentSoft } = useAdminTheme();
|
||
|
||
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 [qrImage, setQrImage] = React.useState<string>('');
|
||
const back = useBackNavigation(slug ? adminPath(`/mobile/events/${slug}`) : adminPath('/mobile/events'));
|
||
|
||
const load = React.useCallback(async () => {
|
||
if (!slug) return;
|
||
setLoading(true);
|
||
try {
|
||
const data = await getEvent(slug);
|
||
const invites = await getEventQrInvites(slug);
|
||
setEvent(data);
|
||
const primaryInvite = invites.find((item) => item.is_active) ?? invites[0] ?? null;
|
||
setSelectedInvite(primaryInvite);
|
||
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 ?? '');
|
||
setQrImage(primaryInvite?.qr_code_data_url ?? '');
|
||
setError(null);
|
||
} catch (err) {
|
||
if (!isAuthError(err)) {
|
||
setError(getApiErrorMessage(err, t('events.errors.loadFailed', 'QR-Daten konnten nicht geladen werden.')));
|
||
}
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [slug, t]);
|
||
|
||
React.useEffect(() => {
|
||
void load();
|
||
}, [load]);
|
||
|
||
return (
|
||
<MobileShell
|
||
activeTab="home"
|
||
title={t('events.qr.title', 'QR Code & Print Layouts')}
|
||
onBack={back}
|
||
headerActions={
|
||
<HeaderActionButton onPress={() => load()} ariaLabel={t('common.refresh', 'Refresh')}>
|
||
<RefreshCcw size={18} color={textStrong} />
|
||
</HeaderActionButton>
|
||
}
|
||
>
|
||
{error ? (
|
||
<MobileCard>
|
||
<Text fontWeight="700" color={danger}>
|
||
{error}
|
||
</Text>
|
||
<CTAButton
|
||
label={t('common.retry', 'Retry')}
|
||
tone="ghost"
|
||
fullWidth={false}
|
||
onPress={() => load()}
|
||
/>
|
||
</MobileCard>
|
||
) : null}
|
||
|
||
<MobileCard space="$3" alignItems="center">
|
||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||
{t('events.qr.heroTitle', 'Entrance QR Code')}
|
||
</Text>
|
||
<YStack
|
||
width={180}
|
||
height={180}
|
||
borderRadius={16}
|
||
borderWidth={1}
|
||
borderColor={border}
|
||
backgroundColor={surfaceMuted}
|
||
alignItems="center"
|
||
justifyContent="center"
|
||
overflow="hidden"
|
||
>
|
||
{qrImage ? (
|
||
<img
|
||
src={qrImage}
|
||
alt={t('events.qr.qrAlt', 'QR code')}
|
||
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
||
/>
|
||
) : (
|
||
<Text color={subtle} fontSize="$sm">
|
||
{t('events.qr.missing', 'Kein QR-Link vorhanden')}
|
||
</Text>
|
||
)}
|
||
</YStack>
|
||
{qrUrl ? (
|
||
<Text fontSize="$xs" color={text} textAlign="center" marginTop="$2" style={{ wordBreak: 'break-word' }}>
|
||
{qrUrl}
|
||
</Text>
|
||
) : null}
|
||
<Text fontSize="$xs" color={muted} marginTop="$1">
|
||
{selectedInvite?.expires_at
|
||
? t('events.qr.expiresAt', 'Valid until {{date}}', {
|
||
date: formatExpiry(selectedInvite.expires_at),
|
||
})
|
||
: t('events.qr.noExpiry', 'No expiry configured')}
|
||
</Text>
|
||
<Text fontSize="$xs" color={muted}>
|
||
{t('events.qr.description', 'Scan to access the event guest app.')}
|
||
</Text>
|
||
<XStack space="$2" width="100%" marginTop="$2">
|
||
<CTAButton
|
||
label={t('events.qr.download', 'Download')}
|
||
fullWidth={false}
|
||
onPress={() => {
|
||
if (!qrImage) {
|
||
toast.error(t('events.qr.missing', 'Kein QR-Link vorhanden'));
|
||
return;
|
||
}
|
||
const link = document.createElement('a');
|
||
link.href = qrImage;
|
||
link.download = 'event-qr.png';
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
document.body.removeChild(link);
|
||
toast.success(t('events.qr.downloadStarted', 'Download gestartet'));
|
||
}}
|
||
/>
|
||
<CTAButton
|
||
label={t('events.qr.share', 'Share')}
|
||
fullWidth={false}
|
||
onPress={async () => {
|
||
try {
|
||
const shareUrl = String(qrUrl || (event as any)?.public_url || '');
|
||
if (navigator.share && shareUrl) {
|
||
await navigator.share({
|
||
title: t('events.qr.title', 'QR Code & Print Layouts'),
|
||
text: t('events.qr.description', 'Scan to access the event guest app.'),
|
||
url: shareUrl,
|
||
});
|
||
toast.success(t('events.qr.shareSuccess', 'Link geteilt'));
|
||
return;
|
||
}
|
||
await navigator.clipboard.writeText(shareUrl);
|
||
toast.success(t('events.qr.shareSuccess', 'Link kopiert'));
|
||
} catch {
|
||
toast.error(t('events.qr.shareFailed', 'Konnte Link nicht kopieren'));
|
||
}
|
||
}}
|
||
/>
|
||
</XStack>
|
||
</MobileCard>
|
||
|
||
<MobileCard space="$2">
|
||
<Text fontSize="$xs" color={muted}>
|
||
{t('events.qr.step1', 'Schritt 1: Format wählen')}
|
||
</Text>
|
||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||
{t('events.qr.layouts', 'Print Layouts')}
|
||
</Text>
|
||
<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={textStrong}>
|
||
{t('events.qr.createLink', 'Neuen QR-Link erstellen')}
|
||
</Text>
|
||
<CTAButton
|
||
label={t('events.qr.createLink', 'Neuen QR-Link erstellen')}
|
||
onPress={async () => {
|
||
if (!slug) return;
|
||
try {
|
||
const invite = await createQrInvite(slug, { label: t('events.qr.mobileLinkLabel', 'Mobile link') });
|
||
setQrUrl(invite.url);
|
||
setQrImage(invite.qr_code_data_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.')));
|
||
}
|
||
}}
|
||
/>
|
||
</MobileCard>
|
||
</MobileShell>
|
||
);
|
||
}
|
||
|
||
function formatExpiry(value: string): string {
|
||
const date = new Date(value);
|
||
if (Number.isNaN(date.getTime())) return value;
|
||
return date.toLocaleString(undefined, { day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||
}
|
||
|
||
function FormatSelection({
|
||
layouts,
|
||
selectedFormat,
|
||
onSelect,
|
||
}: {
|
||
layouts: EventQrInviteLayout[];
|
||
selectedFormat: 'a4-poster' | 'a5-foldable' | null;
|
||
onSelect: (format: 'a4-poster' | 'a5-foldable', layoutId: string | null) => void;
|
||
}) {
|
||
const { t } = useTranslation('management');
|
||
const { border, primary, accentSoft, surface, textStrong, muted, subtle } = useAdminTheme();
|
||
|
||
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">
|
||
{cards.map((card) => {
|
||
const isSelected = selectedFormat === card.key;
|
||
return (
|
||
<Pressable
|
||
key={card.key}
|
||
onPress={() => onSelect(card.key, selectLayoutId(card.key))}
|
||
style={{ width: '100%' }}
|
||
>
|
||
<MobileCard
|
||
padding="$3"
|
||
borderColor={isSelected ? primary : border}
|
||
borderWidth={isSelected ? 2 : 1}
|
||
backgroundColor={isSelected ? accentSoft : surface}
|
||
>
|
||
<XStack alignItems="center" justifyContent="space-between" space="$3">
|
||
<YStack space="$1" flex={1}>
|
||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||
{card.title}
|
||
</Text>
|
||
<Text fontSize="$xs" color={muted}>
|
||
{card.subtitle}
|
||
</Text>
|
||
<XStack space="$2" alignItems="center" flexWrap="wrap">
|
||
{card.badges.map((badge) => (
|
||
<PillBadge tone="muted" key={badge}>
|
||
{badge}
|
||
</PillBadge>
|
||
))}
|
||
</XStack>
|
||
</YStack>
|
||
<ChevronRight size={16} color={subtle} />
|
||
</XStack>
|
||
</MobileCard>
|
||
</Pressable>
|
||
);
|
||
})}
|
||
</YStack>
|
||
);
|
||
}
|
||
|
||
function BackgroundStep({
|
||
onBack,
|
||
presets,
|
||
selectedPreset,
|
||
onSelectPreset,
|
||
selectedLayout,
|
||
layouts,
|
||
onSave,
|
||
saving,
|
||
}: {
|
||
onBack: () => void;
|
||
presets: { id: string; src: string; label: string }[];
|
||
selectedPreset: string | null;
|
||
onSelectPreset: (id: string) => void;
|
||
selectedLayout: EventQrInviteLayout | null;
|
||
layouts: EventQrInviteLayout[];
|
||
onSave: () => void;
|
||
saving: boolean;
|
||
}) {
|
||
const { t } = useTranslation('management');
|
||
const { textStrong, muted, border, primary, 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 isPortrait = (resolvedLayout?.orientation ?? 'portrait').toLowerCase() === 'portrait';
|
||
const disablePresets = isFoldable;
|
||
const formatLabel = isFoldable ? 'A4 Landscape (Double/Mirror)' : 'A4 Portrait';
|
||
|
||
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">
|
||
{selectedLayout ? `${(selectedLayout.paper || 'A4').toUpperCase()} • ${(selectedLayout.orientation || 'portrait').toUpperCase()}` : 'Layout'}
|
||
</PillBadge>
|
||
</XStack>
|
||
|
||
<YStack space="$2">
|
||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||
{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 ? 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}>
|
||
{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>
|
||
|
||
<CTAButton
|
||
label={saving ? t('common.saving', 'Speichern …') : t('common.save', 'Speichern')}
|
||
disabled={saving || !selectedLayout}
|
||
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 { textStrong } = useAdminTheme();
|
||
|
||
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>
|
||
<MobileInput
|
||
placeholder={t('events.qr.headline', 'Headline')}
|
||
value={textFields.headline}
|
||
onChange={(event) => updateField('headline', event.target.value)}
|
||
/>
|
||
<MobileInput
|
||
placeholder={t('events.qr.subtitle', 'Subtitle')}
|
||
value={textFields.subtitle}
|
||
onChange={(event) => updateField('subtitle', event.target.value)}
|
||
/>
|
||
<MobileTextArea
|
||
placeholder={t('events.qr.description', 'Beschreibung')}
|
||
value={textFields.description}
|
||
onChange={(event) => updateField('description', event.target.value)}
|
||
/>
|
||
</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">
|
||
<MobileInput
|
||
style={{ flex: 1 }}
|
||
placeholder={t('events.qr.instructionsPlaceholder', 'Schritt hinzufügen')}
|
||
value={item}
|
||
onChange={(event) => updateInstruction(idx, event.target.value)}
|
||
/>
|
||
<CTAButton
|
||
label="–"
|
||
onPress={() => removeInstruction(idx)}
|
||
disabled={textFields.instructions.length === 1}
|
||
/>
|
||
</XStack>
|
||
))}
|
||
<CTAButton label={t('common.add', 'Hinzufügen')} onPress={addInstruction} />
|
||
</YStack>
|
||
|
||
<CTAButton
|
||
label={saving ? t('common.saving', 'Speichern …') : t('common.save', 'Speichern')}
|
||
disabled={saving}
|
||
onPress={onSave}
|
||
/>
|
||
</YStack>
|
||
);
|
||
}
|
||
|
||
function PreviewStep({
|
||
onBack,
|
||
layout,
|
||
backgroundPreset,
|
||
presets,
|
||
textFields,
|
||
qrUrl,
|
||
qrImage,
|
||
onExport,
|
||
}: {
|
||
onBack: () => void;
|
||
layout: EventQrInviteLayout | null;
|
||
backgroundPreset: string | null;
|
||
presets: { id: string; src: string; label: string }[];
|
||
textFields: { headline: string; subtitle: string; description: string; instructions: string[] };
|
||
qrUrl: string;
|
||
qrImage: string;
|
||
onExport: (format: 'pdf' | 'png') => void;
|
||
}) {
|
||
const { t } = useTranslation('management');
|
||
const { textStrong, text, muted, border, surfaceMuted } = useAdminTheme();
|
||
const presetSrc = backgroundPreset ? presets.find((p) => p.id === backgroundPreset)?.src ?? null : null;
|
||
const resolvedBg = presetSrc ?? layout?.preview?.background ?? surfaceMuted;
|
||
const previewText = layout?.preview?.text ?? textStrong;
|
||
const previewBody = layout?.preview?.text ?? text;
|
||
|
||
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>
|
||
{layout ? (
|
||
<PillBadge tone="muted">
|
||
{(layout.paper || 'A4').toUpperCase()} • {(layout.orientation || 'portrait').toUpperCase()}
|
||
</PillBadge>
|
||
) : null}
|
||
</XStack>
|
||
|
||
<YStack space="$2">
|
||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||
{t('events.qr.preview', 'Vorschau')}
|
||
</Text>
|
||
<YStack
|
||
borderRadius={16}
|
||
borderWidth={1}
|
||
borderColor={border}
|
||
overflow="hidden"
|
||
backgroundColor={presetSrc ? 'transparent' : surfaceMuted}
|
||
style={
|
||
presetSrc
|
||
? {
|
||
backgroundImage: `url(${presetSrc})`,
|
||
backgroundSize: 'cover',
|
||
backgroundPosition: 'center',
|
||
}
|
||
: { background: resolvedBg ?? surfaceMuted }
|
||
}
|
||
padding="$3"
|
||
gap="$3"
|
||
>
|
||
<Text fontSize="$lg" fontWeight="800" color={previewText}>
|
||
{textFields.headline || layout?.name || t('events.qr.previewHeadline', 'Event QR')}
|
||
</Text>
|
||
{textFields.subtitle ? (
|
||
<Text fontSize="$sm" color={previewBody}>
|
||
{textFields.subtitle}
|
||
</Text>
|
||
) : null}
|
||
{textFields.description ? (
|
||
<Text fontSize="$sm" color={previewBody}>
|
||
{textFields.description}
|
||
</Text>
|
||
) : null}
|
||
<YStack space="$1">
|
||
{textFields.instructions.filter((i) => i.trim().length > 0).map((item, idx) => (
|
||
<Text key={idx} fontSize="$xs" color={previewBody}>
|
||
• {item}
|
||
</Text>
|
||
))}
|
||
</YStack>
|
||
<YStack alignItems="center" justifyContent="center">
|
||
{qrImage ? (
|
||
<>
|
||
<img
|
||
src={qrImage}
|
||
alt={t('events.qr.qrAlt', 'QR code')}
|
||
style={{ width: 140, height: 140, objectFit: 'contain' }}
|
||
/>
|
||
<Text fontSize="$xs" color={text} textAlign="center" marginTop="$2" style={{ wordBreak: 'break-word' }}>
|
||
{qrUrl}
|
||
</Text>
|
||
</>
|
||
) : (
|
||
<Text fontSize="$xs" color={muted}>
|
||
{t('events.qr.missing', 'Kein QR-Link vorhanden')}
|
||
</Text>
|
||
)}
|
||
</YStack>
|
||
</YStack>
|
||
</YStack>
|
||
|
||
<XStack space="$2">
|
||
<CTAButton label={t('events.qr.exportPdf', 'Export PDF')} onPress={() => onExport('pdf')} />
|
||
<CTAButton label={t('events.qr.exportPng', 'Export PNG')} onPress={() => onExport('png')} />
|
||
</XStack>
|
||
</YStack>
|
||
);
|
||
}
|