Files
fotospiel-app/resources/js/admin/mobile/QrPrintPage.tsx
2026-02-02 13:01:20 +01:00

670 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 gap="$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 gap="$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 gap="$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 gap="$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;
if (
!window.confirm(
t(
'events.qr.createLinkConfirm',
'Neuen QR-Link erstellen? Dadurch werden alle bisherigen Ausdrucke ungültig und alle Personen mit dem alten Link verlieren den Zugang.'
)
)
) {
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 gap="$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" gap="$3">
<YStack gap="$1" flex={1}>
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{card.title}
</Text>
<Text fontSize="$xs" color={muted}>
{card.subtitle}
</Text>
<XStack gap="$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 gap="$3" marginTop="$2">
<XStack alignItems="center" gap="$2">
<Pressable onPress={onBack}>
<XStack alignItems="center" gap="$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 gap="$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 gap="$3" marginTop="$2">
<XStack alignItems="center" gap="$2">
<Pressable onPress={onBack}>
<XStack alignItems="center" gap="$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 gap="$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 gap="$2">
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{t('events.qr.instructions', 'Anleitung')}
</Text>
{textFields.instructions.map((item, idx) => (
<XStack key={idx} alignItems="center" gap="$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 gap="$3" marginTop="$2">
<XStack alignItems="center" gap="$2">
<Pressable onPress={onBack}>
<XStack alignItems="center" gap="$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 gap="$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 gap="$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 gap="$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>
);
}