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

@@ -29,6 +29,9 @@ export type EventQrInviteLayout = {
name: string;
description: string;
subtitle: string;
paper?: string | null;
orientation?: string | null;
panel_mode?: string | null;
badge_label?: string | null;
instructions_heading?: string | null;
link_heading?: string | null;
@@ -1133,6 +1136,9 @@ function normalizeQrInvite(raw: JsonValue): EventQrInvite {
name: String(layout.name ?? ''),
description: String(layout.description ?? ''),
subtitle: String(layout.subtitle ?? ''),
paper: layout.paper ?? null,
orientation: layout.orientation ?? null,
panel_mode: layout.panel_mode ?? null,
badge_label: layout.badge_label ?? null,
instructions_heading: layout.instructions_heading ?? null,
link_heading: layout.link_heading ?? null,

View File

@@ -1766,6 +1766,20 @@
"days": "+{{count}} Tage"
}
},
"mobileEvents": {
"edit": "Event bearbeiten"
},
"events.qr.layouts.badges.title": "Badges",
"events.qr.layouts.badges.subtitle": "Standard, Staff",
"events.qr.layouts.tents.title": "Tischnummern",
"events.qr.layouts.tents.subtitle": "A4, Letter",
"events.qr.layouts.posters.title": "Poster",
"events.qr.layouts.posters.subtitle": "A3, 11x17",
"events.qr.layouts.programs.title": "Programmhefte",
"events.qr.layouts.programs.subtitle": "Gefalzt, Booklet",
"events.qr.paperOption.A4 (210 x 297 mm)": "A4 (210 x 297 mm)",
"events.qr.paperOption.Letter (8.5 x 11 in)": "Letter (8.5 x 11 in)",
"events.qr.paperOption.A3 (297 x 420 mm)": "A3 (297 x 420 mm)",
"mobileNotifications": {
"title": "Benachrichtigungen",
"empty": "Keine Benachrichtigungen vorhanden.",

View File

@@ -1789,6 +1789,20 @@
"days": "+{{count}} days"
}
},
"mobileEvents": {
"edit": "Edit event"
},
"events.qr.layouts.badges.title": "Badges",
"events.qr.layouts.badges.subtitle": "Standard, Staff",
"events.qr.layouts.tents.title": "Table Tents",
"events.qr.layouts.tents.subtitle": "A4, Letter",
"events.qr.layouts.posters.title": "Posters",
"events.qr.layouts.posters.subtitle": "A3, 11x17",
"events.qr.layouts.programs.title": "Event Programs",
"events.qr.layouts.programs.subtitle": "Folded, Booklet",
"events.qr.paperOption.A4 (210 x 297 mm)": "A4 (210 x 297 mm)",
"events.qr.paperOption.Letter (8.5 x 11 in)": "Letter (8.5 x 11 in)",
"events.qr.paperOption.A3 (297 x 420 mm)": "A3 (297 x 420 mm)",
"mobileNotifications": {
"title": "Notifications",
"empty": "No notifications yet.",

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { CalendarDays, MapPin, Settings, Users, Camera, Sparkles, QrCode, Image, Shield, Layout, RefreshCcw, ChevronDown } from 'lucide-react';
import { CalendarDays, MapPin, Settings, Users, Camera, Sparkles, QrCode, Image, Shield, Layout, RefreshCcw, ChevronDown, Pencil } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite';
@@ -129,6 +129,24 @@ export default function MobileEventDetailPage() {
<PillBadge tone={event?.status === 'published' ? 'success' : 'warning'}>
{event?.status === 'published' ? t('events.status.published', 'Live') : t('events.status.draft', 'Draft')}
</PillBadge>
<Pressable
aria-label={t('mobileEvents.edit', 'Edit event')}
onPress={() => slug && navigate(adminPath(`/mobile/events/${slug}/edit`))}
style={{
position: 'absolute',
right: 16,
top: 16,
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: '#e2e8f0',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 6px 16px rgba(0,0,0,0.12)',
}}
>
<Pencil size={18} color="#0f172a" />
</Pressable>
</MobileCard>
<YStack space="$2">

View File

@@ -4,9 +4,10 @@ import { useTranslation } from 'react-i18next';
import { CalendarDays, ChevronDown, MapPin } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Switch } from '@tamagui/switch';
import { MobileShell } from './components/MobileShell';
import { MobileCard, CTAButton } from './components/Primitives';
import { createEvent, getEvent, updateEvent, TenantEvent } from '../api';
import { createEvent, getEvent, updateEvent, getEventTypes, TenantEvent, TenantEventType } from '../api';
import { adminPath } from '../constants';
import { isAuthError } from '../auth/tokens';
import { getApiErrorMessage } from '../lib/apiError';
@@ -14,14 +15,13 @@ import { getApiErrorMessage } from '../lib/apiError';
type FormState = {
name: string;
date: string;
eventType: string;
eventTypeId: number | null;
description: string;
location: string;
enableBranding: boolean;
published: boolean;
};
const EVENT_TYPES = ['Wedding', 'Corporate', 'Party', 'Other'];
export default function MobileEventFormPage() {
const { slug: slugParam } = useParams<{ slug?: string }>();
const slug = slugParam ?? null;
@@ -32,11 +32,14 @@ export default function MobileEventFormPage() {
const [form, setForm] = React.useState<FormState>({
name: '',
date: '',
eventType: EVENT_TYPES[0],
eventTypeId: null,
description: '',
location: '',
enableBranding: false,
published: false,
});
const [eventTypes, setEventTypes] = React.useState<TenantEventType[]>([]);
const [typesLoading, setTypesLoading] = React.useState(false);
const [loading, setLoading] = React.useState(isEdit);
const [saving, setSaving] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
@@ -49,11 +52,12 @@ export default function MobileEventFormPage() {
const data = await getEvent(slug);
setForm({
name: renderName(data.name),
date: data.event_date ?? '',
eventType: data.event_type?.name ?? EVENT_TYPES[0],
date: toDateTimeLocal(data.event_date),
eventTypeId: data.event_type_id ?? data.event_type?.id ?? null,
description: typeof data.description === 'string' ? data.description : '',
location: resolveLocation(data),
enableBranding: Boolean((data.settings as Record<string, unknown>)?.branding_allowed ?? true),
published: data.status === 'published',
});
setError(null);
} catch (err) {
@@ -66,6 +70,24 @@ export default function MobileEventFormPage() {
})();
}, [slug, t, isEdit]);
React.useEffect(() => {
(async () => {
setTypesLoading(true);
try {
const types = await getEventTypes();
setEventTypes(types);
// Default to first type if none set
if (!form.eventTypeId && types.length) {
setForm((prev) => ({ ...prev, eventTypeId: types[0].id }));
}
} catch {
// silently ignore; fallback to null
} finally {
setTypesLoading(false);
}
})();
}, []);
async function handleSubmit() {
setSaving(true);
setError(null);
@@ -74,6 +96,8 @@ export default function MobileEventFormPage() {
await updateEvent(slug, {
name: form.name,
event_date: form.date || undefined,
event_type_id: form.eventTypeId ?? undefined,
status: form.published ? 'published' : 'draft',
settings: { branding_allowed: form.enableBranding, location: form.location },
});
navigate(adminPath(`/mobile/events/${slug}`));
@@ -81,9 +105,9 @@ export default function MobileEventFormPage() {
const payload = {
name: form.name || 'Event',
slug: `${Date.now()}`,
event_type_id: 1,
event_type_id: form.eventTypeId ?? undefined,
event_date: form.date || undefined,
status: 'draft' as const,
status: form.published ? 'published' : 'draft' as const,
settings: { branding_allowed: form.enableBranding, location: form.location },
};
const { event } = await createEvent(payload as any);
@@ -135,31 +159,25 @@ export default function MobileEventFormPage() {
</XStack>
</Field>
<Field label={t('events.form.type', 'Event Type')}>
<XStack space="$1" flexWrap="wrap">
{EVENT_TYPES.map((type) => {
const active = form.eventType === type;
return (
<button
key={type}
type="button"
onClick={() => setForm((prev) => ({ ...prev, eventType: type }))}
style={{
padding: '10px 12px',
borderRadius: 10,
border: `1px solid ${active ? '#007AFF' : '#e5e7eb'}`,
background: active ? '#e8f1ff' : 'white',
color: active ? '#0f172a' : '#111827',
fontWeight: 700,
minWidth: 90,
textAlign: 'center',
}}
>
{type}
</button>
);
})}
</XStack>
<Field label={t('eventForm.fields.type.label', 'Event type')}>
{typesLoading ? (
<Text fontSize="$sm" color="#6b7280">{t('eventForm.fields.type.loading', 'Loading event types…')}</Text>
) : eventTypes.length === 0 ? (
<Text fontSize="$sm" color="#6b7280">{t('eventForm.fields.type.empty', 'No event types available yet. Please add one in the admin area.')}</Text>
) : (
<select
value={form.eventTypeId ?? ''}
onChange={(e) => setForm((prev) => ({ ...prev, eventTypeId: Number(e.target.value) }))}
style={{ ...inputStyle, height: 44 }}
>
<option value="">{t('eventForm.fields.type.placeholder', 'Select event type')}</option>
{eventTypes.map((type) => (
<option key={type.id} value={type.id}>
{renderName(type.name as any) || type.slug}
</option>
))}
</select>
)}
</Field>
<Field label={t('events.form.description', 'Optional Details')}>
@@ -185,16 +203,40 @@ export default function MobileEventFormPage() {
</Field>
<Field label={t('events.form.enableBranding', 'Enable Branding & Moderation')}>
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
type="checkbox"
<XStack alignItems="center" space="$2">
<Switch
checked={form.enableBranding}
onChange={(e) => setForm((prev) => ({ ...prev, enableBranding: e.target.checked }))}
/>
onCheckedChange={(checked) =>
setForm((prev) => ({ ...prev, enableBranding: Boolean(checked) }))
}
size="$3"
aria-label={t('events.form.enableBranding', 'Enable Branding & Moderation')}
>
<Switch.Thumb />
</Switch>
<Text fontSize="$sm" color="#111827">
{form.enableBranding ? t('common.enabled', 'Enabled') : t('common.disabled', 'Disabled')}
</Text>
</label>
</XStack>
</Field>
<Field label={t('eventForm.fields.publish.label', 'Publish immediately')}>
<XStack alignItems="center" space="$2">
<Switch
checked={form.published}
onCheckedChange={(checked) =>
setForm((prev) => ({ ...prev, published: Boolean(checked) }))
}
size="$3"
aria-label={t('eventForm.fields.publish.label', 'Publish immediately')}
>
<Switch.Thumb />
</Switch>
<Text fontSize="$sm" color="#111827">
{form.published ? t('common.enabled', 'Enabled') : t('common.disabled', 'Disabled')}
</Text>
</XStack>
<Text fontSize="$xs" color="#6b7280">{t('eventForm.fields.publish.help', 'Enable if guests should see the event right away. You can change the status later.')}</Text>
</Field>
</MobileCard>
@@ -250,6 +292,16 @@ function renderName(name: TenantEvent['name']): string {
return '';
}
function toDateTimeLocal(value?: string | null): string {
if (!value) return '';
const parsed = new Date(value);
if (!Number.isNaN(parsed.getTime())) {
return parsed.toISOString().slice(0, 16);
}
const fallback = value.replace(' ', 'T');
return fallback.length >= 16 ? fallback.slice(0, 16) : '';
}
function resolveLocation(event: TenantEvent): string {
const settings = (event.settings ?? {}) as Record<string, unknown>;
const candidate =

View File

@@ -0,0 +1,297 @@
import React from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { RefreshCcw, PlugZap, RefreshCw, ShieldCheck, Copy, Power, Clock3 } 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 { useTheme } from '@tamagui/core';
import { MobileShell } from './components/MobileShell';
import { MobileCard, CTAButton, PillBadge } from './components/Primitives';
import {
getEvent,
getEventPhotoboothStatus,
enableEventPhotobooth,
disableEventPhotobooth,
rotateEventPhotobooth,
PhotoboothStatus,
TenantEvent,
} from '../api';
import { isAuthError } from '../auth/tokens';
import { getApiErrorMessage } from '../lib/apiError';
import { formatEventDate, resolveEventDisplayName } from '../lib/events';
import toast from 'react-hot-toast';
export default function MobileEventPhotoboothPage() {
const { slug: slugParam } = useParams<{ slug?: string }>();
const slug = slugParam ?? null;
const navigate = useNavigate();
const { t, i18n } = useTranslation('management');
const theme = useTheme();
const text = String(theme.color?.val ?? '#111827');
const muted = String(theme.gray?.val ?? '#4b5563');
const border = String(theme.borderColor?.val ?? '#e5e7eb');
const surface = String(theme.surface?.val ?? '#ffffff');
const [event, setEvent] = React.useState<TenantEvent | null>(null);
const [status, setStatus] = React.useState<PhotoboothStatus | null>(null);
const [loading, setLoading] = React.useState(true);
const [updating, setUpdating] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const locale = i18n.language?.startsWith('en') ? 'en-GB' : 'de-DE';
const load = React.useCallback(async () => {
if (!slug) return;
setLoading(true);
setError(null);
try {
const [eventData, statusData] = await Promise.all([getEvent(slug), getEventPhotoboothStatus(slug)]);
setEvent(eventData);
setStatus(statusData);
} catch (err) {
if (!isAuthError(err)) {
setError(getApiErrorMessage(err, t('management.photobooth.errors.loadFailed', 'Photobooth-Link konnte nicht geladen werden.')));
}
} finally {
setLoading(false);
}
}, [slug, t]);
React.useEffect(() => {
void load();
}, [load]);
const handleEnable = async (mode?: 'ftp' | 'sparkbooth') => {
if (!slug) return;
setUpdating(true);
try {
const result = await enableEventPhotobooth(slug, { mode: mode ?? status?.mode ?? 'ftp' });
setStatus(result);
toast.success(t('management.photobooth.actions.enable', 'Zugang aktiviert'));
} catch (err) {
if (!isAuthError(err)) {
toast.error(getApiErrorMessage(err, t('management.photobooth.errors.enableFailed', 'Zugang konnte nicht aktiviert werden.')));
}
} finally {
setUpdating(false);
}
};
const handleDisable = async () => {
if (!slug) return;
setUpdating(true);
try {
const result = await disableEventPhotobooth(slug, { mode: status?.mode ?? 'ftp' });
setStatus(result);
toast.success(t('management.photobooth.actions.disable', 'Zugang deaktiviert'));
} catch (err) {
if (!isAuthError(err)) {
toast.error(getApiErrorMessage(err, t('management.photobooth.errors.disableFailed', 'Zugang konnte nicht deaktiviert werden.')));
}
} finally {
setUpdating(false);
}
};
const handleRotate = async () => {
if (!slug) return;
setUpdating(true);
try {
const result = await rotateEventPhotobooth(slug, { mode: status?.mode ?? 'ftp' });
setStatus(result);
toast.success(t('management.photobooth.presets.actions.rotate', 'Zugang zurückgesetzt'));
} catch (err) {
if (!isAuthError(err)) {
toast.error(getApiErrorMessage(err, t('management.photobooth.errors.rotateFailed', 'Zugangsdaten konnten nicht neu generiert werden.')));
}
} finally {
setUpdating(false);
}
};
const modeLabel =
status?.mode === 'sparkbooth'
? t('photobooth.credentials.sparkboothTitle', 'Sparkbooth / HTTP')
: t('photobooth.credentials.heading', 'FTP (Classic)');
const isActive = Boolean(status?.enabled);
const title = event ? resolveEventDisplayName(event) : t('management.header.appName', 'Event Admin');
const subtitle =
event?.event_date ? formatEventDate(event.event_date, locale) : t('header.selectEvent', 'Select an event to continue');
return (
<MobileShell
activeTab="home"
title={title}
subtitle={subtitle ?? undefined}
onBack={() => navigate(-1)}
headerActions={
<Pressable onPress={() => load()}>
<RefreshCcw size={18} color={text} />
</Pressable>
}
>
{error ? (
<MobileCard>
<Text fontWeight="700" color="#b91c1c">
{error}
</Text>
</MobileCard>
) : null}
{loading ? (
<YStack space="$2">
{Array.from({ length: 3 }).map((_, idx) => (
<MobileCard key={`ph-skel-${idx}`} height={110} opacity={0.6} />
))}
</YStack>
) : (
<YStack space="$2">
<MobileCard space="$2">
<XStack justifyContent="space-between" alignItems="center">
<YStack space="$1">
<Text fontSize="$md" fontWeight="800" color={text}>
{t('photobooth.title', 'Photobooth')}
</Text>
<Text fontSize="$xs" color={muted}>
{t('photobooth.credentials.description', 'Share these credentials with your photobooth software.')}
</Text>
<Text fontSize="$xs" color={muted}>
{t('photobooth.mode.active', 'Current: {{mode}}', { mode: modeLabel })}
</Text>
</YStack>
<PillBadge tone={isActive ? 'success' : 'warning'}>
{isActive ? t('photobooth.status.badgeActive', 'ACTIVE') : t('photobooth.status.badgeInactive', 'INACTIVE')}
</PillBadge>
</XStack>
<XStack space="$2" marginTop="$2" flexWrap="nowrap">
<XStack flex={1} minWidth={0}>
<CTAButton
label={t('photobooth.credentials.heading', 'FTP credentials')}
tone={status?.mode === 'ftp' ? 'primary' : 'ghost'}
onPress={() => handleEnable('ftp')}
disabled={updating}
style={{ width: '100%', paddingHorizontal: 10, paddingVertical: 10 }}
/>
</XStack>
<XStack flex={1} minWidth={0}>
<CTAButton
label={t('photobooth.credentials.sparkboothTitle', 'Sparkbooth upload (HTTP)')}
tone={status?.mode === 'sparkbooth' ? 'primary' : 'ghost'}
onPress={() => handleEnable('sparkbooth')}
disabled={updating}
style={{ width: '100%', paddingHorizontal: 10, paddingVertical: 10 }}
/>
</XStack>
</XStack>
</MobileCard>
<MobileCard space="$2">
<Text fontSize="$sm" fontWeight="700" color={text}>
{t('photobooth.credentials.heading', 'FTP credentials')}
</Text>
<YStack space="$1">
<CredentialRow label={t('photobooth.credentials.host', 'Host')} value={status?.host ?? '—'} border={border} />
<CredentialRow label={t('photobooth.credentials.username', 'Username')} value={status?.username ?? '—'} border={border} />
<CredentialRow label={t('photobooth.credentials.password', 'Password')} value={status?.password ?? '—'} border={border} masked />
{status?.upload_url ? <CredentialRow label={t('photobooth.credentials.postUrl', 'Upload URL')} value={status.upload_url} border={border} /> : null}
</YStack>
<XStack space="$2" marginTop="$2" flexWrap="nowrap">
<XStack flex={1} minWidth={0}>
<CTAButton
label={updating ? t('common.processing', '...') : t('photobooth.actions.rotate', 'Regenerate access')}
onPress={() => handleRotate()}
iconLeft={<RefreshCw size={14} color={surface} />}
style={{ width: '100%', paddingHorizontal: 10, paddingVertical: 10 }}
/>
</XStack>
<XStack flex={1} minWidth={0}>
<CTAButton
label={isActive ? t('photobooth.actions.disable', 'Disable') : t('photobooth.actions.enable', 'Activate photobooth')}
onPress={() => (isActive ? handleDisable() : handleEnable())}
tone={isActive ? 'ghost' : 'primary'}
iconLeft={isActive ? <Power size={14} color={text} /> : <PlugZap size={14} color={surface} />}
disabled={updating}
style={{ width: '100%', paddingHorizontal: 10, paddingVertical: 10 }}
/>
</XStack>
</XStack>
</MobileCard>
<MobileCard space="$2">
<Text fontSize="$sm" fontWeight="700" color={text}>
{t('photobooth.status.heading', 'Status')}
</Text>
<YStack space="$1">
<StatusRow icon={<ShieldCheck size={16} color={text} />} label={t('photobooth.status.mode', 'Mode')} value={modeLabel} />
<StatusRow
icon={<PlugZap size={16} color={text} />}
label={t('photobooth.status.heading', 'Status')}
value={isActive ? t('common.enabled', 'Enabled') : t('common.disabled', 'Disabled')}
/>
{status?.metrics?.uploads_last_hour != null ? (
<StatusRow
icon={<RefreshCcw size={16} color={text} />}
label={t('photobooth.rateLimit.usage', 'Uploads last hour')}
value={String(status.metrics.uploads_last_hour)}
/>
) : null}
{status?.metrics?.last_upload_at ? (
<StatusRow
icon={<Clock3 size={16} color={text} />}
label={t('photobooth.stats.lastUpload', 'Letzter Upload')}
value={formatEventDate(status.metrics.last_upload_at, locale) ?? '—'}
/>
) : null}
</YStack>
</MobileCard>
</YStack>
)}
</MobileShell>
);
}
function CredentialRow({ label, value, border, masked }: { label: string; value: string; border: string; masked?: boolean }) {
const { t } = useTranslation('management');
return (
<XStack alignItems="center" justifyContent="space-between" borderWidth={1} borderColor={border} borderRadius="$3" padding="$2">
<YStack>
<Text fontSize="$xs" color="#6b7280">
{label}
</Text>
<Text fontSize="$sm" fontWeight="700" color="#111827">
{masked ? '••••••••' : value}
</Text>
</YStack>
<Pressable
onPress={async () => {
try {
await navigator.clipboard.writeText(value);
toast.success(t('common.copied', 'Kopiert'));
} catch {
toast.error(t('common.copyFailed', 'Kopieren fehlgeschlagen'));
}
}}
>
<Copy size={16} color="#6b7280" />
</Pressable>
</XStack>
);
}
function StatusRow({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
return (
<XStack alignItems="center" justifyContent="space-between">
<XStack alignItems="center" space="$2">
{icon}
<Text fontSize="$sm" color="#111827">
{label}
</Text>
</XStack>
<Text fontSize="$sm" fontWeight="700" color="#111827">
{value}
</Text>
</XStack>
);
}

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

View File

@@ -107,11 +107,11 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
{onBack ? (
<Pressable onPress={onBack}>
<XStack alignItems="center" space="$1.5">
<ChevronLeft size={18} color="#007AFF" />
<ChevronLeft size={28} color="#007AFF" strokeWidth={2.5} />
</XStack>
</Pressable>
) : (
<XStack width={18} />
<XStack width={28} />
)}
<XStack alignItems="center" space="$2.5" flex={1} justifyContent="flex-end">
@@ -195,7 +195,7 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
{showDevTenantSwitcher ? (
<Suspense fallback={null}>
<DevTenantSwitcher bottomOffset={96} />
<DevTenantSwitcher bottomOffset={64} />
</Suspense>
) : null}

View File

@@ -21,7 +21,7 @@ const EventMembersPage = React.lazy(() => import('./pages/EventMembersPage'));
const EventTasksPage = React.lazy(() => import('./pages/EventTasksPage'));
const EventToolkitPage = React.lazy(() => import('./pages/EventToolkitPage'));
const EventInvitesPage = React.lazy(() => import('./pages/EventInvitesPage'));
const EventPhotoboothPage = React.lazy(() => import('./pages/EventPhotoboothPage'));
const EventPhotoboothPage = React.lazy(() => import('./mobile/EventPhotoboothPage'));
const EventBrandingPage = React.lazy(() => import('./pages/EventBrandingPage'));
const MobileEventsPage = React.lazy(() => import('./mobile/EventsPage'));
const MobileEventDetailPage = React.lazy(() => import('./mobile/EventDetailPage'));