added more translations and added the new layout wizard
This commit is contained in:
@@ -1,24 +1,24 @@
|
||||
import React from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Download, Share2, ChevronRight, RefreshCcw } from 'lucide-react';
|
||||
import { YStack, XStack } from '@tamagui/stacks';
|
||||
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, getEvent, getEventQrInvites, createQrInvite } from '../api';
|
||||
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';
|
||||
import { MobileSheet } from './components/Sheet';
|
||||
|
||||
const LAYOUTS = [
|
||||
{ key: 'badges', title: 'Badges', subtitle: 'Standard, Staff' },
|
||||
{ key: 'tents', title: 'Table Tents', subtitle: 'A4, Letter' },
|
||||
{ key: 'posters', title: 'Posters', subtitle: 'A3, 11x17' },
|
||||
{ key: 'programs', title: 'Event Programs', subtitle: 'Folded, Booklet' },
|
||||
];
|
||||
|
||||
export default function MobileQrPrintPage() {
|
||||
const { slug: slugParam } = useParams<{ slug?: string }>();
|
||||
@@ -27,12 +27,26 @@ export default function MobileQrPrintPage() {
|
||||
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 [paperSize, setPaperSize] = React.useState('A4 (210 x 297 mm)');
|
||||
const [qrUrl, setQrUrl] = React.useState<string>('');
|
||||
const [showPaperSheet, setShowPaperSheet] = React.useState(false);
|
||||
const [showLayoutSheet, setShowLayoutSheet] = React.useState(false);
|
||||
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;
|
||||
@@ -42,7 +56,20 @@ export default function MobileQrPrintPage() {
|
||||
const data = await getEvent(slug);
|
||||
const invites = await getEventQrInvites(slug);
|
||||
setEvent(data);
|
||||
const primaryInvite = invites.find((item) => item.is_active) ?? invites[0];
|
||||
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) {
|
||||
@@ -97,7 +124,7 @@ export default function MobileQrPrintPage() {
|
||||
/>
|
||||
) : (
|
||||
<Text color="#9ca3af" fontSize="$sm">
|
||||
{t('events.qr.missing', 'Kein QR-Link vorhanden')}
|
||||
{t('events.qr.missing', 'Kein QR-Link vorhanden')}
|
||||
</Text>
|
||||
)}
|
||||
</YStack>
|
||||
@@ -134,29 +161,122 @@ export default function MobileQrPrintPage() {
|
||||
<Text fontSize="$md" fontWeight="800" color="#111827">
|
||||
{t('events.qr.layouts', 'Print Layouts')}
|
||||
</Text>
|
||||
<YStack space="$1">
|
||||
{LAYOUTS.map((layout) => (
|
||||
<XStack
|
||||
key={layout.key}
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
paddingVertical="$2"
|
||||
borderBottomWidth={layout.key === 'programs' ? 0 : 1}
|
||||
borderColor="#e5e7eb"
|
||||
onPress={() => setShowLayoutSheet(true)}
|
||||
>
|
||||
<YStack>
|
||||
<Text fontSize="$sm" fontWeight="700" color="#111827">
|
||||
{layout.title}
|
||||
</Text>
|
||||
<Text fontSize="$xs" color="#6b7280">
|
||||
{layout.subtitle}
|
||||
</Text>
|
||||
</YStack>
|
||||
<ChevronRight size={16} color="#9ca3af" />
|
||||
</XStack>
|
||||
))}
|
||||
</YStack>
|
||||
{(() => {
|
||||
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">
|
||||
@@ -174,19 +294,17 @@ export default function MobileQrPrintPage() {
|
||||
</Text>
|
||||
</label>
|
||||
</XStack>
|
||||
<Pressable onPress={() => setShowPaperSheet(true)}>
|
||||
<XStack justifyContent="space-between" alignItems="center" paddingVertical="$2">
|
||||
<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.paper', 'Paper Size')}
|
||||
{t('events.qr.paperAuto', 'Auto (per layout)')}
|
||||
</Text>
|
||||
<XStack alignItems="center" space="$2">
|
||||
<Text fontSize="$sm" color="#111827">
|
||||
{paperSize}
|
||||
</Text>
|
||||
<ChevronRight size={16} color="#9ca3af" />
|
||||
</XStack>
|
||||
<ChevronRight size={16} color="#9ca3af" />
|
||||
</XStack>
|
||||
</Pressable>
|
||||
</XStack>
|
||||
<CTAButton
|
||||
label={t('events.qr.preview', 'Preview & Print')}
|
||||
onPress={() => toast.success(t('events.qr.previewStarted', 'Preview gestartet (mock)'))}
|
||||
@@ -206,62 +324,406 @@ export default function MobileQrPrintPage() {
|
||||
}}
|
||||
/>
|
||||
</MobileCard>
|
||||
|
||||
<MobileSheet
|
||||
open={showPaperSheet}
|
||||
onClose={() => setShowPaperSheet(false)}
|
||||
title={t('events.qr.paper', 'Paper Size')}
|
||||
footer={null}
|
||||
>
|
||||
<YStack space="$2">
|
||||
{['A4 (210 x 297 mm)', 'Letter (8.5 x 11 in)', 'A3 (297 x 420 mm)'].map((size) => (
|
||||
<Pressable
|
||||
key={size}
|
||||
onPress={() => {
|
||||
setPaperSize(size);
|
||||
setShowPaperSheet(false);
|
||||
}}
|
||||
>
|
||||
<XStack alignItems="center" justifyContent="space-between" paddingVertical="$2">
|
||||
<Text fontSize="$sm" color="#111827">
|
||||
{size}
|
||||
</Text>
|
||||
{paperSize === size ? <ChevronRight size={16} color="#007AFF" /> : null}
|
||||
</XStack>
|
||||
</Pressable>
|
||||
))}
|
||||
</YStack>
|
||||
</MobileSheet>
|
||||
|
||||
<MobileSheet
|
||||
open={showLayoutSheet}
|
||||
onClose={() => setShowLayoutSheet(false)}
|
||||
title={t('events.qr.layouts', 'Print Layouts')}
|
||||
footer={
|
||||
<CTAButton
|
||||
label={t('events.qr.preview', 'Preview & Print')}
|
||||
onPress={() => toast.success(t('events.qr.previewStarted', 'Preview gestartet (mock)'))}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<YStack space="$2">
|
||||
{LAYOUTS.map((layout) => (
|
||||
<MobileCard key={`lay-${layout.key}`} padding="$3" borderColor="#e5e7eb">
|
||||
<XStack alignItems="center" justifyContent="space-between">
|
||||
<YStack>
|
||||
<Text fontSize="$sm" fontWeight="700" color="#111827">
|
||||
{layout.title}
|
||||
</Text>
|
||||
<Text fontSize="$xs" color="#6b7280">
|
||||
{layout.subtitle}
|
||||
</Text>
|
||||
</YStack>
|
||||
<PillBadge tone="muted">{paperSize}</PillBadge>
|
||||
</XStack>
|
||||
</MobileCard>
|
||||
))}
|
||||
</YStack>
|
||||
</MobileSheet>
|
||||
</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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user