further improvements for the mobile admin

This commit is contained in:
Codex Agent
2025-12-12 21:47:34 +01:00
parent 1719d96fed
commit a35f81705d
15 changed files with 914 additions and 290 deletions

View File

@@ -2,7 +2,7 @@ import React from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { CalendarDays, Image as ImageIcon, ListTodo, QrCode, Settings, Users, Sparkles } from 'lucide-react';
import { CheckCircle2, Image as ImageIcon, ListTodo, MessageCircle, QrCode, Settings, Users, Sparkles } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite';
@@ -133,35 +133,178 @@ function OnboardingEmptyState() {
const theme = useTheme();
const text = String(theme.color12?.val ?? theme.color?.val ?? '#f8fafc');
const muted = String(theme.gray11?.val ?? theme.gray?.val ?? '#cbd5e1');
const border = String(theme.borderColor?.val ?? '#cbd5e1');
const accent = String(theme.primary?.val ?? '#2563eb');
const accentSoft = String(theme.blue3?.val ?? '#e0f2fe');
const accentStrong = String(theme.blue10?.val ?? '#1d4ed8');
const stepBg = String(theme.gray2?.val ?? '#f8fafc');
const stepBorder = String(theme.gray5?.val ?? '#e2e8f0');
const supportBg = String(theme.gray2?.val ?? '#f8fafc');
const supportBorder = String(theme.gray5?.val ?? '#e2e8f0');
const steps = [
t('mobileDashboard.emptyStepDetails', 'Add name & date'),
t('mobileDashboard.emptyStepQr', 'Share your QR poster'),
t('mobileDashboard.emptyStepReview', 'Review first uploads'),
];
const previews = [
{
icon: QrCode,
title: t('mobileDashboard.emptyPreviewQr', 'Share QR poster'),
desc: t('mobileDashboard.emptyPreviewQrDesc', 'Print-ready codes for guests and crew.'),
},
{
icon: ImageIcon,
title: t('mobileDashboard.emptyPreviewGallery', 'Gallery & highlights'),
desc: t('mobileDashboard.emptyPreviewGalleryDesc', 'Moderate uploads, feature the best moments.'),
},
{
icon: ListTodo,
title: t('mobileDashboard.emptyPreviewTasks', 'Tasks & challenges'),
desc: t('mobileDashboard.emptyPreviewTasksDesc', 'Guide guests with playful prompts.'),
},
];
return (
<YStack space="$3">
<MobileCard alignItems="flex-start" space="$3">
<Text fontSize="$lg" fontWeight="800" color={text}>
{t('mobileDashboard.emptyTitle', 'Create your first event')}
</Text>
<Text fontSize="$sm" color={muted}>
{t('mobileDashboard.emptyBody', 'Start an event to manage tasks, QR posters and uploads.')}
</Text>
<CTAButton label={t('mobileDashboard.ctaCreate', 'Create event')} onPress={() => navigate(adminPath('/mobile/events/new'))} />
<CTAButton label={t('mobileDashboard.ctaDemo', 'View demo')} tone="ghost" onPress={() => navigate(adminPath('/mobile/events'))} />
<MobileCard
padding="$4"
borderColor="transparent"
overflow="hidden"
backgroundColor="linear-gradient(140deg, rgba(14,165,233,0.16), rgba(79,70,229,0.22))"
>
<YStack position="absolute" top={-10} right={-10} opacity={0.16} scale={1.2}>
<Sparkles size={72} color={accentStrong} />
</YStack>
<YStack position="absolute" bottom={-14} left={-8} opacity={0.14}>
<QrCode size={96} color={accentStrong} />
</YStack>
<YStack space="$2" zIndex={1}>
<PillBadge tone="muted">{t('mobileDashboard.emptyBadge', 'Welcome aboard')}</PillBadge>
<Text fontSize="$xl" fontWeight="900" color={text}>
{t('mobileDashboard.emptyTitle', "Welcome! Let's launch your first event")}
</Text>
<Text fontSize="$sm" color={text} opacity={0.9}>
{t('mobileDashboard.emptyBody', 'Print a QR, collect uploads, and start moderating in minutes.')}
</Text>
<CTAButton label={t('mobileDashboard.ctaCreate', 'Create event')} onPress={() => navigate(adminPath('/mobile/events/new'))} />
</YStack>
</MobileCard>
<MobileCard space="$2">
<Text fontSize="$sm" fontWeight="800" color={text}>
{t('mobileDashboard.highlightsTitle', 'What you can do')}
</Text>
<YStack space="$1.5">
{[
t('mobileDashboard.highlightImages', 'Review photos & uploads'),
t('mobileDashboard.highlightTasks', 'Assign tasks & challenges'),
t('mobileDashboard.highlightQr', 'Share QR posters'),
t('mobileDashboard.highlightGuests', 'Invite helpers & guests'),
].map((item) => (
<XStack key={item} alignItems="center" space="$2">
<PillBadge tone="muted">{item}</PillBadge>
<MobileCard space="$2.5" borderColor={border} backgroundColor={stepBg}>
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$sm" fontWeight="800" color={text}>
{t('mobileDashboard.emptyChecklistTitle', 'Quick steps to go live')}
</Text>
<PillBadge tone="muted">
{t('mobileDashboard.emptyChecklistProgress', '{{done}}/{{total}} steps', { done: 0, total: steps.length })}
</PillBadge>
</XStack>
<YStack space="$2">
{steps.map((label) => (
<XStack
key={label}
alignItems="center"
space="$2"
padding="$2"
borderRadius={12}
backgroundColor="rgba(255,255,255,0.5)"
borderWidth={1}
borderColor={stepBorder}
>
<XStack
width={34}
height={34}
borderRadius={12}
alignItems="center"
justifyContent="center"
backgroundColor={accentSoft}
borderWidth={1}
borderColor={`${accentStrong}33`}
>
<CheckCircle2 size={18} color={accent} />
</XStack>
<Text fontSize="$sm" color={text} flex={1}>
{label}
</Text>
</XStack>
))}
</YStack>
</MobileCard>
<MobileCard space="$2" borderColor={border}>
<Text fontSize="$sm" fontWeight="800" color={text}>
{t('mobileDashboard.emptyPreviewTitle', "Here's what awaits")}
</Text>
<XStack space="$2" flexWrap="wrap">
{previews.map(({ icon: Icon, title, desc }) => (
<YStack
key={title}
width="48%"
minWidth={160}
space="$1.5"
padding="$3"
borderRadius={14}
borderWidth={1}
borderColor={`${border}aa`}
backgroundColor="rgba(255,255,255,0.6)"
shadowColor="#0f172a"
shadowOpacity={0.04}
shadowRadius={10}
shadowOffset={{ width: 0, height: 6 }}
>
<XStack
width={36}
height={36}
borderRadius={12}
backgroundColor={accentSoft}
alignItems="center"
justifyContent="center"
>
<Icon size={18} color={accent} />
</XStack>
<Text fontSize="$sm" fontWeight="700" color={text}>
{title}
</Text>
<Text fontSize="$xs" color={muted}>
{desc}
</Text>
</YStack>
))}
</XStack>
</MobileCard>
<MobileCard space="$2" backgroundColor={supportBg} borderColor={supportBorder}>
<XStack alignItems="center" space="$2">
<XStack
width={36}
height={36}
borderRadius={12}
backgroundColor={accentSoft}
alignItems="center"
justifyContent="center"
>
<MessageCircle size={18} color={accent} />
</XStack>
<YStack space="$0.5">
<Text fontSize="$sm" fontWeight="800" color={text}>
{t('mobileDashboard.emptySupportTitle', 'Need help?')}
</Text>
<Text fontSize="$xs" color={muted}>
{t('mobileDashboard.emptySupportBody', 'We are here if you need a hand getting started.')}
</Text>
</YStack>
</XStack>
<XStack space="$3">
<Text fontSize="$xs" color={accent} textDecorationLine="underline">
{t('mobileDashboard.emptySupportDocs', 'Docs: Getting started')}
</Text>
<Text fontSize="$xs" color={accent} textDecorationLine="underline">
{t('mobileDashboard.emptySupportEmail', 'Email support')}
</Text>
</XStack>
</MobileCard>
</YStack>
);
}
@@ -313,6 +456,7 @@ function SecondaryGrid({
const muted = String(theme.gray11?.val ?? theme.gray?.val ?? '#cbd5e1');
const border = String(theme.borderColor?.val ?? '#334155');
const surface = String(theme.surface?.val ?? '#0b1220');
const brandingAllowed = Boolean((event?.package as any)?.branding_allowed ?? true);
const tiles = [
{
icon: Users,
@@ -338,6 +482,13 @@ function SecondaryGrid({
color: '#10b981',
action: onSettings,
},
{
icon: Sparkles,
label: t('mobileDashboard.shortcutBranding', 'Branding & moderation'),
color: '#22d3ee',
action: brandingAllowed ? onSettings : undefined,
disabled: !brandingAllowed,
},
];
return (
@@ -347,7 +498,14 @@ function SecondaryGrid({
</Text>
<XStack flexWrap="wrap" space="$2">
{tiles.map((tile) => (
<ActionTile key={tile.label} icon={tile.icon} label={tile.label} color={tile.color} onPress={tile.action} />
<ActionTile
key={tile.label}
icon={tile.icon}
label={tile.label}
color={tile.color}
onPress={tile.action}
disabled={tile.disabled}
/>
))}
</XStack>
{event ? (

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, Pencil } from 'lucide-react';
import { CalendarDays, MapPin, Settings, Users, Camera, Sparkles, QrCode, Image, Shield, Layout, RefreshCcw, 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';
@@ -90,7 +90,7 @@ export default function MobileEventDetailPage() {
title={resolveEventDisplayName(event ?? activeEvent ?? undefined)}
subtitle={
event?.event_date || activeEvent?.event_date
? formatDate(event?.event_date ?? activeEvent?.event_date)
? formatDate(event?.event_date ?? activeEvent?.event_date, t)
: undefined
}
onBack={() => navigate(-1)}
@@ -115,16 +115,16 @@ export default function MobileEventDetailPage() {
<MobileCard space="$3">
<Text fontSize="$lg" fontWeight="800" color="#111827">
{event ? renderName(event.name) : t('events.placeholders.untitled', 'Unbenanntes Event')}
{event ? renderName(event.name, t) : t('events.placeholders.untitled', 'Unbenanntes Event')}
</Text>
<XStack alignItems="center" space="$2">
<CalendarDays size={16} color="#6b7280" />
<Text fontSize="$sm" color="#4b5563">
{formatDate(event?.event_date)}
{formatDate(event?.event_date, t)}
</Text>
<MapPin size={16} color="#6b7280" />
<Text fontSize="$sm" color="#4b5563">
{resolveLocation(event)}
{resolveLocation(event, t)}
</Text>
</XStack>
<PillBadge tone={event?.status === 'published' ? 'success' : 'warning'}>
@@ -191,12 +191,12 @@ export default function MobileEventDetailPage() {
<XStack alignItems="center" justifyContent="space-between" paddingVertical="$2">
<YStack space="$1">
<Text fontSize={13} fontWeight="700" color="#111827">
{renderName(ev.name)}
{renderName(ev.name, t)}
</Text>
<XStack alignItems="center" space="$1.5">
<CalendarDays size={14} color="#6b7280" />
<Text fontSize={12} color="#4b5563">
{formatDate(ev.event_date)}
{formatDate(ev.event_date, t)}
</Text>
</XStack>
</YStack>
@@ -246,10 +246,10 @@ export default function MobileEventDetailPage() {
onPress={() => navigate(adminPath(`/mobile/events/${slug ?? ''}/branding`))}
/>
<ActionTile
icon={Shield}
label={t('events.quick.moderation', 'Photo Moderation')}
icon={Camera}
label={t('events.quick.photobooth', 'Photobooth')}
color="#38bdf8"
onPress={() => navigate(adminPath(`/mobile/events/${slug ?? ''}/photos`))}
onPress={() => navigate(adminPath(`/mobile/events/${slug ?? ''}/photobooth`))}
/>
{isPastEvent(event?.event_date) ? (
<ActionTile
@@ -265,23 +265,24 @@ export default function MobileEventDetailPage() {
);
}
function renderName(name: TenantEvent['name']): string {
if (typeof name === 'string') return name;
function renderName(name: TenantEvent['name'], t: (key: string, fallback: string) => string): string {
const fallback = t('events.placeholders.untitled', 'Untitled event');
if (typeof name === 'string' && name.trim()) return name;
if (name && typeof name === 'object') {
return name.de ?? name.en ?? Object.values(name)[0] ?? 'Unbenanntes Event';
return name.de ?? name.en ?? Object.values(name)[0] ?? fallback;
}
return 'Unbenanntes Event';
return fallback;
}
function formatDate(iso?: string | null): string {
if (!iso) return 'Date tbd';
function formatDate(iso: string | null | undefined, t: (key: string, fallback: string) => string): string {
if (!iso) return t('events.detail.dateTbd', 'Date tbd');
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return 'Date tbd';
if (Number.isNaN(date.getTime())) return t('events.detail.dateTbd', 'Date tbd');
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
}
function resolveLocation(event: TenantEvent | null): string {
if (!event) return 'Location';
function resolveLocation(event: TenantEvent | null, t: (key: string, fallback: string) => string): string {
if (!event) return t('events.detail.locationPlaceholder', 'Location');
const settings = (event.settings ?? {}) as Record<string, unknown>;
const candidate =
(settings.location as string | undefined) ??
@@ -290,5 +291,5 @@ function resolveLocation(event: TenantEvent | null): string {
if (candidate && candidate.trim()) {
return candidate;
}
return 'Location';
return t('events.detail.locationPlaceholder', 'Location');
}

View File

@@ -18,7 +18,6 @@ type FormState = {
eventTypeId: number | null;
description: string;
location: string;
enableBranding: boolean;
published: boolean;
};
@@ -27,7 +26,7 @@ export default function MobileEventFormPage() {
const slug = slugParam ?? null;
const isEdit = Boolean(slug);
const navigate = useNavigate();
const { t } = useTranslation('management');
const { t } = useTranslation(['management', 'common']);
const [form, setForm] = React.useState<FormState>({
name: '',
@@ -35,7 +34,6 @@ export default function MobileEventFormPage() {
eventTypeId: null,
description: '',
location: '',
enableBranding: false,
published: false,
});
const [eventTypes, setEventTypes] = React.useState<TenantEventType[]>([]);
@@ -56,7 +54,6 @@ export default function MobileEventFormPage() {
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);
@@ -98,24 +95,24 @@ export default function MobileEventFormPage() {
event_date: form.date || undefined,
event_type_id: form.eventTypeId ?? undefined,
status: form.published ? 'published' : 'draft',
settings: { branding_allowed: form.enableBranding, location: form.location },
settings: { location: form.location },
});
navigate(adminPath(`/mobile/events/${slug}`));
} else {
const payload = {
name: form.name || 'Event',
name: form.name || t('eventForm.fields.name.fallback', 'Event'),
slug: `${Date.now()}`,
event_type_id: form.eventTypeId ?? undefined,
event_date: form.date || undefined,
status: form.published ? 'published' : 'draft' as const,
settings: { branding_allowed: form.enableBranding, location: form.location },
status: (form.published ? 'published' : 'draft') as const,
settings: { location: form.location },
};
const { event } = await createEvent(payload as any);
navigate(adminPath(`/mobile/events/${event.slug}`));
}
} catch (err) {
if (!isAuthError(err)) {
setError(getApiErrorMessage(err, t('events.errors.saveFailed', 'Event konnte nicht gespeichert werden.')));
setError(getApiErrorMessage(err, t('eventForm.errors.saveFailed', 'Event could not be saved.')));
}
} finally {
setSaving(false);
@@ -125,7 +122,7 @@ export default function MobileEventFormPage() {
return (
<MobileShell
activeTab="home"
title={isEdit ? t('events.form.editTitle', 'Edit Event') : t('events.form.createTitle', 'Create New Event')}
title={isEdit ? t('eventForm.titles.edit', 'Edit event') : t('eventForm.titles.create', 'Create event')}
onBack={() => navigate(-1)}
>
{error ? (
@@ -137,17 +134,17 @@ export default function MobileEventFormPage() {
) : null}
<MobileCard space="$3">
<Field label={t('events.form.name', 'Event Name')}>
<Field label={t('eventForm.fields.name.label', 'Event name')}>
<input
type="text"
value={form.name}
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
placeholder="e.g., Smith Wedding"
placeholder={t('eventForm.fields.name.placeholder', 'e.g. Summer Party 2025')}
style={inputStyle}
/>
</Field>
<Field label={t('events.form.date', 'Date & Time')}>
<Field label={t('eventForm.fields.date.label', 'Date & time')}>
<XStack alignItems="center" space="$2">
<input
type="datetime-local"
@@ -180,46 +177,28 @@ export default function MobileEventFormPage() {
)}
</Field>
<Field label={t('events.form.description', 'Optional Details')}>
<Field label={t('eventForm.fields.description.label', 'Optional details')}>
<textarea
value={form.description}
onChange={(e) => setForm((prev) => ({ ...prev, description: e.target.value }))}
placeholder={t('events.form.descriptionPlaceholder', 'Description')}
placeholder={t('eventForm.fields.description.placeholder', 'Description')}
style={{ ...inputStyle, minHeight: 96 }}
/>
</Field>
<Field label={t('events.form.location', 'Location')}>
<Field label={t('eventForm.fields.location.label', 'Location')}>
<XStack alignItems="center" space="$2">
<input
type="text"
value={form.location}
onChange={(e) => setForm((prev) => ({ ...prev, location: e.target.value }))}
placeholder={t('events.form.locationPlaceholder', 'Location')}
placeholder={t('eventForm.fields.location.placeholder', 'Location')}
style={{ ...inputStyle, flex: 1 }}
/>
<MapPin size={16} color="#9ca3af" />
</XStack>
</Field>
<Field label={t('events.form.enableBranding', 'Enable Branding & Moderation')}>
<XStack alignItems="center" space="$2">
<Switch
checked={form.enableBranding}
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>
</XStack>
</Field>
<Field label={t('eventForm.fields.publish.label', 'Publish immediately')}>
<XStack alignItems="center" space="$2">
<Switch
@@ -233,7 +212,7 @@ export default function MobileEventFormPage() {
<Switch.Thumb />
</Switch>
<Text fontSize="$sm" color="#111827">
{form.published ? t('common.enabled', 'Enabled') : t('common.disabled', 'Disabled')}
{form.published ? t('common:states.enabled', 'Enabled') : t('common:states.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>
@@ -254,10 +233,19 @@ export default function MobileEventFormPage() {
fontWeight: 700,
}}
>
{t('events.form.saveDraft', 'Save as Draft')}
{t('eventForm.actions.saveDraft', 'Save as draft')}
</button>
) : null}
<CTAButton label={saving ? t('events.form.saving', 'Saving...') : isEdit ? t('events.form.update', 'Update Event') : t('events.form.create', 'Create Event')} onPress={() => handleSubmit()} />
<CTAButton
label={
saving
? t('eventForm.actions.saving', 'Saving…')
: isEdit
? t('eventForm.actions.update', 'Update event')
: t('eventForm.actions.create', 'Create event')
}
onPress={() => handleSubmit()}
/>
</YStack>
</MobileShell>
);

View File

@@ -4,6 +4,7 @@ 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 { Switch } from '@tamagui/switch';
import { Pressable } from '@tamagui/react-native-web-lite';
import { useTheme } from '@tamagui/core';
import { MobileShell } from './components/MobileShell';
@@ -35,6 +36,7 @@ export default function MobileEventPhotoboothPage() {
const [event, setEvent] = React.useState<TenantEvent | null>(null);
const [status, setStatus] = React.useState<PhotoboothStatus | null>(null);
const [selectedMode, setSelectedMode] = React.useState<'ftp' | 'sparkbooth'>('ftp');
const [loading, setLoading] = React.useState(true);
const [updating, setUpdating] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
@@ -49,6 +51,7 @@ export default function MobileEventPhotoboothPage() {
const [eventData, statusData] = await Promise.all([getEvent(slug), getEventPhotoboothStatus(slug)]);
setEvent(eventData);
setStatus(statusData);
setSelectedMode(statusData.mode ?? 'ftp');
} catch (err) {
if (!isAuthError(err)) {
setError(getApiErrorMessage(err, t('management.photobooth.errors.loadFailed', 'Photobooth-Link konnte nicht geladen werden.')));
@@ -62,12 +65,20 @@ export default function MobileEventPhotoboothPage() {
void load();
}, [load]);
React.useEffect(() => {
if (status?.mode) {
setSelectedMode(status.mode);
}
}, [status?.mode]);
const handleEnable = async (mode?: 'ftp' | 'sparkbooth') => {
if (!slug) return;
const nextMode = mode ?? selectedMode ?? status?.mode ?? 'ftp';
setUpdating(true);
try {
const result = await enableEventPhotobooth(slug, { mode: mode ?? status?.mode ?? 'ftp' });
const result = await enableEventPhotobooth(slug, { mode: nextMode });
setStatus(result);
setSelectedMode(result.mode ?? nextMode);
toast.success(t('management.photobooth.actions.enable', 'Zugang aktiviert'));
} catch (err) {
if (!isAuthError(err)) {
@@ -80,10 +91,12 @@ export default function MobileEventPhotoboothPage() {
const handleDisable = async () => {
if (!slug) return;
const mode = status?.mode ?? selectedMode ?? 'ftp';
setUpdating(true);
try {
const result = await disableEventPhotobooth(slug, { mode: status?.mode ?? 'ftp' });
const result = await disableEventPhotobooth(slug, { mode });
setStatus(result);
setSelectedMode(result.mode ?? mode);
toast.success(t('management.photobooth.actions.disable', 'Zugang deaktiviert'));
} catch (err) {
if (!isAuthError(err)) {
@@ -96,10 +109,12 @@ export default function MobileEventPhotoboothPage() {
const handleRotate = async () => {
if (!slug) return;
const mode = selectedMode ?? status?.mode ?? 'ftp';
setUpdating(true);
try {
const result = await rotateEventPhotobooth(slug, { mode: status?.mode ?? 'ftp' });
const result = await rotateEventPhotobooth(slug, { mode });
setStatus(result);
setSelectedMode(result.mode ?? mode);
toast.success(t('management.photobooth.presets.actions.rotate', 'Zugang zurückgesetzt'));
} catch (err) {
if (!isAuthError(err)) {
@@ -110,8 +125,24 @@ export default function MobileEventPhotoboothPage() {
}
};
const activeMode = selectedMode ?? status?.mode ?? 'ftp';
const isSpark = activeMode === 'sparkbooth';
const spark = status?.sparkbooth ?? null;
const ftp = status?.ftp ?? null;
const metrics = isSpark ? spark?.metrics ?? null : status?.metrics ?? null;
const expiresAt = isSpark ? spark?.expires_at ?? status?.expires_at : status?.expires_at ?? spark?.expires_at;
const lastUploadAt = metrics?.last_upload_at;
const uploads24h = metrics?.uploads_24h ?? metrics?.uploads_today;
const uploadsTotal = metrics?.uploads_total;
const connectionPath = status?.path ?? '—';
const ftpUrl = status?.ftp_url ?? '—';
const uploadUrl = isSpark ? spark?.upload_url ?? status?.upload_url : null;
const responseFormat = spark?.response_format ?? 'json';
const username = isSpark ? spark?.username ?? status?.username : status?.username ?? spark?.username ?? null;
const password = isSpark ? spark?.password ?? status?.password : status?.password ?? spark?.password ?? null;
const modeLabel =
status?.mode === 'sparkbooth'
activeMode === 'sparkbooth'
? t('photobooth.credentials.sparkboothTitle', 'Sparkbooth / HTTP')
: t('photobooth.credentials.heading', 'FTP (Classic)');
@@ -120,6 +151,15 @@ export default function MobileEventPhotoboothPage() {
const subtitle =
event?.event_date ? formatEventDate(event.event_date, locale) : t('header.selectEvent', 'Select an event to continue');
const handleToggle = (checked: boolean) => {
if (!slug || updating) return;
if (checked) {
void handleEnable(status?.mode ?? 'ftp');
} else {
void handleDisable();
}
};
return (
<MobileShell
activeTab="home"
@@ -148,9 +188,9 @@ export default function MobileEventPhotoboothPage() {
</YStack>
) : (
<YStack space="$2">
<MobileCard space="$2">
<XStack justifyContent="space-between" alignItems="center">
<YStack space="$1">
<MobileCard space="$3">
<XStack justifyContent="space-between" alignItems="center" space="$3" flexWrap="wrap">
<YStack space="$1" flex={1} minWidth={0}>
<Text fontSize="$md" fontWeight="800" color={text}>
{t('photobooth.title', 'Photobooth')}
</Text>
@@ -161,25 +201,71 @@ export default function MobileEventPhotoboothPage() {
{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>
<YStack alignItems="flex-end" space="$2">
<PillBadge tone={isActive ? 'success' : 'warning'}>
{isActive ? t('photobooth.status.badgeActive', 'ACTIVE') : t('photobooth.status.badgeInactive', 'INACTIVE')}
</PillBadge>
<XStack alignItems="center" space="$2">
<Text fontSize="$xs" color={muted}>
{isActive ? t('common.enabled', 'Enabled') : t('common.disabled', 'Disabled')}
</Text>
<Switch
size="$4"
checked={isActive}
disabled={updating}
onCheckedChange={handleToggle}
aria-label={t('photobooth.actions.toggle', 'Toggle photobooth access')}
>
<Switch.Thumb />
</Switch>
</XStack>
</YStack>
</XStack>
<YStack space="$1" marginTop="$2">
<XStack justifyContent="space-between" alignItems="center">
<Text fontSize="$xs" color={muted}>
{t('photobooth.stats.lastUpload', 'Last upload')}
</Text>
<Text fontSize="$xs" fontWeight="700" color={text}>
{lastUploadAt ? formatEventDate(lastUploadAt, locale) : t('photobooth.status.never', 'Never')}
</Text>
</XStack>
<XStack justifyContent="space-between" alignItems="center">
<Text fontSize="$xs" color={muted}>
{t('photobooth.status.expires', 'Access expires')}
</Text>
<Text fontSize="$xs" fontWeight="700" color={text}>
{expiresAt ? formatEventDate(expiresAt, locale) : '—'}
</Text>
</XStack>
</YStack>
</MobileCard>
<MobileCard space="$2">
<Text fontSize="$sm" fontWeight="700" color={text}>
{t('photobooth.selector.title', 'Choose adapter')}
</Text>
<Text fontSize="$xs" color={muted}>
{t(
'photobooth.selector.description',
'FTP (Classic) works with most booths. Sparkbooth uses HTTP POST without FTP.'
)}
</Text>
<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')}
label={t('photobooth.mode.ftp', 'FTP (Classic)')}
tone={activeMode === 'ftp' ? 'primary' : 'ghost'}
onPress={() => setSelectedMode('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')}
label={t('photobooth.mode.sparkbooth', 'Sparkbooth (HTTP POST)')}
tone={activeMode === 'sparkbooth' ? 'primary' : 'ghost'}
onPress={() => setSelectedMode('sparkbooth')}
disabled={updating}
style={{ width: '100%', paddingHorizontal: 10, paddingVertical: 10 }}
/>
@@ -188,28 +274,51 @@ export default function MobileEventPhotoboothPage() {
</MobileCard>
<MobileCard space="$2">
<Text fontSize="$sm" fontWeight="700" color={text}>
{t('photobooth.credentials.heading', 'FTP credentials')}
</Text>
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$sm" fontWeight="700" color={text}>
{isSpark ? t('photobooth.credentials.sparkboothTitle', 'Sparkbooth upload (HTTP)') : t('photobooth.credentials.heading', 'FTP credentials')}
</Text>
{!isSpark && ftp?.require_ftps ? <PillBadge tone="warning">{t('photobooth.credentials.ftps', 'FTPS required')}</PillBadge> : null}
</XStack>
<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}
{isSpark ? (
<>
<CredentialRow label={t('photobooth.credentials.postUrl', 'Upload URL')} value={uploadUrl ?? '—'} border={border} />
<CredentialRow label={t('photobooth.credentials.username', 'Username')} value={username ?? '—'} border={border} />
<CredentialRow label={t('photobooth.credentials.password', 'Password')} value={password ?? '—'} border={border} masked />
<CredentialRow label={t('photobooth.sparkbooth.format', 'Response format')} value={responseFormat.toUpperCase()} border={border} />
<Text fontSize="$xs" color={muted}>
{t('photobooth.sparkbooth.hint', 'POST with media file or base64 "media" field; username/password required.')}
</Text>
</>
) : (
<>
<CredentialRow label={t('photobooth.credentials.host', 'Host')} value={ftp?.host ?? '—'} border={border} />
<CredentialRow label={t('photobooth.credentials.port', 'Port')} value={String(ftp?.port ?? '—')} border={border} />
<CredentialRow label={t('photobooth.credentials.path', 'Target folder')} value={connectionPath} border={border} />
<CredentialRow label={t('photobooth.credentials.postUrl', 'FTP URL')} value={ftpUrl} border={border} />
<CredentialRow label={t('photobooth.credentials.username', 'Username')} value={username ?? '—'} border={border} />
<CredentialRow label={t('photobooth.credentials.password', 'Password')} value={password ?? '—'} border={border} masked />
<Text fontSize="$xs" color={muted}>
{t('photobooth.credentials.ftpsHint', 'Use FTPS if required; uploads go into the target folder for this event.')}
</Text>
</>
)}
</YStack>
<XStack space="$2" marginTop="$2" flexWrap="nowrap">
<XStack space="$2" marginTop="$2" flexWrap="wrap">
<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} />}
disabled={updating}
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())}
label={isActive ? t('photobooth.actions.disable', 'Disable uploads') : t('photobooth.actions.enable', 'Enable uploads')}
onPress={() => (isActive ? handleDisable() : handleEnable(selectedMode))}
tone={isActive ? 'ghost' : 'primary'}
iconLeft={isActive ? <Power size={14} color={text} /> : <PlugZap size={14} color={surface} />}
disabled={updating}
@@ -230,18 +339,35 @@ export default function MobileEventPhotoboothPage() {
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={<RefreshCcw size={16} color={text} />}
label={t('photobooth.rateLimit.label', 'Rate limit (uploads/min)')}
value={status?.rate_limit_per_minute != null ? String(status.rate_limit_per_minute) : '—'}
/>
<StatusRow
icon={<Clock3 size={16} color={text} />}
label={t('photobooth.status.expires', 'Access expires')}
value={expiresAt ? formatEventDate(expiresAt, locale) ?? '—' : '—'}
/>
{lastUploadAt ? (
<StatusRow
icon={<Clock3 size={16} color={text} />}
label={t('photobooth.stats.lastUpload', 'Letzter Upload')}
value={formatEventDate(status.metrics.last_upload_at, locale) ?? '—'}
label={t('photobooth.stats.lastUpload', 'Last upload')}
value={formatEventDate(lastUploadAt, locale) ?? '—'}
/>
) : null}
{uploads24h != null ? (
<StatusRow
icon={<RefreshCcw size={16} color={text} />}
label={t('photobooth.stats.uploads24h', 'Uploads last 24h')}
value={String(uploads24h)}
/>
) : null}
{uploadsTotal != null ? (
<StatusRow
icon={<RefreshCcw size={16} color={text} />}
label={t('photobooth.stats.uploadsTotal', 'Uploads total')}
value={String(uploadsTotal)}
/>
) : null}
</YStack>

View File

@@ -5,6 +5,7 @@ import { Shield, Bell, LogOut, User } 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 { Switch } from '@tamagui/switch';
import { MobileShell } from './components/MobileShell';
import { MobileCard, CTAButton, PillBadge } from './components/Primitives';
import { useAuth } from '../auth/context';
@@ -18,6 +19,19 @@ import { adminPath } from '../constants';
type PreferenceKey = keyof NotificationPreferences;
const AVAILABLE_PREFS: PreferenceKey[] = [
'photo_thresholds',
'photo_limits',
'guest_thresholds',
'guest_limits',
'gallery_warnings',
'gallery_expired',
'event_thresholds',
'event_limits',
'package_expiring',
'package_expired',
];
export default function MobileSettingsPage() {
const { t } = useTranslation('management');
const navigate = useNavigate();
@@ -33,8 +47,15 @@ export default function MobileSettingsPage() {
setLoading(true);
try {
const result = await getNotificationPreferences();
setPreferences(result.preferences);
setDefaults(result.defaults ?? {});
const defaultsMerged: NotificationPreferences = result.defaults ?? {};
const prefs = { ...defaultsMerged, ...(result.preferences ?? {}) };
AVAILABLE_PREFS.forEach((key) => {
if (prefs[key] === undefined) {
prefs[key] = defaultsMerged[key] ?? false;
}
});
setPreferences(prefs);
setDefaults(defaultsMerged);
setError(null);
} catch (err) {
setError(getApiErrorMessage(err, t('settings.notifications.errorLoad', 'Benachrichtigungen konnten nicht geladen werden.')));
@@ -54,7 +75,11 @@ export default function MobileSettingsPage() {
const handleSave = async () => {
setSaving(true);
try {
await updateNotificationPreferences(preferences);
const payload: NotificationPreferences = {};
AVAILABLE_PREFS.forEach((key) => {
payload[key] = Boolean(preferences[key]);
});
await updateNotificationPreferences(payload);
setError(null);
} catch (err) {
setError(getApiErrorMessage(err, t('settings.notifications.errorSave', 'Speichern fehlgeschlagen')));
@@ -109,21 +134,35 @@ export default function MobileSettingsPage() {
</Text>
) : (
<YStack space="$2">
{(['task_updates','photo_limits','photo_thresholds','guest_limits','guest_thresholds','purchase_limits','billing','alerts'] as PreferenceKey[]).map((key) => {
const prefKey = key as PreferenceKey;
return (
<XStack key={prefKey} alignItems="center" justifyContent="space-between" borderBottomWidth={1} borderColor="#e5e7eb" paddingBottom="$2" paddingTop="$1.5">
<Text fontSize="$sm" color="#0f172a">
{t(`mobileSettings.pref.${prefKey}`, prefKey)}
{AVAILABLE_PREFS.map((key) => (
<XStack
key={key}
alignItems="center"
justifyContent="space-between"
borderBottomWidth={1}
borderColor="#e5e7eb"
paddingBottom="$2"
paddingTop="$1.5"
space="$2"
>
<YStack flex={1} minWidth={0} space="$1">
<Text fontSize="$sm" color="#0f172a" fontWeight="700">
{t(`settings.notifications.keys.${key}.label`, key)}
</Text>
<input
type="checkbox"
checked={Boolean(preferences[prefKey])}
onChange={() => togglePref(prefKey)}
/>
</XStack>
);
})}
<Text fontSize="$xs" color="#6b7280">
{t(`settings.notifications.keys.${key}.description`, '')}
</Text>
</YStack>
<Switch
size="$4"
checked={Boolean(preferences[key])}
onCheckedChange={() => togglePref(key)}
aria-label={t(`settings.notifications.keys.${key}.label`, key)}
>
<Switch.Thumb />
</Switch>
</XStack>
))}
</YStack>
)}
<XStack space="$2">

View File

@@ -15,7 +15,6 @@ import { MobileCard, PillBadge } from './Primitives';
import { useNotificationsBadge } from '../hooks/useNotificationsBadge';
import { formatEventDate, resolveEventDisplayName } from '../../lib/events';
import { TenantEvent, getEvents } from '../../api';
const DevTenantSwitcher = React.lazy(() => import('../../DevTenantSwitcher'));
type MobileShellProps = {
title?: string;
@@ -42,7 +41,6 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
const [fallbackEvents, setFallbackEvents] = React.useState<TenantEvent[]>([]);
const [loadingEvents, setLoadingEvents] = React.useState(false);
const [attemptedFetch, setAttemptedFetch] = React.useState(false);
const showDevTenantSwitcher = import.meta.env.DEV && import.meta.env.VITE_ENABLE_TENANT_SWITCHER === 'true';
const locale = i18n.language?.startsWith('en') ? 'en-GB' : 'de-DE';
const effectiveEvents = events.length ? events : fallbackEvents;
@@ -90,7 +88,7 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
const showQr = Boolean(effectiveActive?.slug);
return (
<YStack backgroundColor={backgroundColor} minHeight="100vh">
<YStack backgroundColor={backgroundColor} minHeight="100vh" alignItems="center">
<YStack
backgroundColor={surfaceColor}
borderBottomWidth={1}
@@ -102,6 +100,8 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
shadowOpacity={0.06}
shadowRadius={10}
shadowOffset={{ width: 0, height: 4 }}
width="100%"
maxWidth={800}
>
<XStack alignItems="center" justifyContent="space-between" minHeight={48} space="$3">
{onBack ? (
@@ -184,17 +184,12 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
</Pressable>
) : null}
{headerActions ?? null}
{showDevTenantSwitcher ? (
<Suspense fallback={null}>
<DevTenantSwitcher variant="inline" />
</Suspense>
) : null}
</XStack>
</XStack>
</XStack>
</YStack>
<YStack flex={1} padding="$4" paddingBottom="$10" space="$3">
<YStack flex={1} padding="$4" paddingBottom="$10" space="$3" width="100%" maxWidth={800}>
{children}
</YStack>

View File

@@ -139,16 +139,22 @@ export function ActionTile({
label,
color,
onPress,
disabled = false,
}: {
icon: React.ComponentType<{ size?: number; color?: string }>;
label: string;
color: string;
onPress: () => void;
onPress?: () => void;
disabled?: boolean;
}) {
const theme = useTheme();
const text = String(theme.color12?.val ?? theme.color?.val ?? '#f8fafc');
return (
<Pressable onPress={onPress} style={{ width: '48%', marginBottom: 12 }}>
<Pressable
onPress={disabled ? undefined : onPress}
style={{ width: '48%', marginBottom: 12, opacity: disabled ? 0.5 : 1 }}
disabled={disabled}
>
<YStack
borderRadius={16}
padding="$3"