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

@@ -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>
);
}