Admin package summary sheet

This commit is contained in:
Codex Agent
2026-01-06 11:57:30 +01:00
parent eba212a056
commit a796973861
13 changed files with 320 additions and 92 deletions

View File

@@ -34,6 +34,8 @@ class OnboardingController extends Controller
'admin_app_opened_at' => Arr::get($settings, 'onboarding.admin_app_opened_at'), 'admin_app_opened_at' => Arr::get($settings, 'onboarding.admin_app_opened_at'),
'primary_event_id' => Arr::get($settings, 'onboarding.primary_event_id'), 'primary_event_id' => Arr::get($settings, 'onboarding.primary_event_id'),
'selected_packages' => Arr::get($settings, 'onboarding.selected_packages'), 'selected_packages' => Arr::get($settings, 'onboarding.selected_packages'),
'summary_seen_package_id' => Arr::get($settings, 'onboarding.summary_seen_package_id'),
'summary_seen_at' => Arr::get($settings, 'onboarding.summary_seen_at'),
'dismissed_at' => Arr::get($settings, 'onboarding.dismissed_at'), 'dismissed_at' => Arr::get($settings, 'onboarding.dismissed_at'),
'completed_at' => Arr::get($settings, 'onboarding.completed_at'), 'completed_at' => Arr::get($settings, 'onboarding.completed_at'),
'branding_completed' => (bool) ($status['palette'] ?? false), 'branding_completed' => (bool) ($status['palette'] ?? false),
@@ -86,6 +88,11 @@ class OnboardingController extends Controller
Arr::set($settings, 'onboarding.invite_created_at', Carbon::now()->toIso8601String()); Arr::set($settings, 'onboarding.invite_created_at', Carbon::now()->toIso8601String());
break; break;
case 'summary_seen':
Arr::set($settings, 'onboarding.summary_seen_package_id', Arr::get($meta, 'package_id'));
Arr::set($settings, 'onboarding.summary_seen_at', Carbon::now()->toIso8601String());
break;
case 'dismissed': case 'dismissed':
Arr::set($settings, 'onboarding.dismissed_at', Carbon::now()->toIso8601String()); Arr::set($settings, 'onboarding.dismissed_at', Carbon::now()->toIso8601String());
break; break;

View File

@@ -330,6 +330,8 @@ export type TenantOnboardingStatus = {
admin_app_opened_at?: string | null; admin_app_opened_at?: string | null;
primary_event_id?: number | string | null; primary_event_id?: number | string | null;
selected_packages?: unknown; selected_packages?: unknown;
summary_seen_package_id?: number | null;
summary_seen_at?: string | null;
dismissed_at?: string | null; dismissed_at?: string | null;
completed_at?: string | null; completed_at?: string | null;
branding_completed?: boolean; branding_completed?: boolean;

View File

@@ -2061,6 +2061,23 @@
"storageUnprotected": "Nicht geschützt", "storageUnprotected": "Nicht geschützt",
"storageAction": "Jetzt schützen" "storageAction": "Jetzt schützen"
}, },
"packageSummary": {
"title": "Deine Paketübersicht",
"fallbackTitle": "Paketübersicht",
"subtitle": "Hier ist dein aktuelles Paket auf einen Blick.",
"limitPhotos": "Fotos",
"limitGuests": "Gäste",
"limitDays": "Galerietage",
"remaining": "Verbleibende Events",
"purchased": "Gekauft",
"expires": "Läuft ab",
"unlimited": "Unbegrenzt",
"unknown": "Unbekannt",
"featuresTitle": "Enthaltene Features",
"hint": "Im Billing kannst du dein Paket jederzeit prüfen oder upgraden.",
"continue": "Weiter zum Event-Setup",
"dismiss": "Schließen"
},
"pickEvent": "Event auswählen", "pickEvent": "Event auswählen",
"status": { "status": {
"published": "Live", "published": "Live",

View File

@@ -15,7 +15,8 @@
"description": "Mit nur wenigen Schritten führst du deine Gäste durch ein magisches Fotoabenteuer inklusive Storytelling, Aufgaben und moderierter Galerie.", "description": "Mit nur wenigen Schritten führst du deine Gäste durch ein magisches Fotoabenteuer inklusive Storytelling, Aufgaben und moderierter Galerie.",
"primary": { "primary": {
"label": "Pakete entdecken", "label": "Pakete entdecken",
"button": "Pakete entdecken" "button": "Pakete entdecken",
"billing": "Zum Billing"
}, },
"secondary": { "secondary": {
"label": "Events anzeigen", "label": "Events anzeigen",
@@ -246,7 +247,8 @@
"cta": { "cta": {
"heading": "Bereit für dein erstes Event?", "heading": "Bereit für dein erstes Event?",
"description": "Du wechselst jetzt in den Event-Manager. Dort kannst du Tasks zuweisen, Mitglieder einladen und die Gästegalerie testen. Keine Sorge: Du kannst jederzeit zur Welcome Journey zurückkehren.", "description": "Du wechselst jetzt in den Event-Manager. Dort kannst du Tasks zuweisen, Mitglieder einladen und die Gästegalerie testen. Keine Sorge: Du kannst jederzeit zur Welcome Journey zurückkehren.",
"button": "Event erstellen" "button": "Event erstellen",
"billing": "Zum Billing"
}, },
"actions": { "actions": {
"back": { "back": {

View File

@@ -2065,6 +2065,23 @@
"storageUnprotected": "Not protected", "storageUnprotected": "Not protected",
"storageAction": "Protect now" "storageAction": "Protect now"
}, },
"packageSummary": {
"title": "Your package summary",
"fallbackTitle": "Package summary",
"subtitle": "Here is a quick overview of your active package.",
"limitPhotos": "Photos",
"limitGuests": "Guests",
"limitDays": "Gallery days",
"remaining": "Remaining events",
"purchased": "Purchased",
"expires": "Expires",
"unlimited": "Unlimited",
"unknown": "Unknown",
"featuresTitle": "Included features",
"hint": "You can revisit billing any time to review or upgrade your package.",
"continue": "Continue to event setup",
"dismiss": "Close"
},
"pickEvent": "Select an event", "pickEvent": "Select an event",
"status": { "status": {
"published": "Live", "published": "Live",

View File

@@ -15,7 +15,8 @@
"description": "In just a few steps you guide guests through a magical photo journey complete with storytelling, tasks, and a moderated gallery.", "description": "In just a few steps you guide guests through a magical photo journey complete with storytelling, tasks, and a moderated gallery.",
"primary": { "primary": {
"label": "Explore packages", "label": "Explore packages",
"button": "Explore packages" "button": "Explore packages",
"billing": "Open billing"
}, },
"secondary": { "secondary": {
"label": "View events", "label": "View events",
@@ -246,7 +247,8 @@
"cta": { "cta": {
"heading": "Ready for your first event?", "heading": "Ready for your first event?",
"description": "You're switching to the event manager. Assign tasks, invite members, and test the gallery. You can always return to the welcome journey.", "description": "You're switching to the event manager. Assign tasks, invite members, and test the gallery. You can always return to the welcome journey.",
"button": "Create event" "button": "Create event",
"billing": "Open billing"
}, },
"actions": { "actions": {
"back": { "back": {

View File

@@ -11,7 +11,7 @@ import { MobileCard, CTAButton, KpiTile, ActionTile, PillBadge, SkeletonCard } f
import { MobileSheet } from './components/Sheet'; import { MobileSheet } from './components/Sheet';
import { adminPath, ADMIN_WELCOME_BASE_PATH } 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 { fetchOnboardingStatus, getEventStats, EventStats, TenantEvent, getEvents, getTenantPackagesOverview } from '../api';
import { formatEventDate, isBrandingAllowed, resolveEngagementMode, resolveEventDisplayName } from '../lib/events'; import { formatEventDate, isBrandingAllowed, resolveEngagementMode, resolveEventDisplayName } from '../lib/events';
import { useAdminPushSubscription } from './hooks/useAdminPushSubscription'; import { useAdminPushSubscription } from './hooks/useAdminPushSubscription';
import { useDevicePermissions } from './hooks/useDevicePermissions'; import { useDevicePermissions } from './hooks/useDevicePermissions';
@@ -39,6 +39,8 @@ export default function MobileDashboardPage() {
const [fallbackAttempted, setFallbackAttempted] = React.useState(false); const [fallbackAttempted, setFallbackAttempted] = React.useState(false);
const [tourOpen, setTourOpen] = React.useState(false); const [tourOpen, setTourOpen] = React.useState(false);
const [tourStep, setTourStep] = React.useState(0); const [tourStep, setTourStep] = React.useState(0);
const [summaryOpen, setSummaryOpen] = React.useState(false);
const [summarySeenOverride, setSummarySeenOverride] = React.useState<number | null>(null);
const onboardingTrackedRef = React.useRef(false); const onboardingTrackedRef = React.useRef(false);
const installPrompt = useInstallPrompt(); const installPrompt = useInstallPrompt();
const pushState = useAdminPushSubscription(); const pushState = useAdminPushSubscription();
@@ -64,6 +66,16 @@ export default function MobileDashboardPage() {
queryFn: () => getEvents({ force: true }), queryFn: () => getEvents({ force: true }),
staleTime: 60_000, staleTime: 60_000,
}); });
const { data: onboardingStatus, isLoading: onboardingLoading } = useQuery({
queryKey: ['mobile', 'onboarding', 'status'],
queryFn: fetchOnboardingStatus,
staleTime: 60_000,
});
const { data: packagesOverview, isLoading: packagesLoading, isError: packagesError } = useQuery({
queryKey: ['mobile', 'onboarding', 'packages-overview'],
queryFn: () => getTenantPackagesOverview({ force: true }),
staleTime: 60_000,
});
const effectiveEvents = events.length ? events : dashboardEvents?.length ? dashboardEvents : fallbackEvents; const effectiveEvents = events.length ? events : dashboardEvents?.length ? dashboardEvents : fallbackEvents;
const effectiveHasEvents = hasEvents || Boolean(dashboardEvents?.length) || fallbackEvents.length > 0; const effectiveHasEvents = hasEvents || Boolean(dashboardEvents?.length) || fallbackEvents.length > 0;
const effectiveMultiple = const effectiveMultiple =
@@ -115,6 +127,41 @@ export default function MobileDashboardPage() {
setTourOpen(true); setTourOpen(true);
}, [forceTour, location.pathname, navigate]); }, [forceTour, location.pathname, navigate]);
const activePackage =
packagesOverview?.activePackage ?? packagesOverview?.packages?.find((pkg) => pkg.active) ?? null;
const remainingEvents = activePackage?.remaining_events ?? null;
const summarySeenPackageId =
summarySeenOverride ?? onboardingStatus?.steps?.summary_seen_package_id ?? null;
const hasSummaryPackage =
Boolean(activePackage?.id && activePackage.id !== summarySeenPackageId);
const shouldRedirectToBilling =
!packagesLoading &&
!packagesError &&
!effectiveHasEvents &&
(activePackage === null || (remainingEvents !== null && remainingEvents <= 0));
React.useEffect(() => {
if (packagesLoading || !shouldRedirectToBilling) {
return;
}
navigate(adminPath('/mobile/billing#packages'), { replace: true });
}, [navigate, packagesLoading, shouldRedirectToBilling]);
React.useEffect(() => {
if (packagesLoading || packagesError || onboardingLoading) {
return;
}
if (shouldRedirectToBilling) {
return;
}
if (hasSummaryPackage) {
setSummaryOpen(true);
}
}, [hasSummaryPackage, onboardingLoading, packagesLoading, shouldRedirectToBilling]);
const markTourSeen = React.useCallback(() => { const markTourSeen = React.useCallback(() => {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return; return;
@@ -278,6 +325,32 @@ export default function MobileDashboardPage() {
</MobileSheet> </MobileSheet>
) : null; ) : null;
const handleSummaryClose = React.useCallback(() => {
setSummaryOpen(false);
if (activePackage?.id) {
setSummarySeenOverride(activePackage.id);
void trackOnboarding('summary_seen', { package_id: activePackage.id });
}
}, [activePackage?.id]);
const packageSummarySheet = activePackage ? (
<PackageSummarySheet
open={summaryOpen}
onClose={handleSummaryClose}
onContinue={() => {
handleSummaryClose();
navigate(adminPath('/mobile/events/new'));
}}
packageName={activePackage.package_name ?? t('mobileDashboard.packageSummary.fallbackTitle', 'Package summary')}
remainingEvents={remainingEvents}
purchasedAt={activePackage.purchased_at}
expiresAt={activePackage.expires_at}
limits={activePackage.package_limits ?? null}
features={activePackage.features ?? null}
locale={locale}
/>
) : null;
React.useEffect(() => { React.useEffect(() => {
if (events.length || isLoading || fallbackLoading || fallbackAttempted) { if (events.length || isLoading || fallbackLoading || fallbackAttempted) {
return; return;
@@ -306,6 +379,7 @@ export default function MobileDashboardPage() {
))} ))}
</YStack> </YStack>
{tourSheet} {tourSheet}
{packageSummarySheet}
</MobileShell> </MobileShell>
); );
} }
@@ -320,6 +394,7 @@ export default function MobileDashboardPage() {
onOpenSettings={() => navigate(adminPath('/mobile/settings'))} onOpenSettings={() => navigate(adminPath('/mobile/settings'))}
/> />
{tourSheet} {tourSheet}
{packageSummarySheet}
</MobileShell> </MobileShell>
); );
} }
@@ -333,6 +408,7 @@ export default function MobileDashboardPage() {
> >
<EventPickerList events={effectiveEvents} locale={locale} text={text} muted={muted} border={border} /> <EventPickerList events={effectiveEvents} locale={locale} text={text} muted={muted} border={border} />
{tourSheet} {tourSheet}
{packageSummarySheet}
</MobileShell> </MobileShell>
); );
} }
@@ -374,10 +450,125 @@ export default function MobileDashboardPage() {
<AlertsAndHints event={activeEvent} stats={stats} tasksEnabled={tasksEnabled} /> <AlertsAndHints event={activeEvent} stats={stats} tasksEnabled={tasksEnabled} />
{tourSheet} {tourSheet}
{packageSummarySheet}
</MobileShell> </MobileShell>
); );
} }
function PackageSummarySheet({
open,
onClose,
onContinue,
packageName,
remainingEvents,
purchasedAt,
expiresAt,
limits,
features,
locale,
}: {
open: boolean;
onClose: () => void;
onContinue: () => void;
packageName: string;
remainingEvents: number | null | undefined;
purchasedAt: string | null | undefined;
expiresAt: string | null | undefined;
limits: Record<string, unknown> | null;
features: string[] | null;
locale: string;
}) {
const { t } = useTranslation('management');
const { textStrong, muted, border, surface, accentSoft, primary } = useAdminTheme();
const text = textStrong;
const maxPhotos = (limits as Record<string, number | null> | null)?.max_photos ?? null;
const maxGuests = (limits as Record<string, number | null> | null)?.max_guests ?? null;
const galleryDays = (limits as Record<string, number | null> | null)?.gallery_days ?? null;
const hasFeatures = Array.isArray(features) && features.length > 0;
const formatLimit = (value: number | null) =>
value === null || value === undefined
? t('mobileDashboard.packageSummary.unlimited', 'Unlimited')
: String(value);
const formatDate = (value?: string | null) => formatEventDate(value, locale) ?? t('mobileDashboard.packageSummary.unknown', 'Unknown');
return (
<MobileSheet open={open} title={t('mobileDashboard.packageSummary.title', 'Your package summary')} onClose={onClose}>
<YStack space="$3">
<MobileCard space="$2.5" borderColor={border} backgroundColor={surface}>
<YStack space="$1.5">
<Text fontSize="$sm" fontWeight="800" color={text}>
{packageName}
</Text>
<Text fontSize="$xs" color={muted}>
{t('mobileDashboard.packageSummary.subtitle', 'Here is a quick overview of your active package.')}
</Text>
</YStack>
<YStack space="$2" marginTop="$2">
<SummaryRow label={t('mobileDashboard.packageSummary.limitPhotos', 'Photos')} value={formatLimit(maxPhotos)} />
<SummaryRow label={t('mobileDashboard.packageSummary.limitGuests', 'Guests')} value={formatLimit(maxGuests)} />
<SummaryRow label={t('mobileDashboard.packageSummary.limitDays', 'Gallery days')} value={formatLimit(galleryDays)} />
<SummaryRow
label={t('mobileDashboard.packageSummary.remaining', 'Remaining events')}
value={remainingEvents === null || remainingEvents === undefined ? t('mobileDashboard.packageSummary.unlimited', 'Unlimited') : String(remainingEvents)}
/>
<SummaryRow label={t('mobileDashboard.packageSummary.purchased', 'Purchased')} value={formatDate(purchasedAt)} />
{expiresAt ? (
<SummaryRow label={t('mobileDashboard.packageSummary.expires', 'Expires')} value={formatDate(expiresAt)} />
) : null}
</YStack>
</MobileCard>
{hasFeatures ? (
<MobileCard space="$2" borderColor={border} backgroundColor={surface}>
<Text fontSize="$sm" fontWeight="800" color={text}>
{t('mobileDashboard.packageSummary.featuresTitle', 'Included features')}
</Text>
<YStack space="$1.5" marginTop="$2">
{features?.map((feature) => (
<XStack key={feature} alignItems="center" space="$2">
<XStack width={24} height={24} borderRadius={8} backgroundColor={accentSoft} alignItems="center" justifyContent="center">
<Sparkles size={14} color={primary} />
</XStack>
<Text fontSize="$xs" color={text}>
{t(`mobileDashboard.packageSummary.feature.${feature}`, feature)}
</Text>
</XStack>
))}
</YStack>
</MobileCard>
) : null}
<MobileCard space="$2" borderColor={border} backgroundColor={surface}>
<Text fontSize="$xs" color={muted}>
{t('mobileDashboard.packageSummary.hint', 'You can revisit billing any time to review or upgrade your package.')}
</Text>
</MobileCard>
<XStack space="$2">
<CTAButton label={t('mobileDashboard.packageSummary.continue', 'Continue to event setup')} onPress={onContinue} fullWidth={false} />
<CTAButton label={t('mobileDashboard.packageSummary.dismiss', 'Close')} tone="ghost" onPress={onClose} fullWidth={false} />
</XStack>
</YStack>
</MobileSheet>
);
}
function SummaryRow({ label, value }: { label: string; value: string }) {
const { textStrong, muted } = useAdminTheme();
return (
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$xs" color={textStrong}>
{label}
</Text>
<Text fontSize="$xs" color={muted}>
{value}
</Text>
</XStack>
);
}
function DeviceSetupCard({ installPrompt, pushState, devicePermissions, onOpenSettings }: DeviceSetupProps) { function DeviceSetupCard({ installPrompt, pushState, devicePermissions, onOpenSettings }: DeviceSetupProps) {
const { t } = useTranslation('management'); const { t } = useTranslation('management');
const { textStrong, muted, border, primary, accentSoft } = useAdminTheme(); const { textStrong, muted, border, primary, accentSoft } = useAdminTheme();

View File

@@ -1,9 +1,7 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { resolveOnboardingRedirect } from './onboardingGuard'; import { resolveOnboardingRedirect } from './onboardingGuard';
import { import {
ADMIN_WELCOME_BASE_PATH, ADMIN_BILLING_PATH,
ADMIN_WELCOME_EVENT_PATH,
ADMIN_WELCOME_SUMMARY_PATH,
} from '../../constants'; } from '../../constants';
describe('resolveOnboardingRedirect', () => { describe('resolveOnboardingRedirect', () => {
@@ -11,23 +9,8 @@ describe('resolveOnboardingRedirect', () => {
const result = resolveOnboardingRedirect({ const result = resolveOnboardingRedirect({
hasEvents: true, hasEvents: true,
hasActivePackage: false, hasActivePackage: false,
selectedPackageId: null, remainingEvents: null,
pathname: '/event-admin/mobile/dashboard', pathname: '/event-admin/mobile/dashboard',
isWelcomePath: false,
isBillingPath: false,
isOnboardingDismissed: false,
isOnboardingCompleted: false,
});
expect(result).toBeNull();
});
it('returns null for welcome paths', () => {
const result = resolveOnboardingRedirect({
hasEvents: false,
hasActivePackage: false,
selectedPackageId: null,
pathname: ADMIN_WELCOME_BASE_PATH,
isWelcomePath: true,
isBillingPath: false, isBillingPath: false,
isOnboardingDismissed: false, isOnboardingDismissed: false,
isOnboardingCompleted: false, isOnboardingCompleted: false,
@@ -39,9 +22,8 @@ describe('resolveOnboardingRedirect', () => {
const result = resolveOnboardingRedirect({ const result = resolveOnboardingRedirect({
hasEvents: false, hasEvents: false,
hasActivePackage: false, hasActivePackage: false,
selectedPackageId: null, remainingEvents: null,
pathname: '/event-admin/mobile/billing', pathname: ADMIN_BILLING_PATH,
isWelcomePath: false,
isBillingPath: true, isBillingPath: true,
isOnboardingDismissed: false, isOnboardingDismissed: false,
isOnboardingCompleted: false, isOnboardingCompleted: false,
@@ -53,9 +35,8 @@ describe('resolveOnboardingRedirect', () => {
const result = resolveOnboardingRedirect({ const result = resolveOnboardingRedirect({
hasEvents: false, hasEvents: false,
hasActivePackage: true, hasActivePackage: true,
selectedPackageId: null, remainingEvents: 1,
pathname: '/event-admin/mobile/events/new', pathname: '/event-admin/mobile/events/new',
isWelcomePath: false,
isBillingPath: false, isBillingPath: false,
isOnboardingDismissed: false, isOnboardingDismissed: false,
isOnboardingCompleted: false, isOnboardingCompleted: false,
@@ -63,55 +44,51 @@ describe('resolveOnboardingRedirect', () => {
expect(result).toBeNull(); expect(result).toBeNull();
}); });
it('redirects to event setup when package active', () => { it('redirects to billing when no active package', () => {
const result = resolveOnboardingRedirect({
hasEvents: false,
hasActivePackage: false,
remainingEvents: null,
pathname: '/event-admin/mobile/dashboard',
isBillingPath: false,
isOnboardingDismissed: false,
isOnboardingCompleted: false,
});
expect(result).toBe(ADMIN_BILLING_PATH);
});
it('redirects to billing when no remaining events', () => {
const result = resolveOnboardingRedirect({ const result = resolveOnboardingRedirect({
hasEvents: false, hasEvents: false,
hasActivePackage: true, hasActivePackage: true,
selectedPackageId: null, remainingEvents: 0,
pathname: '/event-admin/mobile/dashboard', pathname: '/event-admin/mobile/dashboard',
isWelcomePath: false,
isBillingPath: false, isBillingPath: false,
isOnboardingDismissed: false, isOnboardingDismissed: false,
isOnboardingCompleted: false, isOnboardingCompleted: false,
}); });
expect(result).toBe(ADMIN_WELCOME_EVENT_PATH); expect(result).toBe(ADMIN_BILLING_PATH);
}); });
it('redirects to summary when selection exists', () => { it('returns null when remaining events are available', () => {
const result = resolveOnboardingRedirect({ const result = resolveOnboardingRedirect({
hasEvents: false, hasEvents: false,
hasActivePackage: false, hasActivePackage: true,
selectedPackageId: 5, remainingEvents: 2,
pathname: '/event-admin/mobile/dashboard', pathname: '/event-admin/mobile/dashboard',
isWelcomePath: false,
isBillingPath: false, isBillingPath: false,
isOnboardingDismissed: false, isOnboardingDismissed: false,
isOnboardingCompleted: false, isOnboardingCompleted: false,
}); });
expect(result).toBe(ADMIN_WELCOME_SUMMARY_PATH); expect(result).toBeNull();
}); });
it('redirects to landing when no selection exists', () => { it('returns null when remaining events are unlimited', () => {
const result = resolveOnboardingRedirect({ const result = resolveOnboardingRedirect({
hasEvents: false, hasEvents: false,
hasActivePackage: false, hasActivePackage: true,
selectedPackageId: null, remainingEvents: null,
pathname: '/event-admin/mobile/dashboard', pathname: '/event-admin/mobile/dashboard',
isWelcomePath: false,
isBillingPath: false,
isOnboardingDismissed: false,
isOnboardingCompleted: false,
});
expect(result).toBe(ADMIN_WELCOME_BASE_PATH);
});
it('does not redirect when already on target', () => {
const result = resolveOnboardingRedirect({
hasEvents: false,
hasActivePackage: false,
selectedPackageId: null,
pathname: ADMIN_WELCOME_BASE_PATH,
isWelcomePath: false,
isBillingPath: false, isBillingPath: false,
isOnboardingDismissed: false, isOnboardingDismissed: false,
isOnboardingCompleted: false, isOnboardingCompleted: false,
@@ -123,9 +100,8 @@ describe('resolveOnboardingRedirect', () => {
const result = resolveOnboardingRedirect({ const result = resolveOnboardingRedirect({
hasEvents: false, hasEvents: false,
hasActivePackage: false, hasActivePackage: false,
selectedPackageId: null, remainingEvents: null,
pathname: '/event-admin/mobile/dashboard', pathname: '/event-admin/mobile/dashboard',
isWelcomePath: false,
isBillingPath: false, isBillingPath: false,
isOnboardingDismissed: true, isOnboardingDismissed: true,
isOnboardingCompleted: false, isOnboardingCompleted: false,
@@ -137,11 +113,9 @@ describe('resolveOnboardingRedirect', () => {
const result = resolveOnboardingRedirect({ const result = resolveOnboardingRedirect({
hasEvents: false, hasEvents: false,
hasActivePackage: false, hasActivePackage: false,
selectedPackageId: null, remainingEvents: null,
pathname: '/event-admin/mobile/dashboard', pathname: '/event-admin/mobile/dashboard',
isWelcomePath: false,
isBillingPath: false, isBillingPath: false,
isOnboardingDismissed: false,
isOnboardingCompleted: true, isOnboardingCompleted: true,
}); });
expect(result).toBeNull(); expect(result).toBeNull();

View File

@@ -1,16 +1,13 @@
import { import {
ADMIN_EVENT_CREATE_PATH, ADMIN_EVENT_CREATE_PATH,
ADMIN_WELCOME_BASE_PATH, ADMIN_BILLING_PATH,
ADMIN_WELCOME_EVENT_PATH,
ADMIN_WELCOME_SUMMARY_PATH,
} from '../../constants'; } from '../../constants';
type OnboardingRedirectInput = { type OnboardingRedirectInput = {
hasEvents: boolean; hasEvents: boolean;
hasActivePackage: boolean; hasActivePackage: boolean;
selectedPackageId?: number | null; remainingEvents?: number | null;
pathname: string; pathname: string;
isWelcomePath: boolean;
isBillingPath: boolean; isBillingPath: boolean;
isOnboardingDismissed?: boolean; isOnboardingDismissed?: boolean;
isOnboardingCompleted?: boolean; isOnboardingCompleted?: boolean;
@@ -19,22 +16,17 @@ type OnboardingRedirectInput = {
export function resolveOnboardingRedirect({ export function resolveOnboardingRedirect({
hasEvents, hasEvents,
hasActivePackage, hasActivePackage,
selectedPackageId, remainingEvents,
pathname, pathname,
isWelcomePath,
isBillingPath, isBillingPath,
isOnboardingDismissed, isOnboardingDismissed,
isOnboardingCompleted, isOnboardingCompleted,
}: OnboardingRedirectInput): string | null { }: OnboardingRedirectInput): string | null {
if (hasEvents) {
return null;
}
if (isOnboardingDismissed || isOnboardingCompleted) { if (isOnboardingDismissed || isOnboardingCompleted) {
return null; return null;
} }
if (isWelcomePath || isBillingPath) { if (isBillingPath) {
return null; return null;
} }
@@ -42,16 +34,9 @@ export function resolveOnboardingRedirect({
return null; return null;
} }
const shouldContinueSummary = Boolean(selectedPackageId && selectedPackageId > 0); if (!hasEvents && (!hasActivePackage || (remainingEvents !== null && remainingEvents <= 0))) {
const target = hasActivePackage return ADMIN_BILLING_PATH;
? ADMIN_WELCOME_EVENT_PATH
: shouldContinueSummary
? ADMIN_WELCOME_SUMMARY_PATH
: ADMIN_WELCOME_BASE_PATH;
if (pathname === target) {
return null;
} }
return target; return null;
} }

View File

@@ -25,6 +25,8 @@ export default function WelcomeEventPage() {
const hasActivePackage = const hasActivePackage =
Boolean(overview?.activePackage) || Boolean(overview?.packages?.some((pkg) => pkg.active)); Boolean(overview?.activePackage) || Boolean(overview?.packages?.some((pkg) => pkg.active));
const remainingEvents = overview?.activePackage?.remaining_events ?? null;
const shouldGoBilling = !hasActivePackage || (remainingEvents !== null && remainingEvents <= 0);
const handleSkip = React.useCallback(() => { const handleSkip = React.useCallback(() => {
void trackOnboarding('dismissed'); void trackOnboarding('dismissed');
navigate(ADMIN_HOME_PATH); navigate(ADMIN_HOME_PATH);
@@ -87,7 +89,10 @@ export default function WelcomeEventPage() {
"You're switching to the event manager. Assign tasks, invite members, and test the gallery. You can always return to the welcome journey.", "You're switching to the event manager. Assign tasks, invite members, and test the gallery. You can always return to the welcome journey.",
)} )}
</Text> </Text>
<CTAButton label={t('eventSetup.cta.button', 'Create event')} onPress={() => navigate(adminPath('/mobile/events/new'))} /> <CTAButton
label={shouldGoBilling ? t('eventSetup.cta.billing', 'Open billing') : t('eventSetup.cta.button', 'Create event')}
onPress={() => navigate(shouldGoBilling ? adminPath('/mobile/billing#packages') : adminPath('/mobile/events/new'))}
/>
</MobileCard> </MobileCard>
<YStack space="$2"> <YStack space="$2">

View File

@@ -30,6 +30,8 @@ export default function WelcomeLandingPage() {
const hasActivePackage = const hasActivePackage =
Boolean(packagesData?.activePackage) || Boolean(packagesData?.packages?.some((pkg) => pkg.active)); Boolean(packagesData?.activePackage) || Boolean(packagesData?.packages?.some((pkg) => pkg.active));
const remainingEvents = packagesData?.activePackage?.remaining_events ?? null;
const shouldGoBilling = !hasActivePackage || (remainingEvents !== null && remainingEvents <= 0);
const handleSkip = React.useCallback(() => { const handleSkip = React.useCallback(() => {
void trackOnboarding('dismissed'); void trackOnboarding('dismissed');
navigate(ADMIN_HOME_PATH); navigate(ADMIN_HOME_PATH);
@@ -60,11 +62,11 @@ export default function WelcomeLandingPage() {
<XStack space="$2" flexWrap="wrap"> <XStack space="$2" flexWrap="wrap">
<CTAButton <CTAButton
label={ label={
hasActivePackage shouldGoBilling
? t('ctaList.createEvent.button', 'Go to event manager') ? t('hero.primary.billing', 'Open billing')
: t('hero.primary.button', 'Explore packages') : t('ctaList.createEvent.button', 'Go to event manager')
} }
onPress={() => navigate(hasActivePackage ? ADMIN_WELCOME_EVENT_PATH : ADMIN_WELCOME_PACKAGES_PATH)} onPress={() => navigate(shouldGoBilling ? adminPath('/mobile/billing#packages') : ADMIN_WELCOME_EVENT_PATH)}
fullWidth={false} fullWidth={false}
/> />
{hasEvents ? ( {hasEvents ? (

View File

@@ -15,7 +15,6 @@ import {
ADMIN_WELCOME_BASE_PATH, ADMIN_WELCOME_BASE_PATH,
} from './constants'; } from './constants';
import { fetchOnboardingStatus, getTenantPackagesOverview } from './api'; import { fetchOnboardingStatus, getTenantPackagesOverview } from './api';
import { getSelectedPackageId } from './mobile/lib/onboardingSelection';
import { resolveOnboardingRedirect } from './mobile/lib/onboardingGuard'; import { resolveOnboardingRedirect } from './mobile/lib/onboardingGuard';
const AuthCallbackPage = React.lazy(() => import('./mobile/AuthCallbackPage')); const AuthCallbackPage = React.lazy(() => import('./mobile/AuthCallbackPage'));
const LoginStartPage = React.lazy(() => import('./mobile/LoginStartPage')); const LoginStartPage = React.lazy(() => import('./mobile/LoginStartPage'));
@@ -56,7 +55,6 @@ function RequireAuth() {
const { status, user } = useAuth(); const { status, user } = useAuth();
const location = useLocation(); const location = useLocation();
const { hasEvents, isLoading: eventsLoading } = useEventContext(); const { hasEvents, isLoading: eventsLoading } = useEventContext();
const selectedPackageId = getSelectedPackageId();
const isWelcomePath = location.pathname.startsWith(ADMIN_WELCOME_BASE_PATH); const isWelcomePath = location.pathname.startsWith(ADMIN_WELCOME_BASE_PATH);
const isBillingPath = location.pathname.startsWith(ADMIN_BILLING_PATH); const isBillingPath = location.pathname.startsWith(ADMIN_BILLING_PATH);
const isTenantAdmin = Boolean(user && user.role !== 'member'); const isTenantAdmin = Boolean(user && user.role !== 'member');
@@ -79,6 +77,7 @@ function RequireAuth() {
const hasActivePackage = const hasActivePackage =
Boolean(packagesData?.activePackage) || Boolean(packagesData?.packages?.some((pkg) => pkg.active)); Boolean(packagesData?.activePackage) || Boolean(packagesData?.packages?.some((pkg) => pkg.active));
const remainingEvents = packagesData?.activePackage?.remaining_events ?? null;
const isOnboardingDismissed = Boolean(onboardingStatus?.steps?.dismissed_at); const isOnboardingDismissed = Boolean(onboardingStatus?.steps?.dismissed_at);
const isOnboardingCompleted = Boolean(onboardingStatus?.steps?.completed_at); const isOnboardingCompleted = Boolean(onboardingStatus?.steps?.completed_at);
const shouldBlockOnboarding = shouldCheckPackages && onboardingLoading; const shouldBlockOnboarding = shouldCheckPackages && onboardingLoading;
@@ -86,9 +85,8 @@ function RequireAuth() {
const redirectTarget = resolveOnboardingRedirect({ const redirectTarget = resolveOnboardingRedirect({
hasEvents, hasEvents,
hasActivePackage, hasActivePackage,
selectedPackageId, remainingEvents,
pathname: location.pathname, pathname: location.pathname,
isWelcomePath,
isBillingPath, isBillingPath,
isOnboardingDismissed, isOnboardingDismissed,
isOnboardingCompleted, isOnboardingCompleted,

View File

@@ -47,4 +47,30 @@ class OnboardingStatusTest extends TenantTestCase
$show->assertOk(); $show->assertOk();
$show->assertJsonPath('steps.completed_at', $completedAt); $show->assertJsonPath('steps.completed_at', $completedAt);
} }
public function test_tenant_can_mark_summary_seen(): void
{
$response = $this->authenticatedRequest('POST', '/api/v1/tenant/onboarding', [
'step' => 'summary_seen',
'meta' => [
'package_id' => 123,
],
]);
$response->assertOk();
$this->tenant->refresh();
$seenPackageId = Arr::get($this->tenant->settings ?? [], 'onboarding.summary_seen_package_id');
$seenAt = Arr::get($this->tenant->settings ?? [], 'onboarding.summary_seen_at');
$this->assertSame(123, $seenPackageId);
$this->assertNotNull($seenAt);
$show = $this->authenticatedRequest('GET', '/api/v1/tenant/onboarding');
$show->assertOk();
$show->assertJsonPath('steps.summary_seen_package_id', 123);
$show->assertJsonPath('steps.summary_seen_at', $seenAt);
}
} }