v2 onboarding: dedicated verify step and upgrade-free-trial as the last step (#22303)

https://github.com/user-attachments/assets/b1ee4f77-c6d7-4638-b9f1-dd801d1cc0db

Completes the onboarding-v2 flow: a dedicated verify step, the
reordering that makes the plan step come last, and the
upgrade-free-trial page itself.

## Verify step (`/verify-v2`)
After the cross-domain token exchange, v2 sign-ups land on a clean
`BlankLayout` "Verifying your email" screen (fading Twenty logo) instead
of the v1 `AuthModal` flashing over the background mock. The redirect
target is chosen from `isOnboardingV2` (read from the Jotai store at
redirect time). The pulsing logo is extracted into a shared
`OnboardingPulsingLogo`, reused by the workspace-activation loader.
`/verify-v2` joins the same exempt lists as `/verify` (ongoing-creation
guard, metadata gater, apollo unauthenticated handler, captcha, page
title) — intentionally not `useShowAuthModal`, which is what drops the
modal.

## Plan step is now last
`getOnboardingStatus` checks `PLAN_REQUIRED` after invite-team instead
of first, so onboarding runs workspace activation → email → profile →
invite → plan. This is what lets the upgrade step be reached as the
final step instead of gating right after sign-up. Applies to both v1 and
v2 (same order).

## Upgrade free trial page (`PlanRequiredV2` → `ChooseYourPlanV2` /
`UpgradeFreeTrial`)
The final step, full-screen under `BlankLayout` via
`OnboardingV2Layout`, matching the Figma (billing card with the Stripe
form, the "Basic / without credit card" option, trial + credits pills).
Reuses the v1 `ChooseYourPlanContent` billing logic
(`SubscriptionPaymentForm`, `useHandleCheckoutSession`). The "+N free
credits" reward comes from
`clientConfig.onboarding.upgradeCreditsReward` (sourced from
`BILLING_FREE_WORKFLOW_CREDITS_FOR_TRIAL_PERIOD_WITH_CREDIT_CARD`).

## Also
Fixes a latent staleness in the Apollo `onUnauthenticatedError` handler
— it captured `location` from the memoized client, now read via a ref —
so auth-path exemptions are correct after navigation.

Note: the onboarding step order change affects v1 too (plan becomes its
last step as well).
This commit is contained in:
Raphaël Bosi
2026-06-29 18:32:30 +02:00
committed by GitHub
parent 2bb7fb2e9b
commit facdbb5ba8
28 changed files with 701 additions and 51 deletions
@@ -539,4 +539,52 @@ describe('usePageChangeEffectNavigateLocation — onboarding V2', () => {
expect(usePageChangeEffectNavigateLocation()).toBeUndefined();
});
it('routes to PlanRequiredV2 from InviteTeamV2 when onboardingV2 is active and onboarding is completed', () => {
setupOnboardingV2Case(AppPath.InviteTeamV2, OnboardingStatus.COMPLETED);
expect(usePageChangeEffectNavigateLocation()).toEqual(
AppPath.PlanRequiredV2,
);
});
it('routes to PlanRequiredV2 from InviteTeamV2 when onboardingV2 is active and status is BOOK_ONBOARDING', () => {
setupOnboardingV2Case(
AppPath.InviteTeamV2,
OnboardingStatus.BOOK_ONBOARDING,
);
expect(usePageChangeEffectNavigateLocation()).toEqual(
AppPath.PlanRequiredV2,
);
});
it('does not redirect away from the PlanRequiredV2 page when onboarding is completed', () => {
setupOnboardingV2Case(AppPath.PlanRequiredV2, OnboardingStatus.COMPLETED);
expect(usePageChangeEffectNavigateLocation()).toBeUndefined();
});
it('lets completed v2 users route normally away from the invite transition', () => {
setupOnboardingV2Case(AppPath.Index, OnboardingStatus.COMPLETED);
expect(usePageChangeEffectNavigateLocation()).toEqual(defaultHomePagePath);
});
it('routes to PlanRequiredV2 (never the v1 plan page) when onboardingV2 is active and status is PLAN_REQUIRED', () => {
setupOnboardingV2Case(AppPath.Index, OnboardingStatus.PLAN_REQUIRED);
expect(usePageChangeEffectNavigateLocation()).toEqual(
AppPath.PlanRequiredV2,
);
});
it('does not redirect away from the PlanRequiredV2 page when a plan is required', () => {
setupOnboardingV2Case(
AppPath.PlanRequiredV2,
OnboardingStatus.PLAN_REQUIRED,
);
expect(usePageChangeEffectNavigateLocation()).toBeUndefined();
});
});
@@ -93,6 +93,7 @@ export const usePageChangeEffectNavigateLocation = () => {
onboardingStatus === OnboardingStatus.PLAN_REQUIRED &&
!someMatchingLocationOf([
AppPath.PlanRequired,
AppPath.PlanRequiredV2,
AppPath.PlanRequiredSuccess,
AppPath.BookCall,
AppPath.BookCallDecision,
@@ -104,7 +105,7 @@ export const usePageChangeEffectNavigateLocation = () => {
) {
return verifyEmailRedirectPath;
}
return AppPath.PlanRequired;
return isOnboardingV2 ? AppPath.PlanRequiredV2 : AppPath.PlanRequired;
}
if (isWorkspaceSuspended) {
@@ -152,6 +153,19 @@ export const usePageChangeEffectNavigateLocation = () => {
return isOnboardingV2 ? AppPath.InviteTeamV2 : AppPath.InviteTeam;
}
if (
isOnboardingV2 &&
(onboardingStatus === OnboardingStatus.BOOK_ONBOARDING ||
onboardingStatus === OnboardingStatus.COMPLETED)
) {
if (isMatchingLocation(location, AppPath.InviteTeamV2)) {
return AppPath.PlanRequiredV2;
}
if (isMatchingLocation(location, AppPath.PlanRequiredV2)) {
return;
}
}
if (
onboardingStatus === OnboardingStatus.BOOK_ONBOARDING &&
!someMatchingLocationOf([AppPath.BookCallDecision, AppPath.BookCall])