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

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

View File

@@ -149,6 +149,7 @@ export type PhotoboothStatusMetrics = {
uploads_last_hour?: number | null;
uploads_today?: number | null;
uploads_total?: number | null;
uploads_24h?: number | 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_today: readNumber('uploads_today') ?? readNumber('today'),
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,
};
}

View File

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

View File

@@ -191,9 +191,36 @@
}
},
"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": {
"missingSlug": "Kein Event ausgewählt.",
"loadFailed": "Event konnte nicht geladen werden.",
"saveFailed": "Event konnte nicht gespeichert werden.",
"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.",
"toggleFailed": "Status konnte nicht angepasst werden.",
@@ -221,6 +248,22 @@
"buyMoreGuests": "Mehr Gäste freischalten",
"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": {
"detailSubtitle": "Behalte Status, Aufgaben und QR-Codes deines Events im Blick.",
"toolkitSubtitle": "Moderation, Aufgaben und QR-Codes für deinen Eventtag bündeln.",
@@ -1531,7 +1574,9 @@
},
"eventForm": {
"errors": {
"notice": "Hinweis"
"notice": "Hinweis",
"loadFailed": "Event konnte nicht geladen werden.",
"saveFailed": "Event konnte nicht gespeichert werden."
},
"titles": {
"create": "Neues Event erstellen",
@@ -1548,11 +1593,24 @@
"name": {
"label": "Eventname",
"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": {
"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": {
"label": "Event-Typ",
"loading": "Event-Typ wird geladen…",
@@ -1566,9 +1624,12 @@
},
"actions": {
"backToList": "Zurück zur Liste",
"saving": "Speichert",
"saving": "Speichert",
"save": "Speichern",
"cancel": "Abbrechen"
"cancel": "Abbrechen",
"saveDraft": "Als Entwurf speichern",
"update": "Event aktualisieren",
"create": "Event erstellen"
}
},
"notifications": {
@@ -1609,6 +1670,32 @@
}
},
"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": {
"missingSlug": "Kein Event-Slug angegeben.",
"loadFailed": "Tasks konnten nicht geladen werden.",
@@ -1664,15 +1751,26 @@
"mobileDashboard": {
"title": "Dashboard",
"selectEvent": "Wähle ein Event, um fortzufahren",
"emptyTitle": "Erstelle dein erstes Event",
"emptyBody": "Starte ein Event, um Tasks, QR-Poster und Uploads zu verwalten.",
"emptyBadge": "Willkommen!",
"emptyTitle": "Willkommen! Lass uns dein erstes Event starten",
"emptyBody": "Drucke einen QR, sammle Uploads und moderiere in Minuten.",
"ctaCreate": "Event erstellen",
"ctaDemo": "Demo ansehen",
"highlightsTitle": "Das kannst du tun",
"highlightImages": "Fotos & Uploads prüfen",
"highlightTasks": "Tasks & Challenges zuweisen",
"highlightQr": "QR-Poster teilen",
"highlightGuests": "Helfer & Gäste einladen",
"emptyChecklistTitle": "Schnelle Schritte bis live",
"emptyChecklistProgress": "{{done}}/{{total}} Schritte",
"emptyStepDetails": "Name & Datum ergänzen",
"emptyStepQr": "QR-Poster teilen",
"emptyStepReview": "Erste Uploads prüfen",
"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",
"status": {
"published": "Live",
@@ -1689,6 +1787,7 @@
"shortcutPrints": "Drucke & Poster-Downloads",
"shortcutInvites": "Team-/Helfer-Einladungen",
"shortcutSettings": "Event-Einstellungen",
"shortcutBranding": "Branding & Moderation",
"kpiTitle": "Wichtigste Kennzahlen",
"kpiTasks": "Offene Tasks",
"kpiPhotos": "Fotos",
@@ -1741,15 +1840,52 @@
"tenantBadge": "Tenant #{{id}}",
"notificationsTitle": "Benachrichtigungen",
"notificationsLoading": "Lade Einstellungen ...",
"pref": {
"task_updates": "Task-Updates",
"photo_limits": "Foto-Limits",
"photo_thresholds": "Foto-Schwellen",
"guest_limits": "Gäste-Limits",
"guest_thresholds": "Gäste-Schwellen",
"purchase_limits": "Kauf-Limits",
"billing": "Abrechnung & Rechnungen",
"alerts": "Warnungen"
"pref": {}
},
"settings": {
"notifications": {
"keys": {
"photo_thresholds": {
"label": "Foto-Schwellen",
"description": "Benachrichtigt, wenn Foto-Uploads die Paketgrenzen annähern."
},
"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": {

View File

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

View File

@@ -187,6 +187,32 @@
}
},
"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": {
"title": "Your events",
"subtitle": "Plan memorable moments. Manage everything around your events here.",
@@ -793,6 +819,7 @@
"errors": {
"missingSlug": "No event selected.",
"loadFailed": "Event could not be loaded.",
"saveFailed": "Event could not be saved.",
"notFoundTitle": "Event not found",
"notFoundBody": "Without a valid identifier we cant load the data. Return to the list and choose an event.",
"toggleFailed": "Status could not be updated.",
@@ -820,6 +847,22 @@
"buyMoreGuests": "Unlock more guests",
"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": {
"detailSubtitle": "Keep status, tasks, and invites of your event in one view.",
"toolkitSubtitle": "Bundle moderation, tasks, and invites for the event day.",
@@ -1550,6 +1593,9 @@
},
"eventForm": {
"errors": {
"notice": "Notice",
"loadFailed": "Event could not be loaded.",
"saveFailed": "Event could not be saved.",
"nameRequired": "Please enter an event name.",
"typeRequired": "Please select an event type."
},
@@ -1568,11 +1614,24 @@
"name": {
"label": "Event name",
"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": {
"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": {
"label": "Event type",
"loading": "Loading event types…",
@@ -1586,12 +1645,12 @@
},
"actions": {
"backToList": "Back to list",
"saving": "Saving",
"saving": "Saving",
"save": "Save",
"cancel": "Cancel"
},
"errors": {
"notice": "Notice"
"cancel": "Cancel",
"saveDraft": "Save as draft",
"update": "Update event",
"create": "Create event"
}
},
"notifications": {
@@ -1632,6 +1691,32 @@
}
},
"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": {
"missingSlug": "No event slug provided.",
"loadFailed": "Tasks could not be loaded.",
@@ -1687,15 +1772,26 @@
"mobileDashboard": {
"title": "Dashboard",
"selectEvent": "Select an event to continue",
"emptyTitle": "Create your first event",
"emptyBody": "Start an event to manage tasks, QR posters and uploads.",
"emptyBadge": "Welcome aboard",
"emptyTitle": "Welcome! Let's launch your first event",
"emptyBody": "Print a QR, collect uploads, and start moderating in minutes.",
"ctaCreate": "Create event",
"ctaDemo": "View demo",
"highlightsTitle": "What you can do",
"highlightImages": "Review photos & uploads",
"highlightTasks": "Assign tasks & challenges",
"highlightQr": "Share QR posters",
"highlightGuests": "Invite helpers & guests",
"emptyChecklistTitle": "Quick steps to go live",
"emptyChecklistProgress": "{{done}}/{{total}} steps",
"emptyStepDetails": "Add name & date",
"emptyStepQr": "Share your QR poster",
"emptyStepReview": "Review first uploads",
"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",
"status": {
"published": "Live",
@@ -1712,6 +1808,7 @@
"shortcutPrints": "Print & poster downloads",
"shortcutInvites": "Team / helper invites",
"shortcutSettings": "Event settings",
"shortcutBranding": "Branding & moderation",
"kpiTitle": "Key performance indicators",
"kpiTasks": "Open tasks",
"kpiPhotos": "Photos",
@@ -1764,15 +1861,52 @@
"tenantBadge": "Tenant #{{id}}",
"notificationsTitle": "Notifications",
"notificationsLoading": "Loading settings ...",
"pref": {
"task_updates": "Task updates",
"photo_limits": "Photo limits",
"photo_thresholds": "Photo thresholds",
"guest_limits": "Guest limits",
"guest_thresholds": "Guest thresholds",
"purchase_limits": "Purchase limits",
"billing": "Billing & invoices",
"alerts": "Alerts"
"pref": {}
},
"settings": {
"notifications": {
"keys": {
"photo_thresholds": {
"label": "Photo thresholds",
"description": "Get notified as photo uploads approach package limits."
},
"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": {

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"