further improvements for the mobile admin
This commit is contained in:
@@ -169,7 +169,6 @@ class SeedDemoSwitcherTenants extends Command
|
||||
attributes: [
|
||||
'subscription_tier' => 'standard',
|
||||
'subscription_status' => 'active',
|
||||
'event_credits_balance' => 1,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -198,7 +197,6 @@ class SeedDemoSwitcherTenants extends Command
|
||||
attributes: [
|
||||
'subscription_tier' => 'starter',
|
||||
'subscription_status' => 'active',
|
||||
'event_credits_balance' => 0,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -229,7 +227,6 @@ class SeedDemoSwitcherTenants extends Command
|
||||
attributes: [
|
||||
'subscription_tier' => 'reseller',
|
||||
'subscription_status' => 'active',
|
||||
'event_credits_balance' => 2,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -294,7 +291,6 @@ class SeedDemoSwitcherTenants extends Command
|
||||
attributes: [
|
||||
'subscription_tier' => 'reseller',
|
||||
'subscription_status' => 'active',
|
||||
'event_credits_balance' => 0,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -139,11 +139,9 @@ class EventController extends Controller
|
||||
unset($eventData['features']);
|
||||
}
|
||||
|
||||
if ($settings === [] || $settings === null) {
|
||||
unset($eventData['settings']);
|
||||
} else {
|
||||
$settings['branding_allowed'] = $package->branding_allowed !== false;
|
||||
|
||||
$eventData['settings'] = $settings;
|
||||
}
|
||||
|
||||
foreach (['password', 'password_confirmation', 'password_protected', 'logo_image', 'cover_image'] as $unused) {
|
||||
unset($eventData[$unused]);
|
||||
@@ -233,6 +231,7 @@ class EventController extends Controller
|
||||
public function update(EventStoreRequest $request, Event $event): JsonResponse
|
||||
{
|
||||
$tenantId = $request->attributes->get('tenant_id');
|
||||
$event->loadMissing('eventPackage.package');
|
||||
|
||||
if ($event->tenant_id !== $tenantId) {
|
||||
return ApiError::response(
|
||||
@@ -259,10 +258,16 @@ class EventController extends Controller
|
||||
unset($validated[$unused]);
|
||||
}
|
||||
|
||||
$brandingAllowed = optional($event->eventPackage?->package)->branding_allowed !== false;
|
||||
|
||||
if (isset($validated['settings']) && is_array($validated['settings'])) {
|
||||
$validated['settings'] = array_merge($event->settings ?? [], $validated['settings']);
|
||||
} else {
|
||||
$validated['settings'] = $event->settings ?? [];
|
||||
}
|
||||
|
||||
$validated['settings']['branding_allowed'] = $brandingAllowed;
|
||||
|
||||
$event->update($validated);
|
||||
$event->load(['eventType', 'tenant']);
|
||||
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
<PanelLeftClose className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<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}
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
</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)}
|
||||
<Button
|
||||
size="$3"
|
||||
theme="yellow"
|
||||
icon={<PanelRightOpen size={16} />}
|
||||
borderRadius={999}
|
||||
position="fixed"
|
||||
right="$4"
|
||||
zIndex={1000}
|
||||
onPress={() => setCollapsed(false)}
|
||||
style={{ bottom: bottomOffset + 70 }}
|
||||
>
|
||||
<PanelRightOpen className="h-4 w-4" />
|
||||
Demo tenants
|
||||
</button>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -57,6 +57,10 @@
|
||||
"de": "Deutsch",
|
||||
"en": "Englisch"
|
||||
},
|
||||
"states": {
|
||||
"enabled": "Aktiviert",
|
||||
"disabled": "Deaktiviert"
|
||||
},
|
||||
"actions": {
|
||||
"open": "Öffnen",
|
||||
"viewAll": "Alle anzeigen",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -57,6 +57,10 @@
|
||||
"de": "German",
|
||||
"en": "English"
|
||||
},
|
||||
"states": {
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled"
|
||||
},
|
||||
"actions": {
|
||||
"open": "Open",
|
||||
"viewAll": "View all",
|
||||
|
||||
@@ -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 can’t 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": {
|
||||
|
||||
@@ -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')}
|
||||
<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={muted}>
|
||||
{t('mobileDashboard.emptyBody', 'Start an event to manage tasks, QR posters and uploads.')}
|
||||
<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'))} />
|
||||
<CTAButton label={t('mobileDashboard.ctaDemo', 'View demo')} tone="ghost" onPress={() => navigate(adminPath('/mobile/events'))} />
|
||||
</YStack>
|
||||
</MobileCard>
|
||||
<MobileCard space="$2">
|
||||
|
||||
<MobileCard space="$2.5" borderColor={border} backgroundColor={stepBg}>
|
||||
<XStack alignItems="center" justifyContent="space-between">
|
||||
<Text fontSize="$sm" fontWeight="800" color={text}>
|
||||
{t('mobileDashboard.highlightsTitle', 'What you can do')}
|
||||
{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>
|
||||
<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>
|
||||
</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 ? (
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
<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">
|
||||
<XStack alignItems="center" justifyContent="space-between">
|
||||
<Text fontSize="$sm" fontWeight="700" color={text}>
|
||||
{t('photobooth.credentials.heading', 'FTP credentials')}
|
||||
{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)}
|
||||
label={t('photobooth.rateLimit.label', 'Rate limit (uploads/min)')}
|
||||
value={status?.rate_limit_per_minute != null ? String(status.rate_limit_per_minute) : '—'}
|
||||
/>
|
||||
) : null}
|
||||
{status?.metrics?.last_upload_at ? (
|
||||
<StatusRow
|
||||
icon={<Clock3 size={16} color={text} />}
|
||||
label={t('photobooth.stats.lastUpload', 'Letzter Upload')}
|
||||
value={formatEventDate(status.metrics.last_upload_at, locale) ?? '—'}
|
||||
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', '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>
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
<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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user