diff --git a/resources/js/admin/i18n/locales/de/management.json b/resources/js/admin/i18n/locales/de/management.json index ecdb5af..eb72d5b 100644 --- a/resources/js/admin/i18n/locales/de/management.json +++ b/resources/js/admin/i18n/locales/de/management.json @@ -1920,6 +1920,7 @@ "emptyTitle": "Willkommen! Lass uns dein erstes Event starten", "emptyBody": "Drucke einen QR, sammle Uploads und moderiere in Minuten.", "ctaCreate": "Event erstellen", + "ctaWelcome": "Welcome-Guide starten", "emptyChecklistTitle": "Schnelle Schritte bis live", "emptyChecklistProgress": "{{done}}/{{total}} Schritte", "emptyStepDetails": "Name & Datum ergänzen", diff --git a/resources/js/admin/i18n/locales/de/onboarding.json b/resources/js/admin/i18n/locales/de/onboarding.json index b2611fd..e19ff89 100644 --- a/resources/js/admin/i18n/locales/de/onboarding.json +++ b/resources/js/admin/i18n/locales/de/onboarding.json @@ -3,6 +3,8 @@ "eyebrow": "Fotospiel Kunden-Admin", "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.", + "back": "Zurück", + "skip": "Überspringen", "alreadyFamiliar": "Schon vertraut mit Fotospiel?", "jumpToDashboard": "Direkt zum Dashboard" }, @@ -93,6 +95,7 @@ "description": "Sofort einsatzbereit für dein nächstes Event.", "descriptionWithPhotos": "Bis zu {{count}} Fotos inklusive – perfekt für lebendige Reportagen.", "active": "Aktives Paket", + "selected": "Ausgewählt", "select": "Paket wählen", "onRequest": "Auf Anfrage", "purchased": "Bereits gekauft am {{date}}", diff --git a/resources/js/admin/i18n/locales/en/management.json b/resources/js/admin/i18n/locales/en/management.json index b37b004..03b1c25 100644 --- a/resources/js/admin/i18n/locales/en/management.json +++ b/resources/js/admin/i18n/locales/en/management.json @@ -1940,6 +1940,7 @@ "emptyTitle": "Welcome! Let's launch your first event", "emptyBody": "Print a QR, collect uploads, and start moderating in minutes.", "ctaCreate": "Create event", + "ctaWelcome": "Start welcome journey", "emptyChecklistTitle": "Quick steps to go live", "emptyChecklistProgress": "{{done}}/{{total}} steps", "emptyStepDetails": "Add name & date", diff --git a/resources/js/admin/i18n/locales/en/onboarding.json b/resources/js/admin/i18n/locales/en/onboarding.json index 4b71405..613e140 100644 --- a/resources/js/admin/i18n/locales/en/onboarding.json +++ b/resources/js/admin/i18n/locales/en/onboarding.json @@ -3,6 +3,8 @@ "eyebrow": "Fotospiel Customer Admin", "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.", + "back": "Back", + "skip": "Skip", "alreadyFamiliar": "Already familiar with Fotospiel?", "jumpToDashboard": "Jump to dashboard" }, @@ -93,6 +95,7 @@ "description": "Ready for your next event right away.", "descriptionWithPhotos": "Up to {{count}} photos included – perfect for vibrant storytelling.", "active": "Active package", + "selected": "Selected", "select": "Select package", "onRequest": "On request", "purchased": "Purchased on {{date}}", diff --git a/resources/js/admin/mobile/BillingPage.tsx b/resources/js/admin/mobile/BillingPage.tsx index fed8554..a5ff926 100644 --- a/resources/js/admin/mobile/BillingPage.tsx +++ b/resources/js/admin/mobile/BillingPage.tsx @@ -17,8 +17,9 @@ import { } from '../api'; import { TenantAddonHistoryEntry, getTenantAddonHistory } from '../api'; 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 { useBackNavigation } from './hooks/useBackNavigation'; export default function MobileBillingPage() { const { t } = useTranslation('management'); @@ -34,6 +35,7 @@ export default function MobileBillingPage() { const packagesRef = React.useRef(null); const invoicesRef = React.useRef(null); const supportEmail = 'support@fotospiel.de'; + const back = useBackNavigation(adminPath('/mobile/profile')); const load = React.useCallback(async () => { setLoading(true); @@ -102,7 +104,7 @@ export default function MobileBillingPage() { navigate(-1)} + onBack={back} headerActions={ load()} ariaLabel={t('common.refresh', 'Refresh')}> diff --git a/resources/js/admin/mobile/BrandingPage.tsx b/resources/js/admin/mobile/BrandingPage.tsx index d3310a7..a9e6af8 100644 --- a/resources/js/admin/mobile/BrandingPage.tsx +++ b/resources/js/admin/mobile/BrandingPage.tsx @@ -13,6 +13,8 @@ import { ApiError, getApiErrorMessage } from '../lib/apiError'; import { isBrandingAllowed } from '../lib/events'; import { MobileSheet } from './components/Sheet'; import toast from 'react-hot-toast'; +import { adminPath } from '../constants'; +import { useBackNavigation } from './hooks/useBackNavigation'; type BrandingForm = { primary: string; @@ -78,6 +80,7 @@ export default function MobileBrandingPage() { const [error, setError] = React.useState(null); const [showFontsSheet, setShowFontsSheet] = React.useState(false); const [fontField, setFontField] = React.useState<'heading' | 'body'>('heading'); + const back = useBackNavigation(slug ? adminPath(`/mobile/events/${slug}`) : adminPath('/mobile/events')); const [fonts, setFonts] = React.useState([]); const [fontsLoading, setFontsLoading] = React.useState(false); const [fontsLoaded, setFontsLoaded] = React.useState(false); @@ -402,7 +405,7 @@ export default function MobileBrandingPage() { navigate(-1)} + onBack={back} headerActions={ handleSave()} ariaLabel={t('common.save', 'Save')}> diff --git a/resources/js/admin/mobile/DashboardPage.tsx b/resources/js/admin/mobile/DashboardPage.tsx index ce46503..3ac6c1a 100644 --- a/resources/js/admin/mobile/DashboardPage.tsx +++ b/resources/js/admin/mobile/DashboardPage.tsx @@ -9,7 +9,7 @@ import { Pressable } from '@tamagui/react-native-web-lite'; import { MobileShell, renderEventLocation } from './components/MobileShell'; import { MobileCard, CTAButton, KpiTile, ActionTile, PillBadge, SkeletonCard } from './components/Primitives'; import { MobileSheet } from './components/Sheet'; -import { adminPath } from '../constants'; +import { adminPath, ADMIN_WELCOME_BASE_PATH } from '../constants'; import { useEventContext } from '../context/EventContext'; import { getEventStats, EventStats, TenantEvent, getEvents } from '../api'; 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.')} navigate(adminPath('/mobile/events/new'))} /> + navigate(ADMIN_WELCOME_BASE_PATH)} + /> diff --git a/resources/js/admin/mobile/EventDetailPage.tsx b/resources/js/admin/mobile/EventDetailPage.tsx index f2507d8..123962d 100644 --- a/resources/js/admin/mobile/EventDetailPage.tsx +++ b/resources/js/admin/mobile/EventDetailPage.tsx @@ -15,6 +15,7 @@ import { MobileSheet } from './components/Sheet'; import { useEventContext } from '../context/EventContext'; import { formatEventDate, isBrandingAllowed, resolveEngagementMode, resolveEventDisplayName } from '../lib/events'; import { isPastEvent } from './eventDate'; +import { useBackNavigation } from './hooks/useBackNavigation'; export default function MobileEventDetailPage() { const { slug: slugParam } = useParams<{ slug?: string }>(); @@ -29,6 +30,7 @@ export default function MobileEventDetailPage() { const [error, setError] = React.useState(null); const { events, activeEvent, selectEvent } = useEventContext(); const [showEventPicker, setShowEventPicker] = React.useState(false); + const back = useBackNavigation(adminPath('/mobile/events')); React.useEffect(() => { if (!slug) return; @@ -99,7 +101,7 @@ export default function MobileEventDetailPage() { ? formatDate(event?.event_date ?? activeEvent?.event_date, t) : undefined } - onBack={() => navigate(-1)} + onBack={back} headerActions={ navigate(adminPath('/mobile/settings'))} ariaLabel={t('mobileSettings.title', 'Settings')}> diff --git a/resources/js/admin/mobile/EventFormPage.tsx b/resources/js/admin/mobile/EventFormPage.tsx index e631f03..a2acfee 100644 --- a/resources/js/admin/mobile/EventFormPage.tsx +++ b/resources/js/admin/mobile/EventFormPage.tsx @@ -15,6 +15,7 @@ import { adminPath } from '../constants'; import { isAuthError } from '../auth/tokens'; import { getApiValidationMessage, isApiError } from '../lib/apiError'; import toast from 'react-hot-toast'; +import { useBackNavigation } from './hooks/useBackNavigation'; type FormState = { name: string; @@ -52,6 +53,7 @@ export default function MobileEventFormPage() { const [consentBusy, setConsentBusy] = React.useState(false); const [pendingPayload, setPendingPayload] = React.useState[0] | null>(null); const [error, setError] = React.useState(null); + const back = useBackNavigation(slug ? adminPath(`/mobile/events/${slug}`) : adminPath('/mobile/events')); React.useEffect(() => { if (!slug) return; @@ -198,7 +200,7 @@ export default function MobileEventFormPage() { navigate(-1)} + onBack={back} > {error ? ( @@ -357,7 +359,7 @@ export default function MobileEventFormPage() { {!isEdit ? ( + ); +} + +describe('useBackNavigation', () => { + beforeEach(() => { + navigateMock.mockReset(); + }); + + it('navigates back when history is available', () => { + Object.defineProperty(window.history, 'length', { + configurable: true, + get: () => 3, + }); + + const { getByText } = render(); + 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(); + fireEvent.click(getByText('Back')); + expect(navigateMock).toHaveBeenCalledWith('/event-admin/mobile/dashboard', { replace: true }); + }); +}); diff --git a/resources/js/admin/mobile/hooks/useBackNavigation.ts b/resources/js/admin/mobile/hooks/useBackNavigation.ts new file mode 100644 index 0000000..251c00b --- /dev/null +++ b/resources/js/admin/mobile/hooks/useBackNavigation.ts @@ -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]); +} diff --git a/resources/js/admin/mobile/hooks/useMobileNav.ts b/resources/js/admin/mobile/hooks/useMobileNav.ts index 6970b9d..f642f81 100644 --- a/resources/js/admin/mobile/hooks/useMobileNav.ts +++ b/resources/js/admin/mobile/hooks/useMobileNav.ts @@ -1,8 +1,8 @@ import React from 'react'; import { useNavigate } from 'react-router-dom'; -import { adminPath } from '../../constants'; import { useEventContext } from '../../context/EventContext'; import { NavKey } from '../components/BottomNav'; +import { resolveTabTarget } from '../lib/tabHistory'; export function useMobileNav(currentSlug?: string | null) { const navigate = useNavigate(); @@ -11,29 +11,8 @@ export function useMobileNav(currentSlug?: string | null) { const go = React.useCallback( (key: NavKey) => { - if (key === 'tasks') { - if (slug) { - 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')); - } + const target = resolveTabTarget(key, slug); + navigate(target); }, [navigate, slug] ); diff --git a/resources/js/admin/mobile/lib/onboardingSelection.test.ts b/resources/js/admin/mobile/lib/onboardingSelection.test.ts new file mode 100644 index 0000000..96b2ad6 --- /dev/null +++ b/resources/js/admin/mobile/lib/onboardingSelection.test.ts @@ -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(); + }); +}); diff --git a/resources/js/admin/mobile/lib/onboardingSelection.ts b/resources/js/admin/mobile/lib/onboardingSelection.ts new file mode 100644 index 0000000..8454c3f --- /dev/null +++ b/resources/js/admin/mobile/lib/onboardingSelection.ts @@ -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. + } +} diff --git a/resources/js/admin/mobile/lib/tabHistory.test.ts b/resources/js/admin/mobile/lib/tabHistory.test.ts new file mode 100644 index 0000000..7878fb7 --- /dev/null +++ b/resources/js/admin/mobile/lib/tabHistory.test.ts @@ -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')); + }); +}); diff --git a/resources/js/admin/mobile/lib/tabHistory.ts b/resources/js/admin/mobile/lib/tabHistory.ts new file mode 100644 index 0000000..3d85d27 --- /dev/null +++ b/resources/js/admin/mobile/lib/tabHistory.ts @@ -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>; + +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); +} diff --git a/resources/js/admin/mobile/prefetch.ts b/resources/js/admin/mobile/prefetch.ts index 427e74d..68d7fab 100644 --- a/resources/js/admin/mobile/prefetch.ts +++ b/resources/js/admin/mobile/prefetch.ts @@ -18,5 +18,9 @@ export function prefetchMobileRoutes() { void import('./NotificationsPage'); void import('./ProfilePage'); void import('./SettingsPage'); + void import('./welcome/WelcomeLandingPage'); + void import('./welcome/WelcomePackagesPage'); + void import('./welcome/WelcomeSummaryPage'); + void import('./welcome/WelcomeEventPage'); }); } diff --git a/resources/js/admin/mobile/welcome/WelcomeEventPage.tsx b/resources/js/admin/mobile/welcome/WelcomeEventPage.tsx new file mode 100644 index 0000000..95b51f5 --- /dev/null +++ b/resources/js/admin/mobile/welcome/WelcomeEventPage.tsx @@ -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 ( + navigate(backTarget)} + onSkip={() => navigate(ADMIN_HOME_PATH)} + skipLabel={t('layout.jumpToDashboard', 'Jump to dashboard')} + > + + + {t('eventSetup.step.title', 'Event setup in minutes')} + + + {t( + 'eventSetup.step.description', + 'We guide you through name, date, mood, and tasks. Afterwards you can moderate photos and support guests live.', + )} + + + + + + + + + + + {t('eventSetup.cta.heading', 'Ready for your first event?')} + + + {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.", + )} + + navigate(adminPath('/mobile/events/new'))} /> + + + + navigate(ADMIN_HOME_PATH)} + /> + navigate(adminPath('/mobile/events'))} + /> + + + ); +} + +function FeatureRow({ + icon: Icon, + title, + body, +}: { + icon: React.ComponentType<{ size?: number; color?: string }>; + title: string; + body: string; +}) { + return ( + + + + + + + {title} + + + {body} + + + + ); +} diff --git a/resources/js/admin/mobile/welcome/WelcomeLandingPage.tsx b/resources/js/admin/mobile/welcome/WelcomeLandingPage.tsx new file mode 100644 index 0000000..f0ec81a --- /dev/null +++ b/resources/js/admin/mobile/welcome/WelcomeLandingPage.tsx @@ -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 ( + navigate(ADMIN_HOME_PATH)} + skipLabel={t('layout.jumpToDashboard', 'Jump to dashboard')} + > + + {t('hero.eyebrow', 'Your event, your stage')} + + {t('hero.title', 'Design the next Fotospiel experience')} + + + {t( + 'hero.description', + 'In just a few steps you guide guests through a magical photo journey – complete with storytelling, tasks, and a moderated gallery.', + )} + + + navigate(hasActivePackage ? ADMIN_WELCOME_EVENT_PATH : ADMIN_WELCOME_PACKAGES_PATH)} + fullWidth={false} + /> + {hasEvents ? ( + navigate(adminPath('/mobile/events'))} + fullWidth={false} + /> + ) : null} + + + + + + + + + + ); +} + +function FeatureCard({ + icon: Icon, + title, + body, + badge, +}: { + icon: React.ComponentType<{ size?: number; color?: string }>; + title: string; + body: string; + badge?: string; +}) { + return ( + + + + + + + + {title} + + + {badge ? {badge} : null} + + + {body} + + + ); +} diff --git a/resources/js/admin/mobile/welcome/WelcomePackagesPage.tsx b/resources/js/admin/mobile/welcome/WelcomePackagesPage.tsx new file mode 100644 index 0000000..4904ac3 --- /dev/null +++ b/resources/js/admin/mobile/welcome/WelcomePackagesPage.tsx @@ -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(() => 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 ( + navigate(ADMIN_WELCOME_BASE_PATH)} + onSkip={() => navigate(adminPath('/mobile/billing#packages'))} + skipLabel={t('packages.cta.billing.button', 'Open billing')} + > + {isLoading ? ( + + + {t('packages.state.loading', 'Loading packages …')} + + + ) : isError ? ( + + + {t('packages.state.errorTitle', 'Failed to load')} + + + {t('packages.state.errorDescription', 'Please try again or contact support.')} + + + ) : (packages?.length ?? 0) === 0 ? ( + + + {t('packages.state.emptyTitle', 'Catalogue is empty')} + + + {t('packages.state.emptyDescription', 'No packages are currently available. Reach out to support to enable new offers.')} + + + ) : ( + + {packages?.map((pkg) => ( + handleSelect(pkg)} + /> + ))} + + )} + + + + {t('packages.step.title', 'Activate the right plan')} + + + {t('packages.step.description', 'Secure capacity for your next event. Upgrade at any time – only pay for what you need.')} + + + + + navigate(ADMIN_WELCOME_SUMMARY_PATH)} + disabled={!selectedId} + fullWidth={false} + /> + navigate(adminPath('/mobile/billing#packages'))} + fullWidth={false} + /> + + + ); +} + +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 ( + + + + + + + + + + {pkg.name} + + + {t('packages.card.description', 'Ready for your next event right away.')} + + + + + {selected ? t('packages.card.selected', 'Selected') : t('packages.card.select', 'Select package')} + + + + {badges.map((badge) => ( + + {badge} + + ))} + + {selected ? ( + + + + {t('packages.card.selected', 'Selected')} + + + ) : null} + + + ); +} diff --git a/resources/js/admin/mobile/welcome/WelcomeSummaryPage.tsx b/resources/js/admin/mobile/welcome/WelcomeSummaryPage.tsx new file mode 100644 index 0000000..b94ebf4 --- /dev/null +++ b/resources/js/admin/mobile/welcome/WelcomeSummaryPage.tsx @@ -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 ( + navigate(backTarget)} + onSkip={() => navigate(adminPath('/mobile/billing#packages'))} + skipLabel={t('summary.cta.billing.button', 'Go to billing')} + > + {loading ? ( + + + {t('summary.state.loading', 'Checking available packages …')} + + + ) : !resolvedPackage ? ( + + + {t('summary.state.missingTitle', 'No package selected')} + + + {t('summary.state.missingDescription', 'Select a package first or refresh if data changed.')} + + navigate(ADMIN_WELCOME_PACKAGES_PATH)} /> + + ) : ( + + + + + + + + + {resolvedPackage.name} + + + {resolvedPackage.active + ? t('summary.details.section.statusActive', 'Already purchased') + : t('summary.details.section.statusInactive', 'Not purchased yet')} + + + + + {resolvedPackage.active ? t('summary.details.section.statusActive', 'Already purchased') : t('packages.card.select', 'Select package')} + + + + + + + {resolvedPackage.remaining_events !== undefined && resolvedPackage.remaining_events !== null ? ( + + ) : null} + + + {resolvedPackage.active ? ( + + + + {t('summary.details.section.statusActive', 'Already purchased')} + + + ) : null} + + )} + + + + {t('summary.nextStepsTitle', 'Next steps')} + + + {(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) => ( + + + • + + + {item} + + + ))} + + + + + navigate(adminPath('/mobile/billing#packages'))} + fullWidth={false} + /> + navigate(ADMIN_WELCOME_EVENT_PATH)} + disabled={!resolvedPackage && !hasActivePackage} + fullWidth={false} + /> + + + ); +} + +function SummaryRow({ label, value }: { label: string; value: string }) { + return ( + + + {label} + + + {value} + + + ); +} diff --git a/resources/js/admin/router.tsx b/resources/js/admin/router.tsx index 10b660a..f8ebf69 100644 --- a/resources/js/admin/router.tsx +++ b/resources/js/admin/router.tsx @@ -34,6 +34,10 @@ const MobileDashboardPage = React.lazy(() => import('./mobile/DashboardPage')); const MobileTasksTabPage = React.lazy(() => import('./mobile/TasksTabPage')); const MobileUploadsTabPage = React.lazy(() => import('./mobile/UploadsTabPage')); 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() { const { status } = useAuth(); @@ -146,6 +150,10 @@ export const router = createBrowserRouter([ { path: 'mobile/dashboard', element: }, { path: 'mobile/tasks', element: }, { path: 'mobile/uploads', element: }, + { path: 'mobile/welcome', element: }, + { path: 'mobile/welcome/packages', element: }, + { path: 'mobile/welcome/summary', element: }, + { path: 'mobile/welcome/event', element: }, ], }, ],