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 { 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(null); const [selectedInvite, setSelectedInvite] = React.useState(null); const [selectedLayoutId, setSelectedLayoutId] = React.useState(null); const [selectedFormat, setSelectedFormat] = React.useState<'a4-poster' | 'a5-foldable' | null>(null); const [error, setError] = React.useState(null); const [loading, setLoading] = React.useState(true); const [qrUrl, setQrUrl] = React.useState(''); const [qrImage, setQrImage] = React.useState(''); 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 ( load()} ariaLabel={t('common.refresh', 'Refresh')}> } > {error ? ( {error} load()} /> ) : null} {t('events.qr.heroTitle', 'Entrance QR Code')} {qrImage ? ( {t('events.qr.qrAlt', ) : ( {t('events.qr.missing', 'Kein QR-Link vorhanden')} )} {qrUrl ? ( {qrUrl} ) : null} {selectedInvite?.expires_at ? t('events.qr.expiresAt', 'Valid until {{date}}', { date: formatExpiry(selectedInvite.expires_at), }) : t('events.qr.noExpiry', 'No expiry configured')} {t('events.qr.description', 'Scan to access the event guest app.')} { 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')); }} /> { 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')); } }} /> {t('events.qr.step1', 'Schritt 1: Format wählen')} {t('events.qr.layouts', 'Print Layouts')} { setSelectedFormat(format); setSelectedLayoutId(layoutId); }} /> { 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)}`); }} /> {t('events.qr.createLink', 'Neuen QR-Link erstellen')} { 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.'))); } }} /> ); } 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 ( {cards.map((card) => { const isSelected = selectedFormat === card.key; return ( onSelect(card.key, selectLayoutId(card.key))} style={{ width: '100%' }} > {card.title} {card.subtitle} {card.badges.map((badge) => ( {badge} ))} ); })} ); } 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 ( {t('common.back', 'Zurück')} {selectedLayout ? `${(selectedLayout.paper || 'A4').toUpperCase()} • ${(selectedLayout.orientation || 'portrait').toUpperCase()}` : 'Layout'} {t( 'events.qr.backgroundPicker', disablePresets ? 'Hintergrund für A5 (Gradient/Farbe)' : `Hintergrund auswählen (${formatLabel})` )} {!disablePresets ? ( <> {presets.map((preset) => { const isSelected = selectedPreset === preset.id; return ( onSelectPreset(preset.id)} style={{ width: '48%' }}> {preset.label} {isSelected ? {t('common.selected', 'Ausgewählt')} : null} ); })} {t('events.qr.backgroundNote', 'Diese Presets sind für A4 Hochformat. Spiegelung erfolgt automatisch bei Tischkarten.')} ) : ( {t('events.qr.foldableBackgroundNote', 'Für A5 Tischkarten bitte Gradient oder Vollfarbe wählen.')} )} ); } 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 ( {t('common.back', 'Zurück')} {t('events.qr.textStep', 'Texte & Hinweise')} {t('events.qr.textFields', 'Texte')} updateField('headline', val)} /> updateField('subtitle', val)} /> updateField('description', val)} /> {t('events.qr.instructions', 'Anleitung')} {textFields.instructions.map((item, idx) => ( updateInstruction(idx, val)} /> removeInstruction(idx)} disabled={textFields.instructions.length === 1} /> ))} ); } 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 ( {t('common.back', 'Zurück')} {layout ? ( {(layout.paper || 'A4').toUpperCase()} • {(layout.orientation || 'portrait').toUpperCase()} ) : null} {t('events.qr.preview', 'Vorschau')} {textFields.headline || layout?.name || t('events.qr.previewHeadline', 'Event QR')} {textFields.subtitle ? ( {textFields.subtitle} ) : null} {textFields.description ? ( {textFields.description} ) : null} {textFields.instructions.filter((i) => i.trim().length > 0).map((item, idx) => ( • {item} ))} {qrImage ? ( <> {t('events.qr.qrAlt', {qrUrl} ) : ( {t('events.qr.missing', 'Kein QR-Link vorhanden')} )} onExport('pdf')} /> onExport('png')} /> ); } function StyledInput({ value, onChangeText, placeholder, flex, }: { value: string; onChangeText: (value: string) => void; placeholder?: string; flex?: number; }) { const { border } = useAdminTheme(); return ( onChangeText(e.target.value)} placeholder={placeholder} /> ); } function StyledTextarea({ value, onChangeText, placeholder, }: { value: string; onChangeText: (value: string) => void; placeholder?: string; }) { const { border } = useAdminTheme(); return (