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

@@ -169,7 +169,6 @@ class SeedDemoSwitcherTenants extends Command
attributes: [ attributes: [
'subscription_tier' => 'standard', 'subscription_tier' => 'standard',
'subscription_status' => 'active', 'subscription_status' => 'active',
'event_credits_balance' => 1,
], ],
); );
@@ -198,7 +197,6 @@ class SeedDemoSwitcherTenants extends Command
attributes: [ attributes: [
'subscription_tier' => 'starter', 'subscription_tier' => 'starter',
'subscription_status' => 'active', 'subscription_status' => 'active',
'event_credits_balance' => 0,
], ],
); );
@@ -229,7 +227,6 @@ class SeedDemoSwitcherTenants extends Command
attributes: [ attributes: [
'subscription_tier' => 'reseller', 'subscription_tier' => 'reseller',
'subscription_status' => 'active', 'subscription_status' => 'active',
'event_credits_balance' => 2,
], ],
); );
@@ -294,7 +291,6 @@ class SeedDemoSwitcherTenants extends Command
attributes: [ attributes: [
'subscription_tier' => 'reseller', 'subscription_tier' => 'reseller',
'subscription_status' => 'active', 'subscription_status' => 'active',
'event_credits_balance' => 0,
], ],
); );

View File

@@ -139,11 +139,9 @@ class EventController extends Controller
unset($eventData['features']); unset($eventData['features']);
} }
if ($settings === [] || $settings === null) { $settings['branding_allowed'] = $package->branding_allowed !== false;
unset($eventData['settings']);
} else { $eventData['settings'] = $settings;
$eventData['settings'] = $settings;
}
foreach (['password', 'password_confirmation', 'password_protected', 'logo_image', 'cover_image'] as $unused) { foreach (['password', 'password_confirmation', 'password_protected', 'logo_image', 'cover_image'] as $unused) {
unset($eventData[$unused]); unset($eventData[$unused]);
@@ -233,6 +231,7 @@ class EventController extends Controller
public function update(EventStoreRequest $request, Event $event): JsonResponse public function update(EventStoreRequest $request, Event $event): JsonResponse
{ {
$tenantId = $request->attributes->get('tenant_id'); $tenantId = $request->attributes->get('tenant_id');
$event->loadMissing('eventPackage.package');
if ($event->tenant_id !== $tenantId) { if ($event->tenant_id !== $tenantId) {
return ApiError::response( return ApiError::response(
@@ -259,10 +258,16 @@ class EventController extends Controller
unset($validated[$unused]); unset($validated[$unused]);
} }
$brandingAllowed = optional($event->eventPackage?->package)->branding_allowed !== false;
if (isset($validated['settings']) && is_array($validated['settings'])) { if (isset($validated['settings']) && is_array($validated['settings'])) {
$validated['settings'] = array_merge($event->settings ?? [], $validated['settings']); $validated['settings'] = array_merge($event->settings ?? [], $validated['settings']);
} else {
$validated['settings'] = $event->settings ?? [];
} }
$validated['settings']['branding_allowed'] = $brandingAllowed;
$event->update($validated); $event->update($validated);
$event->load(['eventType', 'tenant']); $event->load(['eventType', 'tenant']);

View File

@@ -1,7 +1,9 @@
import React from 'react'; import React from 'react';
import { Loader2, PanelLeftClose, PanelRightOpen } from 'lucide-react'; import { Loader2, PanelLeftClose, PanelRightOpen } from 'lucide-react';
import { XStack, YStack } from '@tamagui/stacks';
import { Button } from '@/components/ui/button'; import { SizableText as Text } from '@tamagui/text';
import { Button } from '@tamagui/button';
import { useTheme } from '@tamagui/core';
const DEV_TENANT_KEYS = [ const DEV_TENANT_KEYS = [
{ key: 'cust-standard-empty', label: 'Endkunde Standard (kein Event)' }, { key: 'cust-standard-empty', label: 'Endkunde Standard (kein Event)' },
@@ -26,6 +28,7 @@ type DevTenantSwitcherProps = {
export function DevTenantSwitcher({ bottomOffset = 16, variant = 'floating' }: DevTenantSwitcherProps) { export function DevTenantSwitcher({ bottomOffset = 16, variant = 'floating' }: DevTenantSwitcherProps) {
const helper = window.fotospielDemoAuth; const helper = window.fotospielDemoAuth;
const theme = useTheme();
const [loggingIn, setLoggingIn] = React.useState<string | null>(null); const [loggingIn, setLoggingIn] = React.useState<string | null>(null);
const [collapsed, setCollapsed] = React.useState<boolean>(() => { const [collapsed, setCollapsed] = React.useState<boolean>(() => {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
@@ -59,72 +62,86 @@ export function DevTenantSwitcher({ bottomOffset = 16, variant = 'floating' }: D
if (variant === 'inline') { if (variant === 'inline') {
if (collapsed) { if (collapsed) {
return ( return (
<button <Button
type="button" size="$2"
className="inline-flex items-center gap-2 rounded-full border border-amber-200 bg-white/95 px-3 py-1.5 text-xs font-semibold text-amber-700 shadow-sm shadow-amber-200/60 transition hover:bg-amber-50" theme="yellow"
onClick={() => setCollapsed(false)} onPress={() => setCollapsed(false)}
borderRadius={999}
icon={<PanelRightOpen size={16} />}
> >
<PanelRightOpen className="h-4 w-4" />
Demo tenants Demo tenants
</button> </Button>
); );
} }
return ( return (
<div className="relative"> <YStack
<div className="pointer-events-auto flex max-w-xs flex-col gap-2 rounded-xl border border-amber-200 bg-white/95 p-3 text-xs shadow-xl shadow-amber-200/60"> borderWidth={1}
<div className="flex items-center justify-between gap-2"> borderColor="rgba(234,179,8,0.5)"
<div className="flex items-center gap-2"> backgroundColor="rgba(255,255,255,0.95)"
<strong className="text-amber-800">Demo tenants</strong> padding="$3"
<span className="text-[10px] uppercase tracking-wide text-amber-600">Dev mode</span> space="$2"
</div> borderRadius="$4"
<button shadowColor="#f59e0b"
type="button" shadowOpacity={0.25}
onClick={() => setCollapsed(true)} shadowRadius={14}
className="inline-flex h-7 w-7 items-center justify-center rounded-full border border-amber-200 bg-white text-amber-600 transition hover:bg-amber-50" shadowOffset={{ width: 0, height: 8 }}
aria-label="Switcher minimieren" maxWidth={320}
>
<XStack alignItems="center" justifyContent="space-between">
<XStack alignItems="center" space="$2">
<Text fontSize={13} fontWeight="800" color="#92400e">
Demo tenants
</Text>
<Text fontSize={10} color="#a16207" textTransform="uppercase" letterSpacing={1}>
Dev mode
</Text>
</XStack>
<Button
size="$2"
theme="yellow"
circular
icon={<PanelLeftClose size={14} />}
onPress={() => setCollapsed(true)}
aria-label="Switcher minimieren"
/>
</XStack>
<YStack space="$1">
{DEV_TENANT_KEYS.map(({ key, label }) => (
<Button
key={key}
size="$3"
variant="outlined"
theme="yellow"
disabled={Boolean(loggingIn)}
onPress={() => void handleLogin(key)}
icon={loggingIn === key ? <Loader2 size={14} className="animate-spin" /> : undefined}
> >
<PanelLeftClose className="h-4 w-4" /> {loggingIn === key ? 'Verbinde...' : label}
</button> </Button>
</div> ))}
<div className="space-y-1"> </YStack>
{DEV_TENANT_KEYS.map(({ key, label }) => ( </YStack>
<Button
key={key}
variant="outline"
className="w-full border-amber-200 text-amber-800 hover:bg-amber-50"
disabled={Boolean(loggingIn)}
onClick={() => void handleLogin(key)}
>
{loggingIn === key ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Verbinde...
</>
) : (
label
)}
</Button>
))}
</div>
</div>
</div>
); );
} }
if (collapsed) { if (collapsed) {
return ( return (
<button <Button
type="button" size="$3"
className="pointer-events-auto fixed right-4 z-[1000] flex items-center gap-2 rounded-full border border-amber-200 bg-white/95 px-4 py-2 text-sm font-medium text-amber-700 shadow-lg shadow-amber-200/60 transition hover:bg-amber-50" theme="yellow"
style={{ bottom: bottomOffset }} icon={<PanelRightOpen size={16} />}
onClick={() => setCollapsed(false)} borderRadius={999}
> position="fixed"
<PanelRightOpen className="h-4 w-4" /> right="$4"
Demo tenants zIndex={1000}
</button> onPress={() => setCollapsed(false)}
); style={{ bottom: bottomOffset + 70 }}
} >
Demo tenants
</Button>
);
}
async function handleLogin(key: string) { async function handleLogin(key: string) {
if (!helper) return; if (!helper) return;
@@ -138,51 +155,64 @@ export function DevTenantSwitcher({ bottomOffset = 16, variant = 'floating' }: D
} }
return ( return (
<div <YStack
className="pointer-events-auto fixed right-4 z-[1000] flex max-w-xs flex-col gap-2 rounded-xl border border-amber-200 bg-white/95 p-3 text-sm shadow-xl shadow-amber-200/60" position="fixed"
style={{ bottom: bottomOffset }} right="$4"
zIndex={1000}
maxWidth={320}
space="$2"
borderWidth={1}
borderColor="rgba(234,179,8,0.5)"
backgroundColor="rgba(255,255,255,0.95)"
padding="$3"
borderRadius="$4"
shadowColor="#f59e0b"
shadowOpacity={0.25}
shadowRadius={14}
shadowOffset={{ width: 0, height: 8 }}
pointerEvents="auto"
style={{ bottom: bottomOffset + 70 }}
> >
<div className="flex items-center justify-between gap-2"> <XStack alignItems="center" justifyContent="space-between">
<div className="flex items-center gap-2"> <XStack alignItems="center" space="$2">
<strong className="text-amber-800">Demo tenants</strong> <Text fontSize={13} fontWeight="800" color="#92400e">
<span className="text-xs uppercase tracking-wide text-amber-600">Dev mode</span> Demo tenants
</div> </Text>
<button <Text fontSize={10} color="#a16207" textTransform="uppercase" letterSpacing={1}>
type="button" Dev mode
onClick={() => setCollapsed(true)} </Text>
className="inline-flex h-7 w-7 items-center justify-center rounded-full border border-amber-200 bg-white text-amber-600 transition hover:bg-amber-50" </XStack>
<Button
size="$2"
theme="yellow"
circular
icon={<PanelLeftClose size={14} />}
onPress={() => setCollapsed(true)}
aria-label="Switcher minimieren" aria-label="Switcher minimieren"
> />
<PanelLeftClose className="h-4 w-4" /> </XStack>
</button> <Text fontSize={11} color="#a16207">
</div>
<p className="text-xs text-amber-700">
Select a seeded tenant to mint Sanctum PATs and jump straight into their admin space. Available only in development builds. Select a seeded tenant to mint Sanctum PATs and jump straight into their admin space. Available only in development builds.
</p> </Text>
<div className="space-y-1"> <YStack space="$1">
{DEV_TENANT_KEYS.map(({ key, label }) => ( {DEV_TENANT_KEYS.map(({ key, label }) => (
<Button <Button
key={key} key={key}
variant="outline" size="$3"
className="w-full border-amber-200 text-amber-800 hover:bg-amber-50" variant="outlined"
theme="yellow"
disabled={Boolean(loggingIn)} disabled={Boolean(loggingIn)}
onClick={() => void handleLogin(key)} onPress={() => void handleLogin(key)}
icon={loggingIn === key ? <Loader2 size={14} className="animate-spin" /> : undefined}
> >
{loggingIn === key ? ( {loggingIn === key ? 'Verbinde...' : label}
<>
<Loader2 className="h-4 w-4 animate-spin" />
Verbinde...
</>
) : (
label
)}
</Button> </Button>
))} ))}
</div> </YStack>
<p className="text-[10px] text-amber-600"> <Text fontSize={10} color="#a16207">
Console: <code>fotospielDemoAuth.loginAs('lumen')</code> Console: <Text as="span" fontFamily="$mono">fotospielDemoAuth.loginAs('lumen')</Text>
</p> </Text>
</div> </YStack>
); );
} }

View File

@@ -149,6 +149,7 @@ export type PhotoboothStatusMetrics = {
uploads_last_hour?: number | null; uploads_last_hour?: number | null;
uploads_today?: number | null; uploads_today?: number | null;
uploads_total?: number | null; uploads_total?: number | null;
uploads_24h?: number | null;
last_upload_at?: string | null; last_upload_at?: string | null;
}; };
@@ -1235,6 +1236,7 @@ function normalizePhotoboothStatus(payload: JsonValue): PhotoboothStatus {
uploads_last_hour: readNumber('uploads_last_hour') ?? readNumber('last_hour') ?? readNumber('hour'), uploads_last_hour: readNumber('uploads_last_hour') ?? readNumber('last_hour') ?? readNumber('hour'),
uploads_today: readNumber('uploads_today') ?? readNumber('today'), uploads_today: readNumber('uploads_today') ?? readNumber('today'),
uploads_total: readNumber('uploads_total') ?? readNumber('total'), uploads_total: readNumber('uploads_total') ?? readNumber('total'),
uploads_24h: readNumber('uploads_24h') ?? readNumber('uploads_today') ?? readNumber('today'),
last_upload_at: typeof record.last_upload_at === 'string' ? record.last_upload_at : null, last_upload_at: typeof record.last_upload_at === 'string' ? record.last_upload_at : null,
}; };
} }

View File

@@ -57,6 +57,10 @@
"de": "Deutsch", "de": "Deutsch",
"en": "Englisch" "en": "Englisch"
}, },
"states": {
"enabled": "Aktiviert",
"disabled": "Deaktiviert"
},
"actions": { "actions": {
"open": "Öffnen", "open": "Öffnen",
"viewAll": "Alle anzeigen", "viewAll": "Alle anzeigen",

View File

@@ -191,9 +191,36 @@
} }
}, },
"events": { "events": {
"detail": {
"kpi": {
"tasks": "Aktive Aufgaben",
"guests": "Registrierte Gäste",
"photos": "Hochgeladene Bilder"
},
"pickEvent": "Event auswählen",
"active": "Aktiv",
"managementTitle": "Event-Verwaltung",
"dateTbd": "Datum folgt",
"locationPlaceholder": "Ort"
},
"quick": {
"tasks": "Aufgaben & Checklisten",
"qr": "QR-Code-Layouts",
"images": "Bildverwaltung",
"guests": "Gästeverwaltung",
"branding": "Branding & Design",
"photobooth": "Photobooth",
"recap": "Recap & Archiv"
},
"status": {
"published": "Live",
"draft": "Entwurf",
"archived": "Archiviert"
},
"errors": { "errors": {
"missingSlug": "Kein Event ausgewählt.", "missingSlug": "Kein Event ausgewählt.",
"loadFailed": "Event konnte nicht geladen werden.", "loadFailed": "Event konnte nicht geladen werden.",
"saveFailed": "Event konnte nicht gespeichert werden.",
"notFoundTitle": "Event nicht gefunden", "notFoundTitle": "Event nicht gefunden",
"notFoundBody": "Ohne gültige Kennung können wir keine Daten laden. Kehre zur Eventliste zurück und wähle dort ein Event aus.", "notFoundBody": "Ohne gültige Kennung können wir keine Daten laden. Kehre zur Eventliste zurück und wähle dort ein Event aus.",
"toggleFailed": "Status konnte nicht angepasst werden.", "toggleFailed": "Status konnte nicht angepasst werden.",
@@ -221,6 +248,22 @@
"buyMoreGuests": "Mehr Gäste freischalten", "buyMoreGuests": "Mehr Gäste freischalten",
"extendGallery": "Galerie verlängern" "extendGallery": "Galerie verlängern"
}, },
"form": {
"editTitle": "Event bearbeiten",
"createTitle": "Neues Event erstellen",
"name": "Eventname",
"date": "Datum & Uhrzeit",
"description": "Optionale Details",
"descriptionPlaceholder": "Beschreibung",
"location": "Ort",
"locationPlaceholder": "Ort",
"enableBranding": "Branding & Moderation aktivieren",
"fallbackName": "Event",
"saveDraft": "Als Entwurf speichern",
"saving": "Speichere…",
"update": "Event aktualisieren",
"create": "Event erstellen"
},
"workspace": { "workspace": {
"detailSubtitle": "Behalte Status, Aufgaben und QR-Codes deines Events im Blick.", "detailSubtitle": "Behalte Status, Aufgaben und QR-Codes deines Events im Blick.",
"toolkitSubtitle": "Moderation, Aufgaben und QR-Codes für deinen Eventtag bündeln.", "toolkitSubtitle": "Moderation, Aufgaben und QR-Codes für deinen Eventtag bündeln.",
@@ -1531,7 +1574,9 @@
}, },
"eventForm": { "eventForm": {
"errors": { "errors": {
"notice": "Hinweis" "notice": "Hinweis",
"loadFailed": "Event konnte nicht geladen werden.",
"saveFailed": "Event konnte nicht gespeichert werden."
}, },
"titles": { "titles": {
"create": "Neues Event erstellen", "create": "Neues Event erstellen",
@@ -1548,11 +1593,24 @@
"name": { "name": {
"label": "Eventname", "label": "Eventname",
"placeholder": "z. B. Sommerfest 2025", "placeholder": "z. B. Sommerfest 2025",
"help": "Die Kennung und Event-URL werden automatisch aus dem Namen generiert." "help": "Die Kennung und Event-URL werden automatisch aus dem Namen generiert.",
"fallback": "Event"
}, },
"date": { "date": {
"label": "Datum" "label": "Datum"
}, },
"description": {
"label": "Optionale Details",
"placeholder": "Beschreibung"
},
"location": {
"label": "Ort",
"placeholder": "Ort"
},
"enableBranding": {
"label": "Branding & Moderation aktivieren",
"locked": "Branding ist in höheren Paketen enthalten. Upgrade, um eigenes Branding zu aktivieren."
},
"type": { "type": {
"label": "Event-Typ", "label": "Event-Typ",
"loading": "Event-Typ wird geladen…", "loading": "Event-Typ wird geladen…",
@@ -1566,9 +1624,12 @@
}, },
"actions": { "actions": {
"backToList": "Zurück zur Liste", "backToList": "Zurück zur Liste",
"saving": "Speichert", "saving": "Speichert",
"save": "Speichern", "save": "Speichern",
"cancel": "Abbrechen" "cancel": "Abbrechen",
"saveDraft": "Als Entwurf speichern",
"update": "Event aktualisieren",
"create": "Event erstellen"
} }
}, },
"notifications": { "notifications": {
@@ -1609,6 +1670,32 @@
} }
}, },
"events": { "events": {
"detail": {
"kpi": {
"tasks": "Aktive Aufgaben",
"guests": "Registrierte Gäste",
"photos": "Hochgeladene Bilder"
},
"pickEvent": "Event auswählen",
"active": "Aktiv",
"managementTitle": "Event-Verwaltung",
"dateTbd": "Datum folgt",
"locationPlaceholder": "Ort"
},
"quick": {
"tasks": "Aufgaben & Checklisten",
"qr": "QR-Code-Layouts",
"images": "Bildverwaltung",
"guests": "Gästeverwaltung",
"branding": "Branding & Design",
"moderation": "Foto-Moderation",
"recap": "Recap & Archiv"
},
"status": {
"published": "Live",
"draft": "Entwurf",
"archived": "Archiviert"
},
"errors": { "errors": {
"missingSlug": "Kein Event-Slug angegeben.", "missingSlug": "Kein Event-Slug angegeben.",
"loadFailed": "Tasks konnten nicht geladen werden.", "loadFailed": "Tasks konnten nicht geladen werden.",
@@ -1664,15 +1751,26 @@
"mobileDashboard": { "mobileDashboard": {
"title": "Dashboard", "title": "Dashboard",
"selectEvent": "Wähle ein Event, um fortzufahren", "selectEvent": "Wähle ein Event, um fortzufahren",
"emptyTitle": "Erstelle dein erstes Event", "emptyBadge": "Willkommen!",
"emptyBody": "Starte ein Event, um Tasks, QR-Poster und Uploads zu verwalten.", "emptyTitle": "Willkommen! Lass uns dein erstes Event starten",
"emptyBody": "Drucke einen QR, sammle Uploads und moderiere in Minuten.",
"ctaCreate": "Event erstellen", "ctaCreate": "Event erstellen",
"ctaDemo": "Demo ansehen", "emptyChecklistTitle": "Schnelle Schritte bis live",
"highlightsTitle": "Das kannst du tun", "emptyChecklistProgress": "{{done}}/{{total}} Schritte",
"highlightImages": "Fotos & Uploads prüfen", "emptyStepDetails": "Name & Datum ergänzen",
"highlightTasks": "Tasks & Challenges zuweisen", "emptyStepQr": "QR-Poster teilen",
"highlightQr": "QR-Poster teilen", "emptyStepReview": "Erste Uploads prüfen",
"highlightGuests": "Helfer & Gäste einladen", "emptyPreviewTitle": "Darauf kannst du dich freuen",
"emptyPreviewQr": "QR-Poster teilen",
"emptyPreviewQrDesc": "Druckfertige Codes für Gäste und Team.",
"emptyPreviewGallery": "Galerie & Highlights",
"emptyPreviewGalleryDesc": "Uploads moderieren, die besten Momente featuren.",
"emptyPreviewTasks": "Tasks & Challenges",
"emptyPreviewTasksDesc": "Gäste mit spielerischen Prompts führen.",
"emptySupportTitle": "Brauchst du Hilfe?",
"emptySupportBody": "Wir unterstützen dich gern beim Start.",
"emptySupportDocs": "Docs: Erste Schritte",
"emptySupportEmail": "E-Mail an Support",
"pickEvent": "Event auswählen", "pickEvent": "Event auswählen",
"status": { "status": {
"published": "Live", "published": "Live",
@@ -1689,6 +1787,7 @@
"shortcutPrints": "Drucke & Poster-Downloads", "shortcutPrints": "Drucke & Poster-Downloads",
"shortcutInvites": "Team-/Helfer-Einladungen", "shortcutInvites": "Team-/Helfer-Einladungen",
"shortcutSettings": "Event-Einstellungen", "shortcutSettings": "Event-Einstellungen",
"shortcutBranding": "Branding & Moderation",
"kpiTitle": "Wichtigste Kennzahlen", "kpiTitle": "Wichtigste Kennzahlen",
"kpiTasks": "Offene Tasks", "kpiTasks": "Offene Tasks",
"kpiPhotos": "Fotos", "kpiPhotos": "Fotos",
@@ -1741,15 +1840,52 @@
"tenantBadge": "Tenant #{{id}}", "tenantBadge": "Tenant #{{id}}",
"notificationsTitle": "Benachrichtigungen", "notificationsTitle": "Benachrichtigungen",
"notificationsLoading": "Lade Einstellungen ...", "notificationsLoading": "Lade Einstellungen ...",
"pref": { "pref": {}
"task_updates": "Task-Updates", },
"photo_limits": "Foto-Limits", "settings": {
"photo_thresholds": "Foto-Schwellen", "notifications": {
"guest_limits": "Gäste-Limits", "keys": {
"guest_thresholds": "Gäste-Schwellen", "photo_thresholds": {
"purchase_limits": "Kauf-Limits", "label": "Foto-Schwellen",
"billing": "Abrechnung & Rechnungen", "description": "Benachrichtigt, wenn Foto-Uploads die Paketgrenzen annähern."
"alerts": "Warnungen" },
"photo_limits": {
"label": "Foto-Limits erreicht",
"description": "Hinweis, wenn das Foto-Upload-Kontingent überschritten wurde."
},
"guest_thresholds": {
"label": "Gäste-Schwellen",
"description": "Warnung, wenn die Gästezahl das Limit fast erreicht."
},
"guest_limits": {
"label": "Gäste-Limits erreicht",
"description": "Hinweis, wenn das Gäste-Limit überschritten wurde."
},
"gallery_warnings": {
"label": "Galerie-Warnungen",
"description": "Vorwarnung, bevor Galerien offline gehen."
},
"gallery_expired": {
"label": "Galerie abgelaufen",
"description": "Info, wenn eine Galerie nicht mehr verfügbar ist."
},
"event_thresholds": {
"label": "Event-Schwellen",
"description": "Warnung, wenn Event-Nutzung sich dem Limit nähert."
},
"event_limits": {
"label": "Event-Limits erreicht",
"description": "Hinweis, wenn Event-Kontingente überschritten sind."
},
"package_expiring": {
"label": "Paket läuft ab",
"description": "Erinnerungen, bevor dein Paket abläuft."
},
"package_expired": {
"label": "Paket abgelaufen",
"description": "Hinweis, wenn dein Paket abgelaufen ist."
}
}
} }
}, },
"mobileBilling": { "mobileBilling": {

View File

@@ -57,6 +57,10 @@
"de": "German", "de": "German",
"en": "English" "en": "English"
}, },
"states": {
"enabled": "Enabled",
"disabled": "Disabled"
},
"actions": { "actions": {
"open": "Open", "open": "Open",
"viewAll": "View all", "viewAll": "View all",

View File

@@ -187,6 +187,32 @@
} }
}, },
"events": { "events": {
"detail": {
"kpi": {
"tasks": "Active tasks",
"guests": "Guests registered",
"photos": "Images uploaded"
},
"pickEvent": "Select event",
"active": "Active",
"managementTitle": "Event management",
"dateTbd": "Date tbd",
"locationPlaceholder": "Location"
},
"quick": {
"tasks": "Tasks & checklists",
"qr": "QR code layouts",
"images": "Image management",
"guests": "Guest management",
"branding": "Branding & theme",
"photobooth": "Photobooth",
"recap": "Recap & archive"
},
"status": {
"published": "Live",
"draft": "Draft",
"archived": "Archived"
},
"list": { "list": {
"title": "Your events", "title": "Your events",
"subtitle": "Plan memorable moments. Manage everything around your events here.", "subtitle": "Plan memorable moments. Manage everything around your events here.",
@@ -793,6 +819,7 @@
"errors": { "errors": {
"missingSlug": "No event selected.", "missingSlug": "No event selected.",
"loadFailed": "Event could not be loaded.", "loadFailed": "Event could not be loaded.",
"saveFailed": "Event could not be saved.",
"notFoundTitle": "Event not found", "notFoundTitle": "Event not found",
"notFoundBody": "Without a valid identifier we cant load the data. Return to the list and choose an event.", "notFoundBody": "Without a valid identifier we cant load the data. Return to the list and choose an event.",
"toggleFailed": "Status could not be updated.", "toggleFailed": "Status could not be updated.",
@@ -820,6 +847,22 @@
"buyMoreGuests": "Unlock more guests", "buyMoreGuests": "Unlock more guests",
"extendGallery": "Extend gallery" "extendGallery": "Extend gallery"
}, },
"form": {
"editTitle": "Edit event",
"createTitle": "Create new event",
"name": "Event name",
"date": "Date & time",
"description": "Optional details",
"descriptionPlaceholder": "Description",
"location": "Location",
"locationPlaceholder": "Location",
"enableBranding": "Enable branding & moderation",
"fallbackName": "Event",
"saveDraft": "Save as draft",
"saving": "Saving…",
"update": "Update event",
"create": "Create event"
},
"workspace": { "workspace": {
"detailSubtitle": "Keep status, tasks, and invites of your event in one view.", "detailSubtitle": "Keep status, tasks, and invites of your event in one view.",
"toolkitSubtitle": "Bundle moderation, tasks, and invites for the event day.", "toolkitSubtitle": "Bundle moderation, tasks, and invites for the event day.",
@@ -1550,6 +1593,9 @@
}, },
"eventForm": { "eventForm": {
"errors": { "errors": {
"notice": "Notice",
"loadFailed": "Event could not be loaded.",
"saveFailed": "Event could not be saved.",
"nameRequired": "Please enter an event name.", "nameRequired": "Please enter an event name.",
"typeRequired": "Please select an event type." "typeRequired": "Please select an event type."
}, },
@@ -1568,11 +1614,24 @@
"name": { "name": {
"label": "Event name", "label": "Event name",
"placeholder": "e.g. Summer Party 2025", "placeholder": "e.g. Summer Party 2025",
"help": "The slug and event URL are generated from the name." "help": "The slug and event URL are generated from the name.",
"fallback": "Event"
}, },
"date": { "date": {
"label": "Date" "label": "Date"
}, },
"description": {
"label": "Optional details",
"placeholder": "Description"
},
"location": {
"label": "Location",
"placeholder": "Location"
},
"enableBranding": {
"label": "Enable branding & moderation",
"locked": "Branding is available on higher packages. Upgrade to enable custom branding."
},
"type": { "type": {
"label": "Event type", "label": "Event type",
"loading": "Loading event types…", "loading": "Loading event types…",
@@ -1586,12 +1645,12 @@
}, },
"actions": { "actions": {
"backToList": "Back to list", "backToList": "Back to list",
"saving": "Saving", "saving": "Saving",
"save": "Save", "save": "Save",
"cancel": "Cancel" "cancel": "Cancel",
}, "saveDraft": "Save as draft",
"errors": { "update": "Update event",
"notice": "Notice" "create": "Create event"
} }
}, },
"notifications": { "notifications": {
@@ -1632,6 +1691,32 @@
} }
}, },
"events": { "events": {
"detail": {
"kpi": {
"tasks": "Active tasks",
"guests": "Guests registered",
"photos": "Images uploaded"
},
"pickEvent": "Select event",
"active": "Active",
"managementTitle": "Event management",
"dateTbd": "Date tbd",
"locationPlaceholder": "Location"
},
"quick": {
"tasks": "Tasks & checklists",
"qr": "QR code layouts",
"images": "Image management",
"guests": "Guest management",
"branding": "Branding & theme",
"moderation": "Photo moderation",
"recap": "Recap & archive"
},
"status": {
"published": "Live",
"draft": "Draft",
"archived": "Archived"
},
"errors": { "errors": {
"missingSlug": "No event slug provided.", "missingSlug": "No event slug provided.",
"loadFailed": "Tasks could not be loaded.", "loadFailed": "Tasks could not be loaded.",
@@ -1687,15 +1772,26 @@
"mobileDashboard": { "mobileDashboard": {
"title": "Dashboard", "title": "Dashboard",
"selectEvent": "Select an event to continue", "selectEvent": "Select an event to continue",
"emptyTitle": "Create your first event", "emptyBadge": "Welcome aboard",
"emptyBody": "Start an event to manage tasks, QR posters and uploads.", "emptyTitle": "Welcome! Let's launch your first event",
"emptyBody": "Print a QR, collect uploads, and start moderating in minutes.",
"ctaCreate": "Create event", "ctaCreate": "Create event",
"ctaDemo": "View demo", "emptyChecklistTitle": "Quick steps to go live",
"highlightsTitle": "What you can do", "emptyChecklistProgress": "{{done}}/{{total}} steps",
"highlightImages": "Review photos & uploads", "emptyStepDetails": "Add name & date",
"highlightTasks": "Assign tasks & challenges", "emptyStepQr": "Share your QR poster",
"highlightQr": "Share QR posters", "emptyStepReview": "Review first uploads",
"highlightGuests": "Invite helpers & guests", "emptyPreviewTitle": "Here's what awaits",
"emptyPreviewQr": "Share QR poster",
"emptyPreviewQrDesc": "Print-ready codes for guests and crew.",
"emptyPreviewGallery": "Gallery & highlights",
"emptyPreviewGalleryDesc": "Moderate uploads, feature the best moments.",
"emptyPreviewTasks": "Tasks & challenges",
"emptyPreviewTasksDesc": "Guide guests with playful prompts.",
"emptySupportTitle": "Need help?",
"emptySupportBody": "We are here if you need a hand getting started.",
"emptySupportDocs": "Docs: Getting started",
"emptySupportEmail": "Email support",
"pickEvent": "Select an event", "pickEvent": "Select an event",
"status": { "status": {
"published": "Live", "published": "Live",
@@ -1712,6 +1808,7 @@
"shortcutPrints": "Print & poster downloads", "shortcutPrints": "Print & poster downloads",
"shortcutInvites": "Team / helper invites", "shortcutInvites": "Team / helper invites",
"shortcutSettings": "Event settings", "shortcutSettings": "Event settings",
"shortcutBranding": "Branding & moderation",
"kpiTitle": "Key performance indicators", "kpiTitle": "Key performance indicators",
"kpiTasks": "Open tasks", "kpiTasks": "Open tasks",
"kpiPhotos": "Photos", "kpiPhotos": "Photos",
@@ -1764,15 +1861,52 @@
"tenantBadge": "Tenant #{{id}}", "tenantBadge": "Tenant #{{id}}",
"notificationsTitle": "Notifications", "notificationsTitle": "Notifications",
"notificationsLoading": "Loading settings ...", "notificationsLoading": "Loading settings ...",
"pref": { "pref": {}
"task_updates": "Task updates", },
"photo_limits": "Photo limits", "settings": {
"photo_thresholds": "Photo thresholds", "notifications": {
"guest_limits": "Guest limits", "keys": {
"guest_thresholds": "Guest thresholds", "photo_thresholds": {
"purchase_limits": "Purchase limits", "label": "Photo thresholds",
"billing": "Billing & invoices", "description": "Get notified as photo uploads approach package limits."
"alerts": "Alerts" },
"photo_limits": {
"label": "Photo limits reached",
"description": "Alert when photo upload quota is exceeded."
},
"guest_thresholds": {
"label": "Guest thresholds",
"description": "Warn when guest count nears the limit."
},
"guest_limits": {
"label": "Guest limits reached",
"description": "Alert when guest limit is exceeded."
},
"gallery_warnings": {
"label": "Gallery warnings",
"description": "Heads-up before galleries go offline."
},
"gallery_expired": {
"label": "Gallery expired",
"description": "Notify when a gallery is no longer available."
},
"event_thresholds": {
"label": "Event thresholds",
"description": "Warn as event usage approaches limits."
},
"event_limits": {
"label": "Event limits reached",
"description": "Alert when event quotas are exceeded."
},
"package_expiring": {
"label": "Package expiring",
"description": "Reminders before your package expires."
},
"package_expired": {
"label": "Package expired",
"description": "Alert when your package has expired."
}
}
} }
}, },
"mobileBilling": { "mobileBilling": {

View File

@@ -2,7 +2,7 @@ import React from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query'; 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 { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text'; import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite'; import { Pressable } from '@tamagui/react-native-web-lite';
@@ -133,35 +133,178 @@ function OnboardingEmptyState() {
const theme = useTheme(); const theme = useTheme();
const text = String(theme.color12?.val ?? theme.color?.val ?? '#f8fafc'); const text = String(theme.color12?.val ?? theme.color?.val ?? '#f8fafc');
const muted = String(theme.gray11?.val ?? theme.gray?.val ?? '#cbd5e1'); 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 ( return (
<YStack space="$3"> <YStack space="$3">
<MobileCard alignItems="flex-start" space="$3"> <MobileCard
<Text fontSize="$lg" fontWeight="800" color={text}> padding="$4"
{t('mobileDashboard.emptyTitle', 'Create your first event')} borderColor="transparent"
</Text> overflow="hidden"
<Text fontSize="$sm" color={muted}> backgroundColor="linear-gradient(140deg, rgba(14,165,233,0.16), rgba(79,70,229,0.22))"
{t('mobileDashboard.emptyBody', 'Start an event to manage tasks, QR posters and uploads.')} >
</Text> <YStack position="absolute" top={-10} right={-10} opacity={0.16} scale={1.2}>
<CTAButton label={t('mobileDashboard.ctaCreate', 'Create event')} onPress={() => navigate(adminPath('/mobile/events/new'))} /> <Sparkles size={72} color={accentStrong} />
<CTAButton label={t('mobileDashboard.ctaDemo', 'View demo')} tone="ghost" onPress={() => navigate(adminPath('/mobile/events'))} /> </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>
<MobileCard space="$2">
<Text fontSize="$sm" fontWeight="800" color={text}> <MobileCard space="$2.5" borderColor={border} backgroundColor={stepBg}>
{t('mobileDashboard.highlightsTitle', 'What you can do')} <XStack alignItems="center" justifyContent="space-between">
</Text> <Text fontSize="$sm" fontWeight="800" color={text}>
<YStack space="$1.5"> {t('mobileDashboard.emptyChecklistTitle', 'Quick steps to go live')}
{[ </Text>
t('mobileDashboard.highlightImages', 'Review photos & uploads'), <PillBadge tone="muted">
t('mobileDashboard.highlightTasks', 'Assign tasks & challenges'), {t('mobileDashboard.emptyChecklistProgress', '{{done}}/{{total}} steps', { done: 0, total: steps.length })}
t('mobileDashboard.highlightQr', 'Share QR posters'), </PillBadge>
t('mobileDashboard.highlightGuests', 'Invite helpers & guests'), </XStack>
].map((item) => ( <YStack space="$2">
<XStack key={item} alignItems="center" space="$2"> {steps.map((label) => (
<PillBadge tone="muted">{item}</PillBadge> <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> </XStack>
))} ))}
</YStack> </YStack>
</MobileCard> </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> </YStack>
); );
} }
@@ -313,6 +456,7 @@ function SecondaryGrid({
const muted = String(theme.gray11?.val ?? theme.gray?.val ?? '#cbd5e1'); const muted = String(theme.gray11?.val ?? theme.gray?.val ?? '#cbd5e1');
const border = String(theme.borderColor?.val ?? '#334155'); const border = String(theme.borderColor?.val ?? '#334155');
const surface = String(theme.surface?.val ?? '#0b1220'); const surface = String(theme.surface?.val ?? '#0b1220');
const brandingAllowed = Boolean((event?.package as any)?.branding_allowed ?? true);
const tiles = [ const tiles = [
{ {
icon: Users, icon: Users,
@@ -338,6 +482,13 @@ function SecondaryGrid({
color: '#10b981', color: '#10b981',
action: onSettings, action: onSettings,
}, },
{
icon: Sparkles,
label: t('mobileDashboard.shortcutBranding', 'Branding & moderation'),
color: '#22d3ee',
action: brandingAllowed ? onSettings : undefined,
disabled: !brandingAllowed,
},
]; ];
return ( return (
@@ -347,7 +498,14 @@ function SecondaryGrid({
</Text> </Text>
<XStack flexWrap="wrap" space="$2"> <XStack flexWrap="wrap" space="$2">
{tiles.map((tile) => ( {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> </XStack>
{event ? ( {event ? (

View File

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

View File

@@ -18,7 +18,6 @@ type FormState = {
eventTypeId: number | null; eventTypeId: number | null;
description: string; description: string;
location: string; location: string;
enableBranding: boolean;
published: boolean; published: boolean;
}; };
@@ -27,7 +26,7 @@ export default function MobileEventFormPage() {
const slug = slugParam ?? null; const slug = slugParam ?? null;
const isEdit = Boolean(slug); const isEdit = Boolean(slug);
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useTranslation('management'); const { t } = useTranslation(['management', 'common']);
const [form, setForm] = React.useState<FormState>({ const [form, setForm] = React.useState<FormState>({
name: '', name: '',
@@ -35,7 +34,6 @@ export default function MobileEventFormPage() {
eventTypeId: null, eventTypeId: null,
description: '', description: '',
location: '', location: '',
enableBranding: false,
published: false, published: false,
}); });
const [eventTypes, setEventTypes] = React.useState<TenantEventType[]>([]); const [eventTypes, setEventTypes] = React.useState<TenantEventType[]>([]);
@@ -56,7 +54,6 @@ export default function MobileEventFormPage() {
eventTypeId: data.event_type_id ?? data.event_type?.id ?? null, eventTypeId: data.event_type_id ?? data.event_type?.id ?? null,
description: typeof data.description === 'string' ? data.description : '', description: typeof data.description === 'string' ? data.description : '',
location: resolveLocation(data), location: resolveLocation(data),
enableBranding: Boolean((data.settings as Record<string, unknown>)?.branding_allowed ?? true),
published: data.status === 'published', published: data.status === 'published',
}); });
setError(null); setError(null);
@@ -98,24 +95,24 @@ export default function MobileEventFormPage() {
event_date: form.date || undefined, event_date: form.date || undefined,
event_type_id: form.eventTypeId ?? undefined, event_type_id: form.eventTypeId ?? undefined,
status: form.published ? 'published' : 'draft', status: form.published ? 'published' : 'draft',
settings: { branding_allowed: form.enableBranding, location: form.location }, settings: { location: form.location },
}); });
navigate(adminPath(`/mobile/events/${slug}`)); navigate(adminPath(`/mobile/events/${slug}`));
} else { } else {
const payload = { const payload = {
name: form.name || 'Event', name: form.name || t('eventForm.fields.name.fallback', 'Event'),
slug: `${Date.now()}`, slug: `${Date.now()}`,
event_type_id: form.eventTypeId ?? undefined, event_type_id: form.eventTypeId ?? undefined,
event_date: form.date || undefined, event_date: form.date || undefined,
status: form.published ? 'published' : 'draft' as const, status: (form.published ? 'published' : 'draft') as const,
settings: { branding_allowed: form.enableBranding, location: form.location }, settings: { location: form.location },
}; };
const { event } = await createEvent(payload as any); const { event } = await createEvent(payload as any);
navigate(adminPath(`/mobile/events/${event.slug}`)); navigate(adminPath(`/mobile/events/${event.slug}`));
} }
} catch (err) { } catch (err) {
if (!isAuthError(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 { } finally {
setSaving(false); setSaving(false);
@@ -125,7 +122,7 @@ export default function MobileEventFormPage() {
return ( return (
<MobileShell <MobileShell
activeTab="home" 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)} onBack={() => navigate(-1)}
> >
{error ? ( {error ? (
@@ -137,17 +134,17 @@ export default function MobileEventFormPage() {
) : null} ) : null}
<MobileCard space="$3"> <MobileCard space="$3">
<Field label={t('events.form.name', 'Event Name')}> <Field label={t('eventForm.fields.name.label', 'Event name')}>
<input <input
type="text" type="text"
value={form.name} value={form.name}
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))} 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} style={inputStyle}
/> />
</Field> </Field>
<Field label={t('events.form.date', 'Date & Time')}> <Field label={t('eventForm.fields.date.label', 'Date & time')}>
<XStack alignItems="center" space="$2"> <XStack alignItems="center" space="$2">
<input <input
type="datetime-local" type="datetime-local"
@@ -180,46 +177,28 @@ export default function MobileEventFormPage() {
)} )}
</Field> </Field>
<Field label={t('events.form.description', 'Optional Details')}> <Field label={t('eventForm.fields.description.label', 'Optional details')}>
<textarea <textarea
value={form.description} value={form.description}
onChange={(e) => setForm((prev) => ({ ...prev, description: e.target.value }))} 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 }} style={{ ...inputStyle, minHeight: 96 }}
/> />
</Field> </Field>
<Field label={t('events.form.location', 'Location')}> <Field label={t('eventForm.fields.location.label', 'Location')}>
<XStack alignItems="center" space="$2"> <XStack alignItems="center" space="$2">
<input <input
type="text" type="text"
value={form.location} value={form.location}
onChange={(e) => setForm((prev) => ({ ...prev, location: e.target.value }))} 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 }} style={{ ...inputStyle, flex: 1 }}
/> />
<MapPin size={16} color="#9ca3af" /> <MapPin size={16} color="#9ca3af" />
</XStack> </XStack>
</Field> </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')}> <Field label={t('eventForm.fields.publish.label', 'Publish immediately')}>
<XStack alignItems="center" space="$2"> <XStack alignItems="center" space="$2">
<Switch <Switch
@@ -233,7 +212,7 @@ export default function MobileEventFormPage() {
<Switch.Thumb /> <Switch.Thumb />
</Switch> </Switch>
<Text fontSize="$sm" color="#111827"> <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> </Text>
</XStack> </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> <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, fontWeight: 700,
}} }}
> >
{t('events.form.saveDraft', 'Save as Draft')} {t('eventForm.actions.saveDraft', 'Save as draft')}
</button> </button>
) : null} ) : 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> </YStack>
</MobileShell> </MobileShell>
); );

View File

@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import { RefreshCcw, PlugZap, RefreshCw, ShieldCheck, Copy, Power, Clock3 } from 'lucide-react'; import { RefreshCcw, PlugZap, RefreshCw, ShieldCheck, Copy, Power, Clock3 } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks'; import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text'; import { SizableText as Text } from '@tamagui/text';
import { Switch } from '@tamagui/switch';
import { Pressable } from '@tamagui/react-native-web-lite'; import { Pressable } from '@tamagui/react-native-web-lite';
import { useTheme } from '@tamagui/core'; import { useTheme } from '@tamagui/core';
import { MobileShell } from './components/MobileShell'; import { MobileShell } from './components/MobileShell';
@@ -35,6 +36,7 @@ export default function MobileEventPhotoboothPage() {
const [event, setEvent] = React.useState<TenantEvent | null>(null); const [event, setEvent] = React.useState<TenantEvent | null>(null);
const [status, setStatus] = React.useState<PhotoboothStatus | 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 [loading, setLoading] = React.useState(true);
const [updating, setUpdating] = React.useState(false); const [updating, setUpdating] = React.useState(false);
const [error, setError] = React.useState<string | null>(null); 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)]); const [eventData, statusData] = await Promise.all([getEvent(slug), getEventPhotoboothStatus(slug)]);
setEvent(eventData); setEvent(eventData);
setStatus(statusData); setStatus(statusData);
setSelectedMode(statusData.mode ?? 'ftp');
} catch (err) { } catch (err) {
if (!isAuthError(err)) { if (!isAuthError(err)) {
setError(getApiErrorMessage(err, t('management.photobooth.errors.loadFailed', 'Photobooth-Link konnte nicht geladen werden.'))); setError(getApiErrorMessage(err, t('management.photobooth.errors.loadFailed', 'Photobooth-Link konnte nicht geladen werden.')));
@@ -62,12 +65,20 @@ export default function MobileEventPhotoboothPage() {
void load(); void load();
}, [load]); }, [load]);
React.useEffect(() => {
if (status?.mode) {
setSelectedMode(status.mode);
}
}, [status?.mode]);
const handleEnable = async (mode?: 'ftp' | 'sparkbooth') => { const handleEnable = async (mode?: 'ftp' | 'sparkbooth') => {
if (!slug) return; if (!slug) return;
const nextMode = mode ?? selectedMode ?? status?.mode ?? 'ftp';
setUpdating(true); setUpdating(true);
try { try {
const result = await enableEventPhotobooth(slug, { mode: mode ?? status?.mode ?? 'ftp' }); const result = await enableEventPhotobooth(slug, { mode: nextMode });
setStatus(result); setStatus(result);
setSelectedMode(result.mode ?? nextMode);
toast.success(t('management.photobooth.actions.enable', 'Zugang aktiviert')); toast.success(t('management.photobooth.actions.enable', 'Zugang aktiviert'));
} catch (err) { } catch (err) {
if (!isAuthError(err)) { if (!isAuthError(err)) {
@@ -80,10 +91,12 @@ export default function MobileEventPhotoboothPage() {
const handleDisable = async () => { const handleDisable = async () => {
if (!slug) return; if (!slug) return;
const mode = status?.mode ?? selectedMode ?? 'ftp';
setUpdating(true); setUpdating(true);
try { try {
const result = await disableEventPhotobooth(slug, { mode: status?.mode ?? 'ftp' }); const result = await disableEventPhotobooth(slug, { mode });
setStatus(result); setStatus(result);
setSelectedMode(result.mode ?? mode);
toast.success(t('management.photobooth.actions.disable', 'Zugang deaktiviert')); toast.success(t('management.photobooth.actions.disable', 'Zugang deaktiviert'));
} catch (err) { } catch (err) {
if (!isAuthError(err)) { if (!isAuthError(err)) {
@@ -96,10 +109,12 @@ export default function MobileEventPhotoboothPage() {
const handleRotate = async () => { const handleRotate = async () => {
if (!slug) return; if (!slug) return;
const mode = selectedMode ?? status?.mode ?? 'ftp';
setUpdating(true); setUpdating(true);
try { try {
const result = await rotateEventPhotobooth(slug, { mode: status?.mode ?? 'ftp' }); const result = await rotateEventPhotobooth(slug, { mode });
setStatus(result); setStatus(result);
setSelectedMode(result.mode ?? mode);
toast.success(t('management.photobooth.presets.actions.rotate', 'Zugang zurückgesetzt')); toast.success(t('management.photobooth.presets.actions.rotate', 'Zugang zurückgesetzt'));
} catch (err) { } catch (err) {
if (!isAuthError(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 = const modeLabel =
status?.mode === 'sparkbooth' activeMode === 'sparkbooth'
? t('photobooth.credentials.sparkboothTitle', 'Sparkbooth / HTTP') ? t('photobooth.credentials.sparkboothTitle', 'Sparkbooth / HTTP')
: t('photobooth.credentials.heading', 'FTP (Classic)'); : t('photobooth.credentials.heading', 'FTP (Classic)');
@@ -120,6 +151,15 @@ export default function MobileEventPhotoboothPage() {
const subtitle = const subtitle =
event?.event_date ? formatEventDate(event.event_date, locale) : t('header.selectEvent', 'Select an event to continue'); 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 ( return (
<MobileShell <MobileShell
activeTab="home" activeTab="home"
@@ -148,9 +188,9 @@ export default function MobileEventPhotoboothPage() {
</YStack> </YStack>
) : ( ) : (
<YStack space="$2"> <YStack space="$2">
<MobileCard space="$2"> <MobileCard space="$3">
<XStack justifyContent="space-between" alignItems="center"> <XStack justifyContent="space-between" alignItems="center" space="$3" flexWrap="wrap">
<YStack space="$1"> <YStack space="$1" flex={1} minWidth={0}>
<Text fontSize="$md" fontWeight="800" color={text}> <Text fontSize="$md" fontWeight="800" color={text}>
{t('photobooth.title', 'Photobooth')} {t('photobooth.title', 'Photobooth')}
</Text> </Text>
@@ -161,25 +201,71 @@ export default function MobileEventPhotoboothPage() {
{t('photobooth.mode.active', 'Current: {{mode}}', { mode: modeLabel })} {t('photobooth.mode.active', 'Current: {{mode}}', { mode: modeLabel })}
</Text> </Text>
</YStack> </YStack>
<PillBadge tone={isActive ? 'success' : 'warning'}> <YStack alignItems="flex-end" space="$2">
{isActive ? t('photobooth.status.badgeActive', 'ACTIVE') : t('photobooth.status.badgeInactive', 'INACTIVE')} <PillBadge tone={isActive ? 'success' : 'warning'}>
</PillBadge> {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> </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 space="$2" marginTop="$2" flexWrap="nowrap">
<XStack flex={1} minWidth={0}> <XStack flex={1} minWidth={0}>
<CTAButton <CTAButton
label={t('photobooth.credentials.heading', 'FTP credentials')} label={t('photobooth.mode.ftp', 'FTP (Classic)')}
tone={status?.mode === 'ftp' ? 'primary' : 'ghost'} tone={activeMode === 'ftp' ? 'primary' : 'ghost'}
onPress={() => handleEnable('ftp')} onPress={() => setSelectedMode('ftp')}
disabled={updating} disabled={updating}
style={{ width: '100%', paddingHorizontal: 10, paddingVertical: 10 }} style={{ width: '100%', paddingHorizontal: 10, paddingVertical: 10 }}
/> />
</XStack> </XStack>
<XStack flex={1} minWidth={0}> <XStack flex={1} minWidth={0}>
<CTAButton <CTAButton
label={t('photobooth.credentials.sparkboothTitle', 'Sparkbooth upload (HTTP)')} label={t('photobooth.mode.sparkbooth', 'Sparkbooth (HTTP POST)')}
tone={status?.mode === 'sparkbooth' ? 'primary' : 'ghost'} tone={activeMode === 'sparkbooth' ? 'primary' : 'ghost'}
onPress={() => handleEnable('sparkbooth')} onPress={() => setSelectedMode('sparkbooth')}
disabled={updating} disabled={updating}
style={{ width: '100%', paddingHorizontal: 10, paddingVertical: 10 }} style={{ width: '100%', paddingHorizontal: 10, paddingVertical: 10 }}
/> />
@@ -188,28 +274,51 @@ export default function MobileEventPhotoboothPage() {
</MobileCard> </MobileCard>
<MobileCard space="$2"> <MobileCard space="$2">
<Text fontSize="$sm" fontWeight="700" color={text}> <XStack alignItems="center" justifyContent="space-between">
{t('photobooth.credentials.heading', 'FTP credentials')} <Text fontSize="$sm" fontWeight="700" color={text}>
</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"> <YStack space="$1">
<CredentialRow label={t('photobooth.credentials.host', 'Host')} value={status?.host ?? '—'} border={border} /> {isSpark ? (
<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 /> <CredentialRow label={t('photobooth.credentials.postUrl', 'Upload URL')} value={uploadUrl ?? '—'} border={border} />
{status?.upload_url ? <CredentialRow label={t('photobooth.credentials.postUrl', 'Upload URL')} value={status.upload_url} border={border} /> : null} <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> </YStack>
<XStack space="$2" marginTop="$2" flexWrap="nowrap"> <XStack space="$2" marginTop="$2" flexWrap="wrap">
<XStack flex={1} minWidth={0}> <XStack flex={1} minWidth={0}>
<CTAButton <CTAButton
label={updating ? t('common.processing', '...') : t('photobooth.actions.rotate', 'Regenerate access')} label={updating ? t('common.processing', '...') : t('photobooth.actions.rotate', 'Regenerate access')}
onPress={() => handleRotate()} onPress={() => handleRotate()}
iconLeft={<RefreshCw size={14} color={surface} />} iconLeft={<RefreshCw size={14} color={surface} />}
disabled={updating}
style={{ width: '100%', paddingHorizontal: 10, paddingVertical: 10 }} style={{ width: '100%', paddingHorizontal: 10, paddingVertical: 10 }}
/> />
</XStack> </XStack>
<XStack flex={1} minWidth={0}> <XStack flex={1} minWidth={0}>
<CTAButton <CTAButton
label={isActive ? t('photobooth.actions.disable', 'Disable') : t('photobooth.actions.enable', 'Activate photobooth')} label={isActive ? t('photobooth.actions.disable', 'Disable uploads') : t('photobooth.actions.enable', 'Enable uploads')}
onPress={() => (isActive ? handleDisable() : handleEnable())} onPress={() => (isActive ? handleDisable() : handleEnable(selectedMode))}
tone={isActive ? 'ghost' : 'primary'} tone={isActive ? 'ghost' : 'primary'}
iconLeft={isActive ? <Power size={14} color={text} /> : <PlugZap size={14} color={surface} />} iconLeft={isActive ? <Power size={14} color={text} /> : <PlugZap size={14} color={surface} />}
disabled={updating} disabled={updating}
@@ -230,18 +339,35 @@ export default function MobileEventPhotoboothPage() {
label={t('photobooth.status.heading', 'Status')} label={t('photobooth.status.heading', 'Status')}
value={isActive ? t('common.enabled', 'Enabled') : t('common.disabled', 'Disabled')} value={isActive ? t('common.enabled', 'Enabled') : t('common.disabled', 'Disabled')}
/> />
{status?.metrics?.uploads_last_hour != null ? ( <StatusRow
<StatusRow icon={<RefreshCcw size={16} color={text} />}
icon={<RefreshCcw size={16} color={text} />} label={t('photobooth.rateLimit.label', 'Rate limit (uploads/min)')}
label={t('photobooth.rateLimit.usage', 'Uploads last hour')} value={status?.rate_limit_per_minute != null ? String(status.rate_limit_per_minute) : '—'}
value={String(status.metrics.uploads_last_hour)} />
/> <StatusRow
) : null} icon={<Clock3 size={16} color={text} />}
{status?.metrics?.last_upload_at ? ( label={t('photobooth.status.expires', 'Access expires')}
value={expiresAt ? formatEventDate(expiresAt, locale) ?? '—' : '—'}
/>
{lastUploadAt ? (
<StatusRow <StatusRow
icon={<Clock3 size={16} color={text} />} icon={<Clock3 size={16} color={text} />}
label={t('photobooth.stats.lastUpload', 'Letzter Upload')} label={t('photobooth.stats.lastUpload', 'Last upload')}
value={formatEventDate(status.metrics.last_upload_at, locale) ?? '—'} 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} ) : null}
</YStack> </YStack>

View File

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

View File

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

View File

@@ -139,16 +139,22 @@ export function ActionTile({
label, label,
color, color,
onPress, onPress,
disabled = false,
}: { }: {
icon: React.ComponentType<{ size?: number; color?: string }>; icon: React.ComponentType<{ size?: number; color?: string }>;
label: string; label: string;
color: string; color: string;
onPress: () => void; onPress?: () => void;
disabled?: boolean;
}) { }) {
const theme = useTheme(); const theme = useTheme();
const text = String(theme.color12?.val ?? theme.color?.val ?? '#f8fafc'); const text = String(theme.color12?.val ?? theme.color?.val ?? '#f8fafc');
return ( 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 <YStack
borderRadius={16} borderRadius={16}
padding="$3" padding="$3"