Files
fotospiel-app/resources/js/admin/mobile/QrPrintPage.tsx
Codex Agent 0eb3b85f06
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
Add tenant PWA help articles and links
2026-01-23 10:29:20 +01:00

660 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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';
import { ContextHelpLink } from './components/ContextHelpLink';
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}
<XStack justifyContent="flex-end">
<ContextHelpLink slug="guest-access-qr" />
</XStack>
<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>
);
}