Navigation now feels more “app‑like” with

stateful tabs and reliable back behavior, and a full onboarding flow is wired in with conditional package selection
  (skips when an active package exists).

  What changed

  - Added per‑tab history + back navigation fallback to make tab switching/Back feel native (resources/js/admin/mobile/
    lib/tabHistory.ts, resources/js/admin/mobile/hooks/useBackNavigation.ts, resources/js/admin/mobile/hooks/
    useMobileNav.ts, resources/js/admin/mobile/components/MobileShell.tsx + updates across mobile pages).
  - Implemented onboarding flow pages + shared shell, and wired new routes/prefetch (resources/js/admin/mobile/welcome/
    WelcomeLandingPage.tsx, resources/js/admin/mobile/welcome/WelcomePackagesPage.tsx, resources/js/admin/mobile/
    welcome/WelcomeSummaryPage.tsx, resources/js/admin/mobile/welcome/WelcomeEventPage.tsx, resources/js/admin/mobile/
    components/OnboardingShell.tsx, resources/js/admin/router.tsx, resources/js/admin/mobile/prefetch.ts).
  - Conditional package step: packages page redirects to event setup if activePackage exists; selection stored locally
    for summary (resources/js/admin/mobile/lib/onboardingSelection.ts, resources/js/admin/mobile/welcome/
    WelcomePackagesPage.tsx).
  - Added a “Start welcome journey” CTA in the empty dashboard state (resources/js/admin/mobile/DashboardPage.tsx).
  - Added translations for onboarding shell + selected package + dashboard CTA (resources/js/admin/i18n/locales/en/
    onboarding.json, resources/js/admin/i18n/locales/de/onboarding.json, resources/js/admin/i18n/locales/en/
    management.json, resources/js/admin/i18n/locales/de/management.json).
  - Tests for new helpers/hooks (resources/js/admin/mobile/lib/tabHistory.test.ts, resources/js/admin/mobile/lib/
    onboardingSelection.test.ts, resources/js/admin/mobile/hooks/useBackNavigation.test.tsx).
This commit is contained in:
Codex Agent
2025-12-28 19:51:57 +01:00
parent 718c129a8d
commit cf73f408b2
36 changed files with 1097 additions and 47 deletions

View File

@@ -1920,6 +1920,7 @@
"emptyTitle": "Willkommen! Lass uns dein erstes Event starten", "emptyTitle": "Willkommen! Lass uns dein erstes Event starten",
"emptyBody": "Drucke einen QR, sammle Uploads und moderiere in Minuten.", "emptyBody": "Drucke einen QR, sammle Uploads und moderiere in Minuten.",
"ctaCreate": "Event erstellen", "ctaCreate": "Event erstellen",
"ctaWelcome": "Welcome-Guide starten",
"emptyChecklistTitle": "Schnelle Schritte bis live", "emptyChecklistTitle": "Schnelle Schritte bis live",
"emptyChecklistProgress": "{{done}}/{{total}} Schritte", "emptyChecklistProgress": "{{done}}/{{total}} Schritte",
"emptyStepDetails": "Name & Datum ergänzen", "emptyStepDetails": "Name & Datum ergänzen",

View File

@@ -3,6 +3,8 @@
"eyebrow": "Fotospiel Kunden-Admin", "eyebrow": "Fotospiel Kunden-Admin",
"title": "Willkommen im Event-Erlebnisstudio", "title": "Willkommen im Event-Erlebnisstudio",
"subtitle": "Starte mit einer inspirierten Einführung, sichere dir dein Event-Paket und kreiere die perfekte Gästegalerie alles optimiert für mobile Hosts.", "subtitle": "Starte mit einer inspirierten Einführung, sichere dir dein Event-Paket und kreiere die perfekte Gästegalerie alles optimiert für mobile Hosts.",
"back": "Zurück",
"skip": "Überspringen",
"alreadyFamiliar": "Schon vertraut mit Fotospiel?", "alreadyFamiliar": "Schon vertraut mit Fotospiel?",
"jumpToDashboard": "Direkt zum Dashboard" "jumpToDashboard": "Direkt zum Dashboard"
}, },
@@ -93,6 +95,7 @@
"description": "Sofort einsatzbereit für dein nächstes Event.", "description": "Sofort einsatzbereit für dein nächstes Event.",
"descriptionWithPhotos": "Bis zu {{count}} Fotos inklusive perfekt für lebendige Reportagen.", "descriptionWithPhotos": "Bis zu {{count}} Fotos inklusive perfekt für lebendige Reportagen.",
"active": "Aktives Paket", "active": "Aktives Paket",
"selected": "Ausgewählt",
"select": "Paket wählen", "select": "Paket wählen",
"onRequest": "Auf Anfrage", "onRequest": "Auf Anfrage",
"purchased": "Bereits gekauft am {{date}}", "purchased": "Bereits gekauft am {{date}}",

View File

@@ -1940,6 +1940,7 @@
"emptyTitle": "Welcome! Let's launch your first event", "emptyTitle": "Welcome! Let's launch your first event",
"emptyBody": "Print a QR, collect uploads, and start moderating in minutes.", "emptyBody": "Print a QR, collect uploads, and start moderating in minutes.",
"ctaCreate": "Create event", "ctaCreate": "Create event",
"ctaWelcome": "Start welcome journey",
"emptyChecklistTitle": "Quick steps to go live", "emptyChecklistTitle": "Quick steps to go live",
"emptyChecklistProgress": "{{done}}/{{total}} steps", "emptyChecklistProgress": "{{done}}/{{total}} steps",
"emptyStepDetails": "Add name & date", "emptyStepDetails": "Add name & date",

View File

@@ -3,6 +3,8 @@
"eyebrow": "Fotospiel Customer Admin", "eyebrow": "Fotospiel Customer Admin",
"title": "Welcome to your event studio", "title": "Welcome to your event studio",
"subtitle": "Begin with an inspired introduction, secure your package, and craft the perfect guest gallery all optimised for mobile hosts.", "subtitle": "Begin with an inspired introduction, secure your package, and craft the perfect guest gallery all optimised for mobile hosts.",
"back": "Back",
"skip": "Skip",
"alreadyFamiliar": "Already familiar with Fotospiel?", "alreadyFamiliar": "Already familiar with Fotospiel?",
"jumpToDashboard": "Jump to dashboard" "jumpToDashboard": "Jump to dashboard"
}, },
@@ -93,6 +95,7 @@
"description": "Ready for your next event right away.", "description": "Ready for your next event right away.",
"descriptionWithPhotos": "Up to {{count}} photos included perfect for vibrant storytelling.", "descriptionWithPhotos": "Up to {{count}} photos included perfect for vibrant storytelling.",
"active": "Active package", "active": "Active package",
"selected": "Selected",
"select": "Select package", "select": "Select package",
"onRequest": "On request", "onRequest": "On request",
"purchased": "Purchased on {{date}}", "purchased": "Purchased on {{date}}",

View File

@@ -17,8 +17,9 @@ import {
} from '../api'; } from '../api';
import { TenantAddonHistoryEntry, getTenantAddonHistory } from '../api'; import { TenantAddonHistoryEntry, getTenantAddonHistory } from '../api';
import { getApiErrorMessage } from '../lib/apiError'; import { getApiErrorMessage } from '../lib/apiError';
import { ADMIN_EVENT_VIEW_PATH } from '../constants'; import { ADMIN_EVENT_VIEW_PATH, adminPath } from '../constants';
import { buildPackageUsageMetrics, PackageUsageMetric, usagePercent } from './billingUsage'; import { buildPackageUsageMetrics, PackageUsageMetric, usagePercent } from './billingUsage';
import { useBackNavigation } from './hooks/useBackNavigation';
export default function MobileBillingPage() { export default function MobileBillingPage() {
const { t } = useTranslation('management'); const { t } = useTranslation('management');
@@ -34,6 +35,7 @@ export default function MobileBillingPage() {
const packagesRef = React.useRef<HTMLDivElement | null>(null); const packagesRef = React.useRef<HTMLDivElement | null>(null);
const invoicesRef = React.useRef<HTMLDivElement | null>(null); const invoicesRef = React.useRef<HTMLDivElement | null>(null);
const supportEmail = 'support@fotospiel.de'; const supportEmail = 'support@fotospiel.de';
const back = useBackNavigation(adminPath('/mobile/profile'));
const load = React.useCallback(async () => { const load = React.useCallback(async () => {
setLoading(true); setLoading(true);
@@ -102,7 +104,7 @@ export default function MobileBillingPage() {
<MobileShell <MobileShell
activeTab="profile" activeTab="profile"
title={t('billing.title', 'Billing & Packages')} title={t('billing.title', 'Billing & Packages')}
onBack={() => navigate(-1)} onBack={back}
headerActions={ headerActions={
<HeaderActionButton onPress={() => load()} ariaLabel={t('common.refresh', 'Refresh')}> <HeaderActionButton onPress={() => load()} ariaLabel={t('common.refresh', 'Refresh')}>
<RefreshCcw size={18} color="#0f172a" /> <RefreshCcw size={18} color="#0f172a" />

View File

@@ -13,6 +13,8 @@ import { ApiError, getApiErrorMessage } from '../lib/apiError';
import { isBrandingAllowed } from '../lib/events'; import { isBrandingAllowed } from '../lib/events';
import { MobileSheet } from './components/Sheet'; import { MobileSheet } from './components/Sheet';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { adminPath } from '../constants';
import { useBackNavigation } from './hooks/useBackNavigation';
type BrandingForm = { type BrandingForm = {
primary: string; primary: string;
@@ -78,6 +80,7 @@ export default function MobileBrandingPage() {
const [error, setError] = React.useState<string | null>(null); const [error, setError] = React.useState<string | null>(null);
const [showFontsSheet, setShowFontsSheet] = React.useState(false); const [showFontsSheet, setShowFontsSheet] = React.useState(false);
const [fontField, setFontField] = React.useState<'heading' | 'body'>('heading'); const [fontField, setFontField] = React.useState<'heading' | 'body'>('heading');
const back = useBackNavigation(slug ? adminPath(`/mobile/events/${slug}`) : adminPath('/mobile/events'));
const [fonts, setFonts] = React.useState<TenantFont[]>([]); const [fonts, setFonts] = React.useState<TenantFont[]>([]);
const [fontsLoading, setFontsLoading] = React.useState(false); const [fontsLoading, setFontsLoading] = React.useState(false);
const [fontsLoaded, setFontsLoaded] = React.useState(false); const [fontsLoaded, setFontsLoaded] = React.useState(false);
@@ -402,7 +405,7 @@ export default function MobileBrandingPage() {
<MobileShell <MobileShell
activeTab="home" activeTab="home"
title={t('events.branding.titleShort', 'Branding')} title={t('events.branding.titleShort', 'Branding')}
onBack={() => navigate(-1)} onBack={back}
headerActions={ headerActions={
<HeaderActionButton onPress={() => handleSave()} ariaLabel={t('common.save', 'Save')}> <HeaderActionButton onPress={() => handleSave()} ariaLabel={t('common.save', 'Save')}>
<Save size={18} color="#007AFF" /> <Save size={18} color="#007AFF" />

View File

@@ -9,7 +9,7 @@ import { Pressable } from '@tamagui/react-native-web-lite';
import { MobileShell, renderEventLocation } from './components/MobileShell'; import { MobileShell, renderEventLocation } from './components/MobileShell';
import { MobileCard, CTAButton, KpiTile, ActionTile, PillBadge, SkeletonCard } from './components/Primitives'; import { MobileCard, CTAButton, KpiTile, ActionTile, PillBadge, SkeletonCard } from './components/Primitives';
import { MobileSheet } from './components/Sheet'; import { MobileSheet } from './components/Sheet';
import { adminPath } from '../constants'; import { adminPath, ADMIN_WELCOME_BASE_PATH } from '../constants';
import { useEventContext } from '../context/EventContext'; import { useEventContext } from '../context/EventContext';
import { getEventStats, EventStats, TenantEvent, getEvents } from '../api'; import { getEventStats, EventStats, TenantEvent, getEvents } from '../api';
import { formatEventDate, isBrandingAllowed, resolveEngagementMode, resolveEventDisplayName } from '../lib/events'; import { formatEventDate, isBrandingAllowed, resolveEngagementMode, resolveEventDisplayName } from '../lib/events';
@@ -588,6 +588,11 @@ function OnboardingEmptyState({ installPrompt, pushState, devicePermissions, onO
{t('mobileDashboard.emptyBody', 'Print a QR, collect uploads, and start moderating in minutes.')} {t('mobileDashboard.emptyBody', 'Print a QR, collect uploads, and start moderating in minutes.')}
</Text> </Text>
<CTAButton label={t('mobileDashboard.ctaCreate', 'Create event')} onPress={() => navigate(adminPath('/mobile/events/new'))} /> <CTAButton label={t('mobileDashboard.ctaCreate', 'Create event')} onPress={() => navigate(adminPath('/mobile/events/new'))} />
<CTAButton
label={t('mobileDashboard.ctaWelcome', 'Start welcome journey')}
tone="ghost"
onPress={() => navigate(ADMIN_WELCOME_BASE_PATH)}
/>
</YStack> </YStack>
</MobileCard> </MobileCard>

View File

@@ -15,6 +15,7 @@ import { MobileSheet } from './components/Sheet';
import { useEventContext } from '../context/EventContext'; import { useEventContext } from '../context/EventContext';
import { formatEventDate, isBrandingAllowed, resolveEngagementMode, resolveEventDisplayName } from '../lib/events'; import { formatEventDate, isBrandingAllowed, resolveEngagementMode, resolveEventDisplayName } from '../lib/events';
import { isPastEvent } from './eventDate'; import { isPastEvent } from './eventDate';
import { useBackNavigation } from './hooks/useBackNavigation';
export default function MobileEventDetailPage() { export default function MobileEventDetailPage() {
const { slug: slugParam } = useParams<{ slug?: string }>(); const { slug: slugParam } = useParams<{ slug?: string }>();
@@ -29,6 +30,7 @@ export default function MobileEventDetailPage() {
const [error, setError] = React.useState<string | null>(null); const [error, setError] = React.useState<string | null>(null);
const { events, activeEvent, selectEvent } = useEventContext(); const { events, activeEvent, selectEvent } = useEventContext();
const [showEventPicker, setShowEventPicker] = React.useState(false); const [showEventPicker, setShowEventPicker] = React.useState(false);
const back = useBackNavigation(adminPath('/mobile/events'));
React.useEffect(() => { React.useEffect(() => {
if (!slug) return; if (!slug) return;
@@ -99,7 +101,7 @@ export default function MobileEventDetailPage() {
? formatDate(event?.event_date ?? activeEvent?.event_date, t) ? formatDate(event?.event_date ?? activeEvent?.event_date, t)
: undefined : undefined
} }
onBack={() => navigate(-1)} onBack={back}
headerActions={ headerActions={
<XStack space="$3" alignItems="center"> <XStack space="$3" alignItems="center">
<HeaderActionButton onPress={() => navigate(adminPath('/mobile/settings'))} ariaLabel={t('mobileSettings.title', 'Settings')}> <HeaderActionButton onPress={() => navigate(adminPath('/mobile/settings'))} ariaLabel={t('mobileSettings.title', 'Settings')}>

View File

@@ -15,6 +15,7 @@ import { adminPath } from '../constants';
import { isAuthError } from '../auth/tokens'; import { isAuthError } from '../auth/tokens';
import { getApiValidationMessage, isApiError } from '../lib/apiError'; import { getApiValidationMessage, isApiError } from '../lib/apiError';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { useBackNavigation } from './hooks/useBackNavigation';
type FormState = { type FormState = {
name: string; name: string;
@@ -52,6 +53,7 @@ export default function MobileEventFormPage() {
const [consentBusy, setConsentBusy] = React.useState(false); const [consentBusy, setConsentBusy] = React.useState(false);
const [pendingPayload, setPendingPayload] = React.useState<Parameters<typeof createEvent>[0] | null>(null); const [pendingPayload, setPendingPayload] = React.useState<Parameters<typeof createEvent>[0] | null>(null);
const [error, setError] = React.useState<string | null>(null); const [error, setError] = React.useState<string | null>(null);
const back = useBackNavigation(slug ? adminPath(`/mobile/events/${slug}`) : adminPath('/mobile/events'));
React.useEffect(() => { React.useEffect(() => {
if (!slug) return; if (!slug) return;
@@ -198,7 +200,7 @@ export default function MobileEventFormPage() {
<MobileShell <MobileShell
activeTab="home" activeTab="home"
title={isEdit ? t('eventForm.titles.edit', 'Edit event') : t('eventForm.titles.create', 'Create event')} title={isEdit ? t('eventForm.titles.edit', 'Edit event') : t('eventForm.titles.create', 'Create event')}
onBack={() => navigate(-1)} onBack={back}
> >
{error ? ( {error ? (
<MobileCard> <MobileCard>
@@ -357,7 +359,7 @@ export default function MobileEventFormPage() {
{!isEdit ? ( {!isEdit ? (
<button <button
type="button" type="button"
onClick={() => navigate(-1)} onClick={back}
style={{ style={{
...inputStyle, ...inputStyle,
height: 48, height: 48,

View File

@@ -23,6 +23,7 @@ import { isAuthError } from '../auth/tokens';
import { getApiErrorMessage } from '../lib/apiError'; import { getApiErrorMessage } from '../lib/apiError';
import { adminPath } from '../constants'; import { adminPath } from '../constants';
import { formatGuestMessageDate } from './guestMessages'; import { formatGuestMessageDate } from './guestMessages';
import { useBackNavigation } from './hooks/useBackNavigation';
type FormState = { type FormState = {
title: string; title: string;
@@ -48,6 +49,7 @@ export default function MobileEventGuestNotificationsPage() {
const [error, setError] = React.useState<string | null>(null); const [error, setError] = React.useState<string | null>(null);
const [fallbackAttempted, setFallbackAttempted] = React.useState(false); const [fallbackAttempted, setFallbackAttempted] = React.useState(false);
const formRef = React.useRef<HTMLDivElement | null>(null); const formRef = React.useRef<HTMLDivElement | null>(null);
const back = useBackNavigation(slug ? adminPath(`/mobile/events/${slug}`) : adminPath('/mobile/events'));
const [form, setForm] = React.useState<FormState>({ const [form, setForm] = React.useState<FormState>({
title: '', title: '',
@@ -186,7 +188,7 @@ export default function MobileEventGuestNotificationsPage() {
activeTab="home" activeTab="home"
title={t('guestMessages.title', 'Guest messages')} title={t('guestMessages.title', 'Guest messages')}
subtitle={t('guestMessages.subtitle', 'Send push messages to guests')} subtitle={t('guestMessages.subtitle', 'Send push messages to guests')}
onBack={() => navigate(-1)} onBack={back}
headerActions={ headerActions={
<HeaderActionButton onPress={() => loadHistory()} ariaLabel={t('common.refresh', 'Refresh')}> <HeaderActionButton onPress={() => loadHistory()} ariaLabel={t('common.refresh', 'Refresh')}>
<RefreshCcw size={18} color={String(theme.color?.val ?? '#111827')} /> <RefreshCcw size={18} color={String(theme.color?.val ?? '#111827')} />

View File

@@ -13,6 +13,8 @@ import { isAuthError } from '../auth/tokens';
import { getApiErrorMessage } from '../lib/apiError'; import { getApiErrorMessage } from '../lib/apiError';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { MobileSheet } from './components/Sheet'; import { MobileSheet } from './components/Sheet';
import { adminPath } from '../constants';
import { useBackNavigation } from './hooks/useBackNavigation';
export default function MobileEventMembersPage() { export default function MobileEventMembersPage() {
const { slug: slugParam } = useParams<{ slug?: string }>(); const { slug: slugParam } = useParams<{ slug?: string }>();
@@ -29,6 +31,7 @@ export default function MobileEventMembersPage() {
const emailInputRef = React.useRef<HTMLInputElement | null>(null); const emailInputRef = React.useRef<HTMLInputElement | null>(null);
const [search, setSearch] = React.useState(''); const [search, setSearch] = React.useState('');
const [confirmRemove, setConfirmRemove] = React.useState<EventMember | null>(null); const [confirmRemove, setConfirmRemove] = React.useState<EventMember | null>(null);
const back = useBackNavigation(slug ? adminPath(`/mobile/events/${slug}`) : adminPath('/mobile/events'));
const load = React.useCallback(async () => { const load = React.useCallback(async () => {
if (!slug) return; if (!slug) return;
@@ -98,7 +101,7 @@ export default function MobileEventMembersPage() {
<MobileShell <MobileShell
activeTab="home" activeTab="home"
title={t('events.members.title', 'Guest Management')} title={t('events.members.title', 'Guest Management')}
onBack={() => navigate(-1)} onBack={back}
headerActions={ headerActions={
<HeaderActionButton onPress={() => load()} ariaLabel={t('common.refresh', 'Refresh')}> <HeaderActionButton onPress={() => load()} ariaLabel={t('common.refresh', 'Refresh')}>
<RefreshCcw size={18} color="#0f172a" /> <RefreshCcw size={18} color="#0f172a" />

View File

@@ -22,6 +22,8 @@ import { isAuthError } from '../auth/tokens';
import { getApiErrorMessage } from '../lib/apiError'; import { getApiErrorMessage } from '../lib/apiError';
import { formatEventDate, resolveEventDisplayName } from '../lib/events'; import { formatEventDate, resolveEventDisplayName } from '../lib/events';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { adminPath } from '../constants';
import { useBackNavigation } from './hooks/useBackNavigation';
export default function MobileEventPhotoboothPage() { export default function MobileEventPhotoboothPage() {
const { slug: slugParam } = useParams<{ slug?: string }>(); const { slug: slugParam } = useParams<{ slug?: string }>();
@@ -40,6 +42,7 @@ export default function MobileEventPhotoboothPage() {
const [loading, setLoading] = React.useState(true); const [loading, setLoading] = React.useState(true);
const [updating, setUpdating] = React.useState(false); const [updating, setUpdating] = React.useState(false);
const [error, setError] = React.useState<string | null>(null); const [error, setError] = React.useState<string | null>(null);
const back = useBackNavigation(slug ? adminPath(`/mobile/events/${slug}`) : adminPath('/mobile/events'));
const locale = i18n.language?.startsWith('en') ? 'en-GB' : 'de-DE'; const locale = i18n.language?.startsWith('en') ? 'en-GB' : 'de-DE';
@@ -165,7 +168,7 @@ export default function MobileEventPhotoboothPage() {
activeTab="home" activeTab="home"
title={title} title={title}
subtitle={subtitle ?? undefined} subtitle={subtitle ?? undefined}
onBack={() => navigate(-1)} onBack={back}
headerActions={ headerActions={
<HeaderActionButton onPress={() => load()} ariaLabel={t('common.refresh', 'Refresh')}> <HeaderActionButton onPress={() => load()} ariaLabel={t('common.refresh', 'Refresh')}>
<RefreshCcw size={18} color={text} /> <RefreshCcw size={18} color={text} />

View File

@@ -36,6 +36,7 @@ import { scopeDefaults, selectAddonKeyForScope } from './addons';
import { LegalConsentSheet } from './components/LegalConsentSheet'; import { LegalConsentSheet } from './components/LegalConsentSheet';
import { triggerHaptic } from './lib/haptics'; import { triggerHaptic } from './lib/haptics';
import { useOnlineStatus } from './hooks/useOnlineStatus'; import { useOnlineStatus } from './hooks/useOnlineStatus';
import { useBackNavigation } from './hooks/useBackNavigation';
import { import {
enqueuePhotoAction, enqueuePhotoAction,
loadPhotoQueue, loadPhotoQueue,
@@ -78,6 +79,7 @@ export default function MobileEventPhotosPage() {
const [catalogAddons, setCatalogAddons] = React.useState<EventAddonCatalogItem[]>([]); const [catalogAddons, setCatalogAddons] = React.useState<EventAddonCatalogItem[]>([]);
const [eventAddons, setEventAddons] = React.useState<EventAddonSummary[]>([]); const [eventAddons, setEventAddons] = React.useState<EventAddonSummary[]>([]);
const [busyScope, setBusyScope] = React.useState<string | null>(null); const [busyScope, setBusyScope] = React.useState<string | null>(null);
const back = useBackNavigation(slug ? adminPath(`/mobile/events/${slug}`) : adminPath('/mobile/events'));
const [consentOpen, setConsentOpen] = React.useState(false); const [consentOpen, setConsentOpen] = React.useState(false);
const [consentTarget, setConsentTarget] = React.useState<{ scope: 'photos' | 'gallery' | 'guests'; addonKey: string } | null>(null); const [consentTarget, setConsentTarget] = React.useState<{ scope: 'photos' | 'gallery' | 'guests'; addonKey: string } | null>(null);
const [consentBusy, setConsentBusy] = React.useState(false); const [consentBusy, setConsentBusy] = React.useState(false);
@@ -626,7 +628,7 @@ export default function MobileEventPhotosPage() {
<MobileShell <MobileShell
activeTab="uploads" activeTab="uploads"
title={t('mobilePhotos.title', 'Photo moderation')} title={t('mobilePhotos.title', 'Photo moderation')}
onBack={() => navigate(-1)} onBack={back}
headerActions={ headerActions={
<XStack space="$3"> <XStack space="$3">
<HeaderActionButton <HeaderActionButton

View File

@@ -27,6 +27,7 @@ import { MobileCard, CTAButton, PillBadge, SkeletonCard } from './components/Pri
import { adminPath } from '../constants'; import { adminPath } from '../constants';
import { selectAddonKeyForScope } from './addons'; import { selectAddonKeyForScope } from './addons';
import { LegalConsentSheet } from './components/LegalConsentSheet'; import { LegalConsentSheet } from './components/LegalConsentSheet';
import { useBackNavigation } from './hooks/useBackNavigation';
export default function MobileEventRecapPage() { export default function MobileEventRecapPage() {
const { slug } = useParams<{ slug?: string }>(); const { slug } = useParams<{ slug?: string }>();
@@ -46,6 +47,7 @@ export default function MobileEventRecapPage() {
const [consentOpen, setConsentOpen] = React.useState(false); const [consentOpen, setConsentOpen] = React.useState(false);
const [consentBusy, setConsentBusy] = React.useState(false); const [consentBusy, setConsentBusy] = React.useState(false);
const [consentAddonKey, setConsentAddonKey] = React.useState<string | null>(null); const [consentAddonKey, setConsentAddonKey] = React.useState<string | null>(null);
const back = useBackNavigation(slug ? adminPath(`/mobile/events/${slug}`) : adminPath('/mobile/events'));
const load = React.useCallback(async () => { const load = React.useCallback(async () => {
if (!slug) return; if (!slug) return;
@@ -88,7 +90,7 @@ export default function MobileEventRecapPage() {
if (!slug) { if (!slug) {
return ( return (
<MobileShell activeTab="home" title={t('events.errors.notFoundTitle', 'Event nicht gefunden')} onBack={() => navigate(-1)}> <MobileShell activeTab="home" title={t('events.errors.notFoundTitle', 'Event nicht gefunden')} onBack={back}>
<MobileCard> <MobileCard>
<Text color="#b91c1c">{t('events.errors.notFoundBody', 'Kehre zur Eventliste zurück und wähle dort ein Event aus.')}</Text> <Text color="#b91c1c">{t('events.errors.notFoundBody', 'Kehre zur Eventliste zurück und wähle dort ein Event aus.')}</Text>
</MobileCard> </MobileCard>
@@ -197,7 +199,7 @@ export default function MobileEventRecapPage() {
activeTab="home" activeTab="home"
title={event ? resolveName(event.name) : t('events.placeholders.untitled', 'Unbenanntes Event')} title={event ? resolveName(event.name) : t('events.placeholders.untitled', 'Unbenanntes Event')}
subtitle={event?.event_date ? formatDate(event.event_date) : undefined} subtitle={event?.event_date ? formatDate(event.event_date) : undefined}
onBack={() => navigate(-1)} onBack={back}
headerActions={ headerActions={
<HeaderActionButton onPress={() => load()} ariaLabel={t('common.refresh', 'Refresh')}> <HeaderActionButton onPress={() => load()} ariaLabel={t('common.refresh', 'Refresh')}>
<RefreshCcw size={18} color="#0f172a" /> <RefreshCcw size={18} color="#0f172a" />

View File

@@ -38,6 +38,7 @@ import { Tag } from './components/Tag';
import { useEventContext } from '../context/EventContext'; import { useEventContext } from '../context/EventContext';
import { useTheme } from '@tamagui/core'; import { useTheme } from '@tamagui/core';
import { RadioGroup } from '@tamagui/radio-group'; import { RadioGroup } from '@tamagui/radio-group';
import { useBackNavigation } from './hooks/useBackNavigation';
function InlineSeparator() { function InlineSeparator() {
const theme = useTheme(); const theme = useTheme();
@@ -88,6 +89,7 @@ export default function MobileEventTasksPage() {
const [emotionForm, setEmotionForm] = React.useState({ name: '', color: String(border) }); const [emotionForm, setEmotionForm] = React.useState({ name: '', color: String(border) });
const [savingEmotion, setSavingEmotion] = React.useState(false); const [savingEmotion, setSavingEmotion] = React.useState(false);
const [showEmotionFilterSheet, setShowEmotionFilterSheet] = React.useState(false); const [showEmotionFilterSheet, setShowEmotionFilterSheet] = React.useState(false);
const back = useBackNavigation(slug ? adminPath(`/mobile/events/${slug}`) : adminPath('/mobile/events'));
React.useEffect(() => { React.useEffect(() => {
if (slugParam && activeEvent?.slug !== slugParam) { if (slugParam && activeEvent?.slug !== slugParam) {
selectEvent(slugParam); selectEvent(slugParam);
@@ -355,7 +357,7 @@ export default function MobileEventTasksPage() {
<MobileShell <MobileShell
activeTab="tasks" activeTab="tasks"
title={t('events.tasks.title', 'Tasks & Checklists')} title={t('events.tasks.title', 'Tasks & Checklists')}
onBack={() => navigate(-1)} onBack={back}
headerActions={ headerActions={
<XStack space="$2"> <XStack space="$2">
<HeaderActionButton onPress={() => load()} ariaLabel={t('common.refresh', 'Refresh')}> <HeaderActionButton onPress={() => load()} ariaLabel={t('common.refresh', 'Refresh')}>

View File

@@ -13,6 +13,7 @@ import { adminPath } from '../constants';
import { isAuthError } from '../auth/tokens'; import { isAuthError } from '../auth/tokens';
import { getApiErrorMessage } from '../lib/apiError'; import { getApiErrorMessage } from '../lib/apiError';
import { useTheme } from '@tamagui/core'; import { useTheme } from '@tamagui/core';
import { useBackNavigation } from './hooks/useBackNavigation';
export default function MobileEventsPage() { export default function MobileEventsPage() {
const { t } = useTranslation('management'); const { t } = useTranslation('management');
@@ -22,6 +23,7 @@ export default function MobileEventsPage() {
const [error, setError] = React.useState<string | null>(null); const [error, setError] = React.useState<string | null>(null);
const [query, setQuery] = React.useState(''); const [query, setQuery] = React.useState('');
const searchRef = React.useRef<HTMLInputElement>(null); const searchRef = React.useRef<HTMLInputElement>(null);
const back = useBackNavigation();
const theme = useTheme(); const theme = useTheme();
const text = String(theme.color?.val ?? '#111827'); const text = String(theme.color?.val ?? '#111827');
const muted = String(theme.gray?.val ?? '#4b5563'); const muted = String(theme.gray?.val ?? '#4b5563');
@@ -48,7 +50,7 @@ export default function MobileEventsPage() {
<MobileShell <MobileShell
activeTab="home" activeTab="home"
title={t('events.list.dashboardTitle', 'All Events Dashboard')} title={t('events.list.dashboardTitle', 'All Events Dashboard')}
onBack={() => navigate(-1)} onBack={back}
headerActions={ headerActions={
<HeaderActionButton onPress={() => searchRef.current?.focus()} ariaLabel={t('events.list.search', 'Search events')}> <HeaderActionButton onPress={() => searchRef.current?.focus()} ariaLabel={t('events.list.search', 'Search events')}>
<Search size={18} color={text} /> <Search size={18} color={text} />

View File

@@ -18,6 +18,7 @@ import { getEvents, TenantEvent } from '../api';
import { useTheme } from '@tamagui/core'; import { useTheme } from '@tamagui/core';
import { triggerHaptic } from './lib/haptics'; import { triggerHaptic } from './lib/haptics';
import { adminPath } from '../constants'; import { adminPath } from '../constants';
import { useBackNavigation } from './hooks/useBackNavigation';
type NotificationItem = { type NotificationItem = {
id: string; id: string;
@@ -312,6 +313,7 @@ export default function MobileNotificationsPage() {
const [error, setError] = React.useState<string | null>(null); const [error, setError] = React.useState<string | null>(null);
const [events, setEvents] = React.useState<TenantEvent[]>([]); const [events, setEvents] = React.useState<TenantEvent[]>([]);
const [showEventPicker, setShowEventPicker] = React.useState(false); const [showEventPicker, setShowEventPicker] = React.useState(false);
const back = useBackNavigation(adminPath('/mobile/dashboard'));
const theme = useTheme(); const theme = useTheme();
const text = String(theme.color?.val ?? '#111827'); const text = String(theme.color?.val ?? '#111827');
const muted = String(theme.gray?.val ?? '#4b5563'); const muted = String(theme.gray?.val ?? '#4b5563');
@@ -451,7 +453,7 @@ export default function MobileNotificationsPage() {
<MobileShell <MobileShell
activeTab="home" activeTab="home"
title={t('mobileNotifications.title', 'Notifications')} title={t('mobileNotifications.title', 'Notifications')}
onBack={() => navigate(-1)} onBack={back}
headerActions={ headerActions={
<HeaderActionButton onPress={() => reload()} ariaLabel={t('common.refresh', 'Refresh')}> <HeaderActionButton onPress={() => reload()} ariaLabel={t('common.refresh', 'Refresh')}>
<RefreshCcw size={18} color={text} /> <RefreshCcw size={18} color={text} />

View File

@@ -16,6 +16,7 @@ import { fetchTenantProfile } from '../api';
import { adminPath } from '../constants'; import { adminPath } from '../constants';
import i18n from '../i18n'; import i18n from '../i18n';
import { useAppearance } from '@/hooks/use-appearance'; import { useAppearance } from '@/hooks/use-appearance';
import { useBackNavigation } from './hooks/useBackNavigation';
export default function MobileProfilePage() { export default function MobileProfilePage() {
const { user, logout } = useAuth(); const { user, logout } = useAuth();
@@ -28,6 +29,7 @@ export default function MobileProfilePage() {
const borderColor = String(theme.borderColor?.val ?? '#e5e7eb'); const borderColor = String(theme.borderColor?.val ?? '#e5e7eb');
const avatarBg = String(theme.surface?.val ?? '#e0f2fe'); const avatarBg = String(theme.surface?.val ?? '#e0f2fe');
const primary = String(theme.primary?.val ?? '#2563eb'); const primary = String(theme.primary?.val ?? '#2563eb');
const back = useBackNavigation(adminPath('/mobile/dashboard'));
const [name, setName] = React.useState(user?.name ?? t('events.members.roles.guest', 'Guest')); const [name, setName] = React.useState(user?.name ?? t('events.members.roles.guest', 'Guest'));
const [email, setEmail] = React.useState(user?.email ?? ''); const [email, setEmail] = React.useState(user?.email ?? '');
@@ -51,7 +53,7 @@ export default function MobileProfilePage() {
<MobileShell <MobileShell
activeTab="profile" activeTab="profile"
title={t('mobileProfile.title', 'Profile')} title={t('mobileProfile.title', 'Profile')}
onBack={() => navigate(-1)} onBack={back}
> >
<MobileCard space="$3" alignItems="center"> <MobileCard space="$3" alignItems="center">
<XStack <XStack

View File

@@ -32,6 +32,8 @@ import {
} from './invite-layout/export-utils'; } from './invite-layout/export-utils';
import { LayoutElement, CANVAS_WIDTH, CANVAS_HEIGHT } from './invite-layout/schema'; import { LayoutElement, CANVAS_WIDTH, CANVAS_HEIGHT } from './invite-layout/schema';
import { buildInitialTextFields } from './qr/utils'; import { buildInitialTextFields } from './qr/utils';
import { adminPath } from '../constants';
import { useBackNavigation } from './hooks/useBackNavigation';
type Step = 'background' | 'text' | 'preview'; type Step = 'background' | 'text' | 'preview';
@@ -69,6 +71,7 @@ export default function MobileQrLayoutCustomizePage() {
const [loading, setLoading] = React.useState(true); const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null); const [error, setError] = React.useState<string | null>(null);
const { fonts: tenantFonts } = useTenantFonts(); const { fonts: tenantFonts } = useTenantFonts();
const back = useBackNavigation(slug ? adminPath(`/mobile/events/${slug}/qr`) : adminPath('/mobile/events'));
const handleSelectPreset = React.useCallback((id: string | null) => { const handleSelectPreset = React.useCallback((id: string | null) => {
setBackgroundPreset(id); setBackgroundPreset(id);
@@ -181,7 +184,7 @@ export default function MobileQrLayoutCustomizePage() {
<MobileShell <MobileShell
activeTab="home" activeTab="home"
title={t('events.qr.customize', 'Layout anpassen')} title={t('events.qr.customize', 'Layout anpassen')}
onBack={() => navigate(-1)} onBack={back}
headerActions={ headerActions={
<HeaderActionButton onPress={() => window.location.reload()} ariaLabel={t('common.refresh', 'Refresh')}> <HeaderActionButton onPress={() => window.location.reload()} ariaLabel={t('common.refresh', 'Refresh')}>
<RefreshCcw size={18} color="#0f172a" /> <RefreshCcw size={18} color="#0f172a" />
@@ -201,7 +204,7 @@ export default function MobileQrLayoutCustomizePage() {
<MobileCard space="$2" marginTop="$2"> <MobileCard space="$2" marginTop="$2">
{step === 'background' && ( {step === 'background' && (
<BackgroundStep <BackgroundStep
onBack={() => navigate(-1)} onBack={back}
presets={BACKGROUND_PRESETS} presets={BACKGROUND_PRESETS}
selectedPreset={backgroundPreset} selectedPreset={backgroundPreset}
onSelectPreset={handleSelectPreset} onSelectPreset={handleSelectPreset}

View File

@@ -18,8 +18,9 @@ import {
import { isAuthError } from '../auth/tokens'; import { isAuthError } from '../auth/tokens';
import { getApiErrorMessage } from '../lib/apiError'; import { getApiErrorMessage } from '../lib/apiError';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { ADMIN_BASE_PATH } from '../constants'; import { ADMIN_BASE_PATH, adminPath } from '../constants';
import { resolveLayoutForFormat } from './qr/utils'; import { resolveLayoutForFormat } from './qr/utils';
import { useBackNavigation } from './hooks/useBackNavigation';
export default function MobileQrPrintPage() { export default function MobileQrPrintPage() {
const { slug: slugParam } = useParams<{ slug?: string }>(); const { slug: slugParam } = useParams<{ slug?: string }>();
@@ -35,6 +36,7 @@ export default function MobileQrPrintPage() {
const [loading, setLoading] = React.useState(true); const [loading, setLoading] = React.useState(true);
const [qrUrl, setQrUrl] = React.useState<string>(''); const [qrUrl, setQrUrl] = React.useState<string>('');
const [qrImage, setQrImage] = React.useState<string>(''); const [qrImage, setQrImage] = React.useState<string>('');
const back = useBackNavigation(slug ? adminPath(`/mobile/events/${slug}`) : adminPath('/mobile/events'));
React.useEffect(() => { React.useEffect(() => {
if (!slug) return; if (!slug) return;
@@ -73,7 +75,7 @@ export default function MobileQrPrintPage() {
<MobileShell <MobileShell
activeTab="home" activeTab="home"
title={t('events.qr.title', 'QR Code & Print Layouts')} title={t('events.qr.title', 'QR Code & Print Layouts')}
onBack={() => navigate(-1)} onBack={back}
headerActions={ headerActions={
<HeaderActionButton onPress={() => window.location.reload()} ariaLabel={t('common.refresh', 'Refresh')}> <HeaderActionButton onPress={() => window.location.reload()} ariaLabel={t('common.refresh', 'Refresh')}>
<RefreshCcw size={18} color="#0f172a" /> <RefreshCcw size={18} color="#0f172a" />

View File

@@ -25,6 +25,7 @@ import { useInstallPrompt } from './hooks/useInstallPrompt';
import { getInstallBannerDismissed, setInstallBannerDismissed, shouldShowInstallBanner } from './lib/installBanner'; import { getInstallBannerDismissed, setInstallBannerDismissed, shouldShowInstallBanner } from './lib/installBanner';
import { MobileInstallBanner } from './components/MobileInstallBanner'; import { MobileInstallBanner } from './components/MobileInstallBanner';
import { setTourSeen } from './lib/mobileTour'; import { setTourSeen } from './lib/mobileTour';
import { useBackNavigation } from './hooks/useBackNavigation';
type PreferenceKey = keyof NotificationPreferences; type PreferenceKey = keyof NotificationPreferences;
@@ -59,6 +60,7 @@ export default function MobileSettingsPage() {
const pushState = useAdminPushSubscription(); const pushState = useAdminPushSubscription();
const devicePermissions = useDevicePermissions(); const devicePermissions = useDevicePermissions();
const installPrompt = useInstallPrompt(); const installPrompt = useInstallPrompt();
const back = useBackNavigation(adminPath('/mobile/profile'));
const [installBannerDismissed, setInstallBannerDismissedState] = React.useState(() => getInstallBannerDismissed()); const [installBannerDismissed, setInstallBannerDismissedState] = React.useState(() => getInstallBannerDismissed());
const installBanner = shouldShowInstallBanner( const installBanner = shouldShowInstallBanner(
{ {
@@ -189,7 +191,7 @@ export default function MobileSettingsPage() {
}; };
return ( return (
<MobileShell activeTab="profile" title={t('mobileSettings.title', 'Settings')} onBack={() => navigate(-1)}> <MobileShell activeTab="profile" title={t('mobileSettings.title', 'Settings')} onBack={back}>
{error ? ( {error ? (
<MobileCard> <MobileCard>
<Text fontWeight="700" color="#b91c1c"> <Text fontWeight="700" color="#b91c1c">

View File

@@ -1,5 +1,5 @@
import React, { Suspense } from 'react'; import React, { Suspense } from 'react';
import { useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import { ChevronDown, ChevronLeft, Bell, QrCode } from 'lucide-react'; import { ChevronDown, ChevronLeft, Bell, QrCode } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks'; import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text'; import { SizableText as Text } from '@tamagui/text';
@@ -17,6 +17,7 @@ import { useOnlineStatus } from '../hooks/useOnlineStatus';
import { formatEventDate, resolveEventDisplayName } from '../../lib/events'; import { formatEventDate, resolveEventDisplayName } from '../../lib/events';
import { TenantEvent, getEvents } from '../../api'; import { TenantEvent, getEvents } from '../../api';
import { withAlpha } from './colors'; import { withAlpha } from './colors';
import { setTabHistory } from '../lib/tabHistory';
type MobileShellProps = { type MobileShellProps = {
title?: string; title?: string;
@@ -31,6 +32,7 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
const { events, activeEvent, hasMultipleEvents, hasEvents, selectEvent } = useEventContext(); const { events, activeEvent, hasMultipleEvents, hasEvents, selectEvent } = useEventContext();
const { go } = useMobileNav(activeEvent?.slug); const { go } = useMobileNav(activeEvent?.slug);
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const { t, i18n } = useTranslation('mobile'); const { t, i18n } = useTranslation('mobile');
const { count: notificationCount } = useNotificationsBadge(); const { count: notificationCount } = useNotificationsBadge();
const online = useOnlineStatus(); const online = useOnlineStatus();
@@ -81,6 +83,11 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
.finally(() => setLoadingEvents(false)); .finally(() => setLoadingEvents(false));
}, [pickerOpen, effectiveEvents.length]); }, [pickerOpen, effectiveEvents.length]);
React.useEffect(() => {
const path = `${location.pathname}${location.search}${location.hash}`;
setTabHistory(activeTab, path);
}, [activeTab, location.hash, location.pathname, location.search]);
const eventTitle = title ?? (effectiveActive ? resolveEventDisplayName(effectiveActive) : t('header.appName', 'Event Admin')); const eventTitle = title ?? (effectiveActive ? resolveEventDisplayName(effectiveActive) : t('header.appName', 'Event Admin'));
const subtitleText = const subtitleText =
subtitle ?? subtitle ??

View File

@@ -0,0 +1,113 @@
import React from 'react';
import { ChevronLeft } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite';
import { useTheme } from '@tamagui/core';
import { useTranslation } from 'react-i18next';
type OnboardingShellProps = {
eyebrow?: string;
title: string;
subtitle?: string;
children: React.ReactNode;
onBack?: () => void;
onSkip?: () => void;
footer?: React.ReactNode;
backLabel?: string;
skipLabel?: string;
};
export function OnboardingShell({
eyebrow,
title,
subtitle,
children,
onBack,
onSkip,
footer,
backLabel,
skipLabel,
}: OnboardingShellProps) {
const { t } = useTranslation('onboarding');
const theme = useTheme();
const background = String(theme.background?.val ?? '#f7f8fb');
const surface = String(theme.surface?.val ?? '#ffffff');
const text = String(theme.color?.val ?? '#111827');
const muted = String(theme.gray?.val ?? '#6b7280');
const border = String(theme.borderColor?.val ?? '#e5e7eb');
const resolvedBackLabel = backLabel ?? t('layout.back', 'Back');
const resolvedSkipLabel = skipLabel ?? t('layout.skip', 'Skip');
return (
<YStack minHeight="100vh" backgroundColor={background} alignItems="center">
<YStack
width="100%"
maxWidth={860}
paddingHorizontal="$5"
paddingTop="$5"
paddingBottom="$6"
space="$4"
style={{
paddingTop: 'calc(env(safe-area-inset-top, 0px) + 20px)',
paddingBottom: 'calc(env(safe-area-inset-bottom, 0px) + 24px)',
}}
>
<XStack alignItems="center" justifyContent="space-between">
{onBack ? (
<Pressable onPress={onBack} aria-label="Back">
<XStack alignItems="center" space="$1.5">
<ChevronLeft size={22} color={text} />
<Text fontSize="$sm" fontWeight="700" color={text}>
{resolvedBackLabel}
</Text>
</XStack>
</Pressable>
) : (
<XStack width={64} />
)}
{onSkip ? (
<Pressable onPress={onSkip} aria-label="Skip">
<Text fontSize="$sm" fontWeight="700" color={muted}>
{resolvedSkipLabel}
</Text>
</Pressable>
) : (
<XStack width={64} />
)}
</XStack>
<YStack
padding="$4"
borderRadius={20}
borderWidth={1}
borderColor={border}
backgroundColor={surface}
shadowColor="#0f172a"
shadowOpacity={0.06}
shadowRadius={14}
shadowOffset={{ width: 0, height: 8 }}
space="$2"
>
{eyebrow ? (
<Text fontSize="$xs" fontWeight="700" color={muted} textTransform="uppercase" letterSpacing={0.6}>
{eyebrow}
</Text>
) : null}
<Text fontSize="$xl" fontWeight="900" color={text}>
{title}
</Text>
{subtitle ? (
<Text fontSize="$sm" color={muted}>
{subtitle}
</Text>
) : null}
</YStack>
<YStack space="$4">{children}</YStack>
{footer ? <YStack marginTop="$2">{footer}</YStack> : null}
</YStack>
</YStack>
);
}

View File

@@ -0,0 +1,47 @@
import React from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render } from '@testing-library/react';
import { useBackNavigation } from './useBackNavigation';
const navigateMock = vi.fn();
vi.mock('react-router-dom', () => ({
useNavigate: () => navigateMock,
}));
function TestComponent({ fallback }: { fallback?: string }) {
const back = useBackNavigation(fallback);
return (
<button type="button" onClick={back}>
Back
</button>
);
}
describe('useBackNavigation', () => {
beforeEach(() => {
navigateMock.mockReset();
});
it('navigates back when history is available', () => {
Object.defineProperty(window.history, 'length', {
configurable: true,
get: () => 3,
});
const { getByText } = render(<TestComponent />);
fireEvent.click(getByText('Back'));
expect(navigateMock).toHaveBeenCalledWith(-1);
});
it('navigates to fallback when history is empty', () => {
Object.defineProperty(window.history, 'length', {
configurable: true,
get: () => 1,
});
const { getByText } = render(<TestComponent fallback="/event-admin/mobile/dashboard" />);
fireEvent.click(getByText('Back'));
expect(navigateMock).toHaveBeenCalledWith('/event-admin/mobile/dashboard', { replace: true });
});
});

View File

@@ -0,0 +1,17 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { ADMIN_HOME_PATH } from '../../constants';
export function useBackNavigation(fallback?: string) {
const navigate = useNavigate();
const fallbackTarget = fallback ?? ADMIN_HOME_PATH;
return React.useCallback(() => {
if (typeof window !== 'undefined' && window.history.length > 1) {
navigate(-1);
return;
}
navigate(fallbackTarget, { replace: true });
}, [fallbackTarget, navigate]);
}

View File

@@ -1,8 +1,8 @@
import React from 'react'; import React from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { adminPath } from '../../constants';
import { useEventContext } from '../../context/EventContext'; import { useEventContext } from '../../context/EventContext';
import { NavKey } from '../components/BottomNav'; import { NavKey } from '../components/BottomNav';
import { resolveTabTarget } from '../lib/tabHistory';
export function useMobileNav(currentSlug?: string | null) { export function useMobileNav(currentSlug?: string | null) {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -11,29 +11,8 @@ export function useMobileNav(currentSlug?: string | null) {
const go = React.useCallback( const go = React.useCallback(
(key: NavKey) => { (key: NavKey) => {
if (key === 'tasks') { const target = resolveTabTarget(key, slug);
if (slug) { navigate(target);
navigate(adminPath(`/mobile/events/${slug}/tasks`));
} else {
navigate(adminPath('/mobile/tasks'));
}
return;
}
if (key === 'uploads') {
if (slug) {
navigate(adminPath(`/mobile/events/${slug}/photos`));
} else {
navigate(adminPath('/mobile/uploads'));
}
return;
}
if (key === 'home') {
navigate(adminPath('/mobile/dashboard'));
return;
}
if (key === 'profile') {
navigate(adminPath('/mobile/profile'));
}
}, },
[navigate, slug] [navigate, slug]
); );

View File

@@ -0,0 +1,21 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { getSelectedPackageId, setSelectedPackageId } from './onboardingSelection';
describe('onboardingSelection', () => {
beforeEach(() => {
if (typeof window !== 'undefined') {
window.localStorage.clear();
}
});
it('stores and returns the selected package id', () => {
setSelectedPackageId(12);
expect(getSelectedPackageId()).toBe(12);
});
it('clears the selection when set to null', () => {
setSelectedPackageId(3);
setSelectedPackageId(null);
expect(getSelectedPackageId()).toBeNull();
});
});

View File

@@ -0,0 +1,34 @@
const STORAGE_KEY = 'admin-onboarding-package-v1';
export function getSelectedPackageId(): number | null {
if (typeof window === 'undefined') {
return null;
}
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) {
return null;
}
const value = Number(raw);
return Number.isFinite(value) ? value : null;
} catch {
return null;
}
}
export function setSelectedPackageId(id: number | null): void {
if (typeof window === 'undefined') {
return;
}
try {
if (!id) {
window.localStorage.removeItem(STORAGE_KEY);
return;
}
window.localStorage.setItem(STORAGE_KEY, String(id));
} catch {
// Ignore storage errors.
}
}

View File

@@ -0,0 +1,36 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { getTabHistory, resolveTabTarget, setTabHistory } from './tabHistory';
import { adminPath } from '../../constants';
describe('tabHistory', () => {
beforeEach(() => {
if (typeof window !== 'undefined') {
window.localStorage.clear();
}
});
it('stores tab history entries', () => {
setTabHistory('home', adminPath('/mobile/dashboard'));
setTabHistory('tasks', adminPath('/mobile/tasks'));
const history = getTabHistory();
expect(history.home).toBe(adminPath('/mobile/dashboard'));
expect(history.tasks).toBe(adminPath('/mobile/tasks'));
});
it('returns fallback when no history exists', () => {
const target = resolveTabTarget('uploads', null);
expect(target).toBe(adminPath('/mobile/uploads'));
});
it('reuses stored event route when slug matches', () => {
setTabHistory('uploads', adminPath('/mobile/events/summer/photos'));
const target = resolveTabTarget('uploads', 'summer');
expect(target).toBe(adminPath('/mobile/events/summer/photos'));
});
it('falls back to active slug when stored slug differs', () => {
setTabHistory('tasks', adminPath('/mobile/events/winter/tasks'));
const target = resolveTabTarget('tasks', 'summer');
expect(target).toBe(adminPath('/mobile/events/summer/tasks'));
});
});

View File

@@ -0,0 +1,92 @@
import { adminPath } from '../../constants';
import type { NavKey } from '../components/BottomNav';
const STORAGE_KEY = 'admin-mobile-tab-history-v1';
type TabHistory = Partial<Record<NavKey, string>>;
function readHistory(): TabHistory {
if (typeof window === 'undefined') {
return {};
}
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) {
return {};
}
const parsed = JSON.parse(raw) as TabHistory;
return parsed ?? {};
} catch {
return {};
}
}
function writeHistory(history: TabHistory): void {
if (typeof window === 'undefined') {
return;
}
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(history));
} catch {
// Ignore storage errors.
}
}
export function setTabHistory(key: NavKey, path: string): void {
const history = readHistory();
history[key] = path;
writeHistory(history);
}
export function getTabHistory(): TabHistory {
return readHistory();
}
function resolveDefaultTarget(key: NavKey, slug?: string | null): string {
if (key === 'tasks') {
return slug ? adminPath(`/mobile/events/${slug}/tasks`) : adminPath('/mobile/tasks');
}
if (key === 'uploads') {
return slug ? adminPath(`/mobile/events/${slug}/photos`) : adminPath('/mobile/uploads');
}
if (key === 'profile') {
return adminPath('/mobile/profile');
}
return adminPath('/mobile/dashboard');
}
function resolveEventScopedTarget(path: string, slug: string | null | undefined, key: NavKey): string {
if (!slug) {
return path;
}
if (key !== 'tasks' && key !== 'uploads') {
return path;
}
const match = path.match(/\/event-admin\/mobile\/events\/([^/]+)\/(tasks|photos)(?:\/.*)?$/);
if (!match) {
return resolveDefaultTarget(key, slug);
}
const storedSlug = match[1];
if (storedSlug === slug) {
return path;
}
return resolveDefaultTarget(key, slug);
}
export function resolveTabTarget(key: NavKey, slug?: string | null): string {
const history = readHistory();
const stored = history[key];
const fallback = resolveDefaultTarget(key, slug);
if (!stored) {
return fallback;
}
return resolveEventScopedTarget(stored, slug, key);
}

View File

@@ -18,5 +18,9 @@ export function prefetchMobileRoutes() {
void import('./NotificationsPage'); void import('./NotificationsPage');
void import('./ProfilePage'); void import('./ProfilePage');
void import('./SettingsPage'); void import('./SettingsPage');
void import('./welcome/WelcomeLandingPage');
void import('./welcome/WelcomePackagesPage');
void import('./welcome/WelcomeSummaryPage');
void import('./welcome/WelcomeEventPage');
}); });
} }

View File

@@ -0,0 +1,128 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { CalendarDays, Sparkles, Users } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { OnboardingShell } from '../components/OnboardingShell';
import { MobileCard, CTAButton } from '../components/Primitives';
import { ADMIN_HOME_PATH, ADMIN_WELCOME_BASE_PATH, ADMIN_WELCOME_PACKAGES_PATH, ADMIN_WELCOME_SUMMARY_PATH, adminPath } from '../../constants';
import { getTenantPackagesOverview } from '../../api';
import { getSelectedPackageId } from '../lib/onboardingSelection';
export default function WelcomeEventPage() {
const navigate = useNavigate();
const { t } = useTranslation('onboarding');
const selectedId = getSelectedPackageId();
const { data: overview } = useQuery({
queryKey: ['mobile', 'onboarding', 'packages-overview'],
queryFn: () => getTenantPackagesOverview({ force: true }),
staleTime: 60_000,
});
const hasActivePackage =
Boolean(overview?.activePackage) || Boolean(overview?.packages?.some((pkg) => pkg.active));
const backTarget = selectedId
? ADMIN_WELCOME_SUMMARY_PATH
: hasActivePackage
? ADMIN_WELCOME_BASE_PATH
: ADMIN_WELCOME_PACKAGES_PATH;
return (
<OnboardingShell
eyebrow={t('eventSetup.layout.eyebrow', 'Step 4')}
title={t('eventSetup.layout.title', 'Prepare your first event')}
subtitle={t(
'eventSetup.layout.subtitle',
'Fill in a few details, invite co-hosts, and open your guest gallery for the big day.',
)}
onBack={() => navigate(backTarget)}
onSkip={() => navigate(ADMIN_HOME_PATH)}
skipLabel={t('layout.jumpToDashboard', 'Jump to dashboard')}
>
<MobileCard space="$3">
<Text fontSize="$sm" fontWeight="800">
{t('eventSetup.step.title', 'Event setup in minutes')}
</Text>
<Text fontSize="$sm" color="#6b7280">
{t(
'eventSetup.step.description',
'We guide you through name, date, mood, and tasks. Afterwards you can moderate photos and support guests live.',
)}
</Text>
<YStack space="$2">
<FeatureRow
icon={Sparkles}
title={t('eventSetup.tiles.story.title', 'Story & mood')}
body={t('eventSetup.tiles.story.copy', 'Pick imagery, colours, and emotion cards for your event.')}
/>
<FeatureRow
icon={Users}
title={t('eventSetup.tiles.team.title', 'Organise your team')}
body={t('eventSetup.tiles.team.copy', 'Invite moderators or photographers and assign roles.')}
/>
<FeatureRow
icon={CalendarDays}
title={t('eventSetup.tiles.launch.title', 'Prepare go-live')}
body={t('eventSetup.tiles.launch.copy', 'Create QR codes, test the gallery, and align the run of show.')}
/>
</YStack>
</MobileCard>
<MobileCard space="$2">
<Text fontSize="$sm" fontWeight="800">
{t('eventSetup.cta.heading', 'Ready for your first event?')}
</Text>
<Text fontSize="$sm" color="#6b7280">
{t(
'eventSetup.cta.description',
"You're switching to the event manager. Assign tasks, invite members, and test the gallery. You can always return to the welcome journey.",
)}
</Text>
<CTAButton label={t('eventSetup.cta.button', 'Create event')} onPress={() => navigate(adminPath('/mobile/events/new'))} />
</MobileCard>
<YStack space="$2">
<CTAButton
label={t('eventSetup.actions.dashboard.button', 'Open dashboard')}
tone="ghost"
onPress={() => navigate(ADMIN_HOME_PATH)}
/>
<CTAButton
label={t('eventSetup.actions.events.button', 'Open event list')}
tone="ghost"
onPress={() => navigate(adminPath('/mobile/events'))}
/>
</YStack>
</OnboardingShell>
);
}
function FeatureRow({
icon: Icon,
title,
body,
}: {
icon: React.ComponentType<{ size?: number; color?: string }>;
title: string;
body: string;
}) {
return (
<XStack alignItems="center" space="$2">
<XStack width={34} height={34} borderRadius={12} backgroundColor="#e0f2fe" alignItems="center" justifyContent="center">
<Icon size={16} color="#0284c7" />
</XStack>
<YStack>
<Text fontSize="$sm" fontWeight="700">
{title}
</Text>
<Text fontSize="$xs" color="#6b7280">
{body}
</Text>
</YStack>
</XStack>
);
}

View File

@@ -0,0 +1,130 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { Image as ImageIcon, Sparkles, Users } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { MobileCard, CTAButton, PillBadge } from '../components/Primitives';
import { OnboardingShell } from '../components/OnboardingShell';
import { getTenantPackagesOverview } from '../../api';
import { useEventContext } from '../../context/EventContext';
import {
ADMIN_HOME_PATH,
ADMIN_WELCOME_EVENT_PATH,
ADMIN_WELCOME_PACKAGES_PATH,
adminPath,
} from '../../constants';
export default function WelcomeLandingPage() {
const navigate = useNavigate();
const { t } = useTranslation('onboarding');
const { hasEvents } = useEventContext();
const { data: packagesData } = useQuery({
queryKey: ['mobile', 'onboarding', 'packages-overview'],
queryFn: () => getTenantPackagesOverview({ force: true }),
staleTime: 60_000,
});
const hasActivePackage =
Boolean(packagesData?.activePackage) || Boolean(packagesData?.packages?.some((pkg) => pkg.active));
return (
<OnboardingShell
eyebrow={t('layout.eyebrow', 'Fotospiel Customer Admin')}
title={t('layout.title', 'Welcome to your event studio')}
subtitle={t(
'layout.subtitle',
'Begin with an inspired introduction, secure your package, and craft the perfect guest gallery all optimised for mobile hosts.',
)}
onSkip={() => navigate(ADMIN_HOME_PATH)}
skipLabel={t('layout.jumpToDashboard', 'Jump to dashboard')}
>
<MobileCard space="$3">
<PillBadge tone="muted">{t('hero.eyebrow', 'Your event, your stage')}</PillBadge>
<Text fontSize="$lg" fontWeight="900">
{t('hero.title', 'Design the next Fotospiel experience')}
</Text>
<Text fontSize="$sm" color="#6b7280">
{t(
'hero.description',
'In just a few steps you guide guests through a magical photo journey complete with storytelling, tasks, and a moderated gallery.',
)}
</Text>
<XStack space="$2" flexWrap="wrap">
<CTAButton
label={
hasActivePackage
? t('ctaList.createEvent.button', 'Go to event manager')
: t('hero.primary.button', 'Explore packages')
}
onPress={() => navigate(hasActivePackage ? ADMIN_WELCOME_EVENT_PATH : ADMIN_WELCOME_PACKAGES_PATH)}
fullWidth={false}
/>
{hasEvents ? (
<CTAButton
label={t('hero.secondary.button', 'View existing events')}
tone="ghost"
onPress={() => navigate(adminPath('/mobile/events'))}
fullWidth={false}
/>
) : null}
</XStack>
</MobileCard>
<YStack space="$3">
<FeatureCard
icon={ImageIcon}
title={t('highlights.gallery.title', 'Premium guest gallery')}
body={t('highlights.gallery.description', 'Curate photos in real time, highlight favourites, and share QR codes in a tap.')}
badge={t('highlights.gallery.badge', 'New')}
/>
<FeatureCard
icon={Users}
title={t('highlights.team.title', 'Flexible team onboarding')}
body={t('highlights.team.description', 'Invite co-hosts, assign roles, and stay on top of moderation and tasks.')}
/>
<FeatureCard
icon={Sparkles}
title={t('highlights.story.title', 'Storytelling in chapters')}
body={t(
'highlights.story.description',
'Guided tasks and emotion cards turn every event into a memorable journey.',
)}
/>
</YStack>
</OnboardingShell>
);
}
function FeatureCard({
icon: Icon,
title,
body,
badge,
}: {
icon: React.ComponentType<{ size?: number; color?: string }>;
title: string;
body: string;
badge?: string;
}) {
return (
<MobileCard space="$2">
<XStack alignItems="center" justifyContent="space-between">
<XStack alignItems="center" space="$2">
<XStack width={36} height={36} borderRadius={12} backgroundColor="#e0f2fe" alignItems="center" justifyContent="center">
<Icon size={18} color="#0284c7" />
</XStack>
<Text fontSize="$sm" fontWeight="800">
{title}
</Text>
</XStack>
{badge ? <PillBadge tone="muted">{badge}</PillBadge> : null}
</XStack>
<Text fontSize="$sm" color="#6b7280">
{body}
</Text>
</MobileCard>
);
}

View File

@@ -0,0 +1,180 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { Check, Package as PackageIcon } 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 { OnboardingShell } from '../components/OnboardingShell';
import { MobileCard, CTAButton, PillBadge } from '../components/Primitives';
import { getPackages, getTenantPackagesOverview, Package, trackOnboarding } from '../../api';
import { ADMIN_WELCOME_BASE_PATH, ADMIN_WELCOME_EVENT_PATH, ADMIN_WELCOME_SUMMARY_PATH, adminPath } from '../../constants';
import { getSelectedPackageId, setSelectedPackageId } from '../lib/onboardingSelection';
export default function WelcomePackagesPage() {
const navigate = useNavigate();
const { t } = useTranslation('onboarding');
const [selectedId, setSelectedId] = React.useState<number | null>(() => getSelectedPackageId());
const { data: overview } = useQuery({
queryKey: ['mobile', 'onboarding', 'packages-overview'],
queryFn: () => getTenantPackagesOverview({ force: true }),
staleTime: 60_000,
});
const { data: packages, isLoading, isError } = useQuery({
queryKey: ['mobile', 'onboarding', 'packages-list'],
queryFn: () => getPackages('endcustomer'),
staleTime: 60_000,
});
const hasActivePackage =
Boolean(overview?.activePackage) || Boolean(overview?.packages?.some((pkg) => pkg.active));
React.useEffect(() => {
if (!hasActivePackage) {
return;
}
setSelectedPackageId(null);
navigate(ADMIN_WELCOME_EVENT_PATH, { replace: true });
}, [hasActivePackage, navigate]);
const handleSelect = (pkg: Package) => {
setSelectedId(pkg.id);
setSelectedPackageId(pkg.id);
void trackOnboarding('package_selected', { package_id: pkg.id, package_name: pkg.name });
};
return (
<OnboardingShell
eyebrow={t('packages.layout.eyebrow', 'Step 2')}
title={t('packages.layout.title', 'Choose your package')}
subtitle={t(
'packages.layout.subtitle',
'Fotospiel supports flexible pricing: single-use event slots or subscriptions covering multiple events.',
)}
onBack={() => navigate(ADMIN_WELCOME_BASE_PATH)}
onSkip={() => navigate(adminPath('/mobile/billing#packages'))}
skipLabel={t('packages.cta.billing.button', 'Open billing')}
>
{isLoading ? (
<MobileCard>
<Text fontSize="$sm" color="#6b7280">
{t('packages.state.loading', 'Loading packages …')}
</Text>
</MobileCard>
) : isError ? (
<MobileCard>
<Text fontSize="$sm" fontWeight="700">
{t('packages.state.errorTitle', 'Failed to load')}
</Text>
<Text fontSize="$sm" color="#6b7280">
{t('packages.state.errorDescription', 'Please try again or contact support.')}
</Text>
</MobileCard>
) : (packages?.length ?? 0) === 0 ? (
<MobileCard>
<Text fontSize="$sm" fontWeight="700">
{t('packages.state.emptyTitle', 'Catalogue is empty')}
</Text>
<Text fontSize="$sm" color="#6b7280">
{t('packages.state.emptyDescription', 'No packages are currently available. Reach out to support to enable new offers.')}
</Text>
</MobileCard>
) : (
<YStack space="$3">
{packages?.map((pkg) => (
<PackageCard
key={pkg.id}
pkg={pkg}
selected={selectedId === pkg.id}
onSelect={() => handleSelect(pkg)}
/>
))}
</YStack>
)}
<MobileCard space="$2">
<Text fontSize="$sm" fontWeight="800">
{t('packages.step.title', 'Activate the right plan')}
</Text>
<Text fontSize="$sm" color="#6b7280">
{t('packages.step.description', 'Secure capacity for your next event. Upgrade at any time only pay for what you need.')}
</Text>
</MobileCard>
<XStack space="$2">
<CTAButton
label={t('packages.cta.summary.button', 'Continue to summary')}
onPress={() => navigate(ADMIN_WELCOME_SUMMARY_PATH)}
disabled={!selectedId}
fullWidth={false}
/>
<CTAButton
label={t('packages.cta.billing.button', 'Open billing')}
tone="ghost"
onPress={() => navigate(adminPath('/mobile/billing#packages'))}
fullWidth={false}
/>
</XStack>
</OnboardingShell>
);
}
function PackageCard({
pkg,
selected,
onSelect,
}: {
pkg: Package;
selected: boolean;
onSelect: () => void;
}) {
const { t } = useTranslation('onboarding');
const badges = [
t('packages.card.badges.photos', { count: pkg.max_photos ?? t('summary.details.infinity', '∞') }),
t('packages.card.badges.guests', { count: pkg.max_guests ?? t('summary.details.infinity', '∞') }),
t('packages.card.badges.days', { count: pkg.gallery_days ?? t('summary.details.infinity', '∞') }),
];
return (
<Pressable onPress={onSelect}>
<MobileCard borderColor={selected ? '#2563eb' : '#e5e7eb'} space="$2">
<XStack alignItems="center" justifyContent="space-between">
<XStack alignItems="center" space="$2">
<XStack width={36} height={36} borderRadius={12} backgroundColor="#e0f2fe" alignItems="center" justifyContent="center">
<PackageIcon size={18} color="#0ea5e9" />
</XStack>
<YStack>
<Text fontSize="$sm" fontWeight="800">
{pkg.name}
</Text>
<Text fontSize="$xs" color="#6b7280">
{t('packages.card.description', 'Ready for your next event right away.')}
</Text>
</YStack>
</XStack>
<PillBadge tone={selected ? 'success' : 'muted'}>
{selected ? t('packages.card.selected', 'Selected') : t('packages.card.select', 'Select package')}
</PillBadge>
</XStack>
<XStack flexWrap="wrap" space="$2">
{badges.map((badge) => (
<PillBadge key={badge} tone="muted">
{badge}
</PillBadge>
))}
</XStack>
{selected ? (
<XStack alignItems="center" space="$1">
<Check size={14} color="#2563eb" />
<Text fontSize="$xs" color="#2563eb" fontWeight="700">
{t('packages.card.selected', 'Selected')}
</Text>
</XStack>
) : null}
</MobileCard>
</Pressable>
);
}

View File

@@ -0,0 +1,205 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { CheckCircle2, Package as PackageIcon } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { OnboardingShell } from '../components/OnboardingShell';
import { MobileCard, CTAButton, PillBadge } from '../components/Primitives';
import { getPackages, getTenantPackagesOverview } from '../../api';
import { ADMIN_WELCOME_BASE_PATH, ADMIN_WELCOME_EVENT_PATH, ADMIN_WELCOME_PACKAGES_PATH, adminPath } from '../../constants';
import { getSelectedPackageId } from '../lib/onboardingSelection';
type SummaryPackage = {
id: number;
name: string;
max_photos: number | null;
max_guests: number | null;
gallery_days: number | null;
active: boolean;
remaining_events?: number | null;
};
export default function WelcomeSummaryPage() {
const navigate = useNavigate();
const { t } = useTranslation('onboarding');
const selectedId = getSelectedPackageId();
const { data: catalog, isLoading: catalogLoading } = useQuery({
queryKey: ['mobile', 'onboarding', 'packages-list'],
queryFn: () => getPackages('endcustomer'),
staleTime: 60_000,
});
const { data: overview, isLoading: overviewLoading } = useQuery({
queryKey: ['mobile', 'onboarding', 'packages-overview'],
queryFn: () => getTenantPackagesOverview({ force: true }),
staleTime: 60_000,
});
const selectedPackage = catalog?.find((pkg) => pkg.id === selectedId) ?? null;
const activePackage = overview?.activePackage ?? null;
const hasActivePackage =
Boolean(activePackage) || Boolean(overview?.packages?.some((pkg) => pkg.active));
const resolvedPackage: SummaryPackage | null = selectedPackage
? {
id: selectedPackage.id,
name: selectedPackage.name,
max_photos: selectedPackage.max_photos ?? null,
max_guests: selectedPackage.max_guests ?? null,
gallery_days: selectedPackage.gallery_days ?? null,
active: false,
}
: activePackage
? {
id: activePackage.id,
name: activePackage.package_name ?? 'Package',
max_photos: (activePackage.package_limits as any)?.max_photos ?? null,
max_guests: (activePackage.package_limits as any)?.max_guests ?? null,
gallery_days: (activePackage.package_limits as any)?.gallery_days ?? null,
active: true,
remaining_events: activePackage.remaining_events ?? null,
}
: null;
const loading = catalogLoading || overviewLoading;
const backTarget = selectedPackage ? ADMIN_WELCOME_PACKAGES_PATH : ADMIN_WELCOME_BASE_PATH;
return (
<OnboardingShell
eyebrow={t('summary.layout.eyebrow', 'Step 3')}
title={t('summary.layout.title', 'Order summary')}
subtitle={t('summary.layout.subtitle', 'Review package, price, and payment before proceeding to the event setup.')}
onBack={() => navigate(backTarget)}
onSkip={() => navigate(adminPath('/mobile/billing#packages'))}
skipLabel={t('summary.cta.billing.button', 'Go to billing')}
>
{loading ? (
<MobileCard>
<Text fontSize="$sm" color="#6b7280">
{t('summary.state.loading', 'Checking available packages …')}
</Text>
</MobileCard>
) : !resolvedPackage ? (
<MobileCard>
<Text fontSize="$sm" fontWeight="800">
{t('summary.state.missingTitle', 'No package selected')}
</Text>
<Text fontSize="$sm" color="#6b7280">
{t('summary.state.missingDescription', 'Select a package first or refresh if data changed.')}
</Text>
<CTAButton label={t('summary.footer.back', 'Back to package selection')} onPress={() => navigate(ADMIN_WELCOME_PACKAGES_PATH)} />
</MobileCard>
) : (
<MobileCard space="$3">
<XStack alignItems="center" justifyContent="space-between">
<XStack alignItems="center" space="$2">
<XStack width={36} height={36} borderRadius={12} backgroundColor="#e0f2fe" alignItems="center" justifyContent="center">
<PackageIcon size={18} color="#0ea5e9" />
</XStack>
<YStack>
<Text fontSize="$sm" fontWeight="800">
{resolvedPackage.name}
</Text>
<Text fontSize="$xs" color="#6b7280">
{resolvedPackage.active
? t('summary.details.section.statusActive', 'Already purchased')
: t('summary.details.section.statusInactive', 'Not purchased yet')}
</Text>
</YStack>
</XStack>
<PillBadge tone={resolvedPackage.active ? 'success' : 'muted'}>
{resolvedPackage.active ? t('summary.details.section.statusActive', 'Already purchased') : t('packages.card.select', 'Select package')}
</PillBadge>
</XStack>
<YStack space="$2">
<SummaryRow
label={t('summary.details.section.photosTitle', 'Photos & gallery')}
value={t('summary.details.section.photosValue', {
count: resolvedPackage.max_photos ?? t('summary.details.infinity', '∞'),
days: resolvedPackage.gallery_days ?? t('summary.details.infinity', '∞'),
})}
/>
<SummaryRow
label={t('summary.details.section.guestsTitle', 'Guests & team')}
value={t('summary.details.section.guestsValue', {
count: resolvedPackage.max_guests ?? t('summary.details.infinity', '∞'),
})}
/>
{resolvedPackage.remaining_events !== undefined && resolvedPackage.remaining_events !== null ? (
<SummaryRow
label={t('summary.details.section.statusTitle', 'Status')}
value={t('summary.details.section.statusActive', 'Already purchased')}
/>
) : null}
</YStack>
{resolvedPackage.active ? (
<XStack alignItems="center" space="$2">
<CheckCircle2 size={18} color="#22c55e" />
<Text fontSize="$sm" color="#16a34a" fontWeight="700">
{t('summary.details.section.statusActive', 'Already purchased')}
</Text>
</XStack>
) : null}
</MobileCard>
)}
<MobileCard space="$2">
<Text fontSize="$sm" fontWeight="800">
{t('summary.nextStepsTitle', 'Next steps')}
</Text>
<YStack space="$1">
{(t('summary.nextSteps', {
returnObjects: true,
defaultValue: [
'Optional: finish billing via Paddle inside the billing area.',
'Complete the event setup and configure tasks, team, and gallery.',
'Check your event slots before go-live and share your guest link.',
],
}) as string[]).map((item) => (
<XStack key={item} space="$2">
<Text fontSize="$xs" color="#6b7280">
</Text>
<Text fontSize="$sm" color="#6b7280">
{item}
</Text>
</XStack>
))}
</YStack>
</MobileCard>
<XStack space="$2">
<CTAButton
label={t('summary.cta.billing.button', 'Go to billing')}
tone="ghost"
onPress={() => navigate(adminPath('/mobile/billing#packages'))}
fullWidth={false}
/>
<CTAButton
label={t('summary.cta.setup.button', 'Continue to setup')}
onPress={() => navigate(ADMIN_WELCOME_EVENT_PATH)}
disabled={!resolvedPackage && !hasActivePackage}
fullWidth={false}
/>
</XStack>
</OnboardingShell>
);
}
function SummaryRow({ label, value }: { label: string; value: string }) {
return (
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$sm" color="#111827">
{label}
</Text>
<Text fontSize="$sm" color="#6b7280">
{value}
</Text>
</XStack>
);
}

View File

@@ -34,6 +34,10 @@ const MobileDashboardPage = React.lazy(() => import('./mobile/DashboardPage'));
const MobileTasksTabPage = React.lazy(() => import('./mobile/TasksTabPage')); const MobileTasksTabPage = React.lazy(() => import('./mobile/TasksTabPage'));
const MobileUploadsTabPage = React.lazy(() => import('./mobile/UploadsTabPage')); const MobileUploadsTabPage = React.lazy(() => import('./mobile/UploadsTabPage'));
const MobileAnimatedOutlet = React.lazy(() => import('./mobile/components/MobileAnimatedOutlet')); const MobileAnimatedOutlet = React.lazy(() => import('./mobile/components/MobileAnimatedOutlet'));
const MobileWelcomeLandingPage = React.lazy(() => import('./mobile/welcome/WelcomeLandingPage'));
const MobileWelcomePackagesPage = React.lazy(() => import('./mobile/welcome/WelcomePackagesPage'));
const MobileWelcomeSummaryPage = React.lazy(() => import('./mobile/welcome/WelcomeSummaryPage'));
const MobileWelcomeEventPage = React.lazy(() => import('./mobile/welcome/WelcomeEventPage'));
function RequireAuth() { function RequireAuth() {
const { status } = useAuth(); const { status } = useAuth();
@@ -146,6 +150,10 @@ export const router = createBrowserRouter([
{ path: 'mobile/dashboard', element: <MobileDashboardPage /> }, { path: 'mobile/dashboard', element: <MobileDashboardPage /> },
{ path: 'mobile/tasks', element: <MobileTasksTabPage /> }, { path: 'mobile/tasks', element: <MobileTasksTabPage /> },
{ path: 'mobile/uploads', element: <MobileUploadsTabPage /> }, { path: 'mobile/uploads', element: <MobileUploadsTabPage /> },
{ path: 'mobile/welcome', element: <MobileWelcomeLandingPage /> },
{ path: 'mobile/welcome/packages', element: <MobileWelcomePackagesPage /> },
{ path: 'mobile/welcome/summary', element: <MobileWelcomeSummaryPage /> },
{ path: 'mobile/welcome/event', element: <MobileWelcomeEventPage /> },
], ],
}, },
], ],