added more translations and added the new layout wizard

This commit is contained in:
Codex Agent
2025-12-11 16:55:12 +01:00
parent b4417db5cd
commit 57be7d0030
15 changed files with 4951 additions and 2897 deletions

View File

@@ -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}
/>
);
}