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:
128
resources/js/admin/mobile/welcome/WelcomeEventPage.tsx
Normal file
128
resources/js/admin/mobile/welcome/WelcomeEventPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
130
resources/js/admin/mobile/welcome/WelcomeLandingPage.tsx
Normal file
130
resources/js/admin/mobile/welcome/WelcomeLandingPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
180
resources/js/admin/mobile/welcome/WelcomePackagesPage.tsx
Normal file
180
resources/js/admin/mobile/welcome/WelcomePackagesPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
205
resources/js/admin/mobile/welcome/WelcomeSummaryPage.tsx
Normal file
205
resources/js/admin/mobile/welcome/WelcomeSummaryPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user