730 lines
26 KiB
TypeScript
730 lines
26 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, Stack } from '@tamagui/stacks';
|
||
import { SizableText as Text } from '@tamagui/text';
|
||
import { Pressable } from '@tamagui/react-native-web-lite';
|
||
import { MobileShell } from './components/MobileShell';
|
||
import { MobileCard, CTAButton, PillBadge } from './components/Primitives';
|
||
import {
|
||
TenantEvent,
|
||
EventQrInvite,
|
||
EventQrInviteLayout,
|
||
getEvent,
|
||
getEventQrInvites,
|
||
createQrInvite,
|
||
updateEventQrInvite,
|
||
} from '../api';
|
||
import { isAuthError } from '../auth/tokens';
|
||
import { getApiErrorMessage } from '../lib/apiError';
|
||
import toast from 'react-hot-toast';
|
||
|
||
export default function MobileQrPrintPage() {
|
||
const { slug: slugParam } = useParams<{ slug?: string }>();
|
||
const slug = slugParam ?? null;
|
||
const navigate = useNavigate();
|
||
const { t } = useTranslation('management');
|
||
|
||
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 [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;
|
||
(async () => {
|
||
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);
|
||
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)
|
||
: [''],
|
||
});
|
||
setQrUrl(primaryInvite?.url ?? data.public_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]);
|
||
|
||
return (
|
||
<MobileShell
|
||
activeTab="home"
|
||
title={t('events.qr.title', 'QR Code & Print Layouts')}
|
||
onBack={() => navigate(-1)}
|
||
headerActions={
|
||
<Pressable onPress={() => window.location.reload()}>
|
||
<RefreshCcw size={18} color="#0f172a" />
|
||
</Pressable>
|
||
}
|
||
>
|
||
{error ? (
|
||
<MobileCard>
|
||
<Text fontWeight="700" color="#b91c1c">
|
||
{error}
|
||
</Text>
|
||
</MobileCard>
|
||
) : null}
|
||
|
||
<MobileCard space="$3" alignItems="center">
|
||
<Text fontSize="$md" fontWeight="800" color="#111827">
|
||
{t('events.qr.heroTitle', 'Entrance QR Code')}
|
||
</Text>
|
||
<YStack
|
||
width={180}
|
||
height={180}
|
||
borderRadius={16}
|
||
borderWidth={1}
|
||
borderColor="#e5e7eb"
|
||
backgroundColor="#f8fafc"
|
||
alignItems="center"
|
||
justifyContent="center"
|
||
overflow="hidden"
|
||
>
|
||
{qrUrl ? (
|
||
<img
|
||
src={`https://api.qrserver.com/v1/create-qr-code/?size=220x220&data=${encodeURIComponent(qrUrl)}`}
|
||
alt="QR"
|
||
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
||
/>
|
||
) : (
|
||
<Text color="#9ca3af" fontSize="$sm">
|
||
{t('events.qr.missing', 'Kein QR-Link vorhanden')}
|
||
</Text>
|
||
)}
|
||
</YStack>
|
||
<Text fontSize="$xs" color="#6b7280">
|
||
{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')}
|
||
onPress={() => {
|
||
if (qrUrl) {
|
||
toast.success(t('events.qr.downloadStarted', 'Download gestartet'));
|
||
} else {
|
||
toast.error(t('events.qr.missing', 'Kein QR-Link vorhanden'));
|
||
}
|
||
}}
|
||
/>
|
||
<CTAButton
|
||
label={t('events.qr.share', 'Share')}
|
||
onPress={async () => {
|
||
try {
|
||
const shareUrl = String(qrUrl || (event as any)?.public_url || '');
|
||
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="$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');
|
||
}}
|
||
/>
|
||
);
|
||
})()}
|
||
</MobileCard>
|
||
|
||
<MobileCard space="$2">
|
||
<Text fontSize="$sm" fontWeight="700" color="#111827">
|
||
{t('events.qr.templates', 'Templates')}
|
||
</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' });
|
||
setQrUrl(invite.url);
|
||
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 LayoutSelection({
|
||
layouts,
|
||
selectedLayoutId,
|
||
onSelect,
|
||
}: {
|
||
layouts: EventQrInviteLayout[];
|
||
selectedLayoutId: string | null;
|
||
onSelect: (layoutId: string) => void;
|
||
}) {
|
||
const { t } = useTranslation('management');
|
||
|
||
if (!layouts.length) {
|
||
return (
|
||
<Text fontSize="$sm" color="#6b7280">
|
||
{t('events.qr.noLayouts', 'Keine Layouts verfügbar.')}
|
||
</Text>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<YStack space="$2" marginTop="$2">
|
||
{layouts.map((layout) => {
|
||
const isSelected = layout.id === selectedLayoutId;
|
||
return (
|
||
<Pressable key={layout.id} onPress={() => onSelect(layout.id)} style={{ width: '100%' }}>
|
||
<MobileCard
|
||
padding="$3"
|
||
borderColor={isSelected ? '#2563EB' : '#e5e7eb'}
|
||
borderWidth={isSelected ? 2 : 1}
|
||
backgroundColor={isSelected ? '#eff6ff' : '#fff'}
|
||
>
|
||
<XStack alignItems="center" justifyContent="space-between" space="$3">
|
||
<YStack space="$1" flex={1}>
|
||
<Text fontSize="$sm" fontWeight="700" color="#111827">
|
||
{layout.name || layout.id}
|
||
</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}
|
||
</XStack>
|
||
</YStack>
|
||
<ChevronRight size={16} color="#9ca3af" />
|
||
</XStack>
|
||
</MobileCard>
|
||
</Pressable>
|
||
);
|
||
})}
|
||
</YStack>
|
||
);
|
||
}
|
||
|
||
function BackgroundStep({
|
||
onBack,
|
||
presets,
|
||
selectedPreset,
|
||
onSelectPreset,
|
||
selectedLayout,
|
||
onSave,
|
||
saving,
|
||
}: {
|
||
onBack: () => void;
|
||
presets: { id: string; src: string; label: string }[];
|
||
selectedPreset: string | null;
|
||
onSelectPreset: (id: string) => void;
|
||
selectedLayout: EventQrInviteLayout | null;
|
||
onSave: () => void;
|
||
saving: boolean;
|
||
}) {
|
||
const { t } = useTranslation('management');
|
||
|
||
return (
|
||
<YStack space="$3" marginTop="$2">
|
||
<XStack alignItems="center" space="$2">
|
||
<Pressable onPress={onBack}>
|
||
<XStack alignItems="center" space="$1">
|
||
<ArrowLeft size={16} color="#111827" />
|
||
<Text fontSize="$sm" color="#111827">
|
||
{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="#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.')}
|
||
</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 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="#111827" />
|
||
<Text fontSize="$sm" color="#111827">
|
||
{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="#111827">
|
||
{t('events.qr.textFields', 'Texte')}
|
||
</Text>
|
||
<StyledInput
|
||
placeholder={t('events.qr.headline', 'Headline')}
|
||
value={textFields.headline}
|
||
onChangeText={(val) => updateField('headline', val)}
|
||
/>
|
||
<StyledInput
|
||
placeholder={t('events.qr.subtitle', 'Subtitle')}
|
||
value={textFields.subtitle}
|
||
onChangeText={(val) => updateField('subtitle', val)}
|
||
/>
|
||
<StyledTextarea
|
||
placeholder={t('events.qr.description', 'Beschreibung')}
|
||
value={textFields.description}
|
||
onChangeText={(val) => updateField('description', val)}
|
||
/>
|
||
</YStack>
|
||
|
||
<YStack space="$2">
|
||
<Text fontSize="$sm" fontWeight="700" color="#111827">
|
||
{t('events.qr.instructions', 'Anleitung')}
|
||
</Text>
|
||
{textFields.instructions.map((item, idx) => (
|
||
<XStack key={idx} alignItems="center" space="$2">
|
||
<StyledInput
|
||
flex={1}
|
||
placeholder={t('events.qr.instructionsPlaceholder', 'Schritt hinzufügen')}
|
||
value={item}
|
||
onChangeText={(val) => updateInstruction(idx, val)}
|
||
/>
|
||
<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,
|
||
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;
|
||
onExport: (format: 'pdf' | 'png') => void;
|
||
}) {
|
||
const { t } = useTranslation('management');
|
||
const presetSrc = backgroundPreset ? presets.find((p) => p.id === backgroundPreset)?.src ?? null : null;
|
||
const resolvedBg = presetSrc ?? layout?.preview?.background ?? '#f8fafc';
|
||
|
||
return (
|
||
<YStack space="$3" marginTop="$2">
|
||
<XStack alignItems="center" space="$2">
|
||
<Pressable onPress={onBack}>
|
||
<XStack alignItems="center" space="$1">
|
||
<ArrowLeft size={16} color="#111827" />
|
||
<Text fontSize="$sm" color="#111827">
|
||
{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="#111827">
|
||
{t('events.qr.preview', 'Vorschau')}
|
||
</Text>
|
||
<Stack
|
||
borderRadius={16}
|
||
borderWidth={1}
|
||
borderColor="#e5e7eb"
|
||
overflow="hidden"
|
||
backgroundColor={presetSrc ? 'transparent' : '#f8fafc'}
|
||
style={
|
||
presetSrc
|
||
? {
|
||
backgroundImage: `url(${presetSrc})`,
|
||
backgroundSize: 'cover',
|
||
backgroundPosition: 'center',
|
||
}
|
||
: { background: resolvedBg ?? '#f8fafc' }
|
||
}
|
||
padding="$3"
|
||
gap="$3"
|
||
>
|
||
<Text fontSize="$lg" fontWeight="800" color={layout?.preview?.text ?? '#0f172a'}>
|
||
{textFields.headline || layout?.name || t('events.qr.previewHeadline', 'Event QR')}
|
||
</Text>
|
||
{textFields.subtitle ? (
|
||
<Text fontSize="$sm" color={layout?.preview?.text ?? '#1f2937'}>
|
||
{textFields.subtitle}
|
||
</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'}>
|
||
• {item}
|
||
</Text>
|
||
))}
|
||
</YStack>
|
||
<YStack alignItems="center" justifyContent="center">
|
||
{qrUrl ? (
|
||
<img
|
||
src={`https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(qrUrl)}`}
|
||
alt="QR"
|
||
style={{ width: 140, height: 140, objectFit: 'contain' }}
|
||
/>
|
||
) : (
|
||
<Text fontSize="$xs" color="#6b7280">
|
||
{t('events.qr.missing', 'Kein QR-Link vorhanden')}
|
||
</Text>
|
||
)}
|
||
</YStack>
|
||
</Stack>
|
||
</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>
|
||
);
|
||
}
|
||
function StyledInput({
|
||
value,
|
||
onChangeText,
|
||
placeholder,
|
||
flex,
|
||
}: {
|
||
value: string;
|
||
onChangeText: (value: string) => void;
|
||
placeholder?: string;
|
||
flex?: number;
|
||
}) {
|
||
return (
|
||
<input
|
||
style={{
|
||
width: '100%',
|
||
padding: '10px 12px',
|
||
borderRadius: 12,
|
||
border: '1px solid #e5e7eb',
|
||
fontSize: 14,
|
||
outline: 'none',
|
||
flex: flex ?? undefined,
|
||
}}
|
||
value={value}
|
||
onChange={(e) => onChangeText(e.target.value)}
|
||
placeholder={placeholder}
|
||
/>
|
||
);
|
||
}
|
||
|
||
function StyledTextarea({
|
||
value,
|
||
onChangeText,
|
||
placeholder,
|
||
}: {
|
||
value: string;
|
||
onChangeText: (value: string) => void;
|
||
placeholder?: string;
|
||
}) {
|
||
return (
|
||
<textarea
|
||
style={{
|
||
width: '100%',
|
||
padding: '10px 12px',
|
||
borderRadius: 12,
|
||
border: '1px solid #e5e7eb',
|
||
fontSize: 14,
|
||
outline: 'none',
|
||
minHeight: 96,
|
||
}}
|
||
value={value}
|
||
onChange={(e) => onChangeText(e.target.value)}
|
||
placeholder={placeholder}
|
||
/>
|
||
);
|
||
}
|