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
@@ -0,0 +1,27 @@
import { styled } from '@linaria/react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledLogo = styled.img`
animation: onboardingPulsingLogo 0.8s ease-in-out infinite alternate;
height: ${themeCssVariables.spacing[12]};
margin-bottom: ${themeCssVariables.spacing[8]};
width: ${themeCssVariables.spacing[12]};
@keyframes onboardingPulsingLogo {
from {
opacity: 1;
}
to {
opacity: 0.4;
}
}
@media (prefers-reduced-motion: reduce) {
animation: none;
opacity: 1;
}
`;
export const OnboardingPulsingLogo = () => (
<StyledLogo src="/images/integrations/twenty-logo.svg" alt="" />
);
@@ -0,0 +1,129 @@
import { styled } from '@linaria/react';
import { isValidElement, type ReactNode } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { Radio } from 'twenty-ui/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledCard = styled.div`
background-color: ${themeCssVariables.background.primary};
border: 1px solid ${themeCssVariables.border.color.medium};
border-radius: ${themeCssVariables.border.radius.md};
box-sizing: border-box;
display: flex;
flex-direction: column;
overflow: hidden;
width: 100%;
`;
const StyledHeader = styled.button<{ hasBody: boolean }>`
align-items: flex-start;
background-color: transparent;
border: none;
border-bottom: ${({ hasBody }) =>
hasBody ? `1px solid ${themeCssVariables.border.color.light}` : 'none'};
cursor: pointer;
display: flex;
gap: ${themeCssVariables.spacing[2]};
justify-content: space-between;
padding: ${themeCssVariables.spacing[4]} ${themeCssVariables.spacing[3]};
text-align: left;
width: 100%;
`;
const StyledHeaderLeft = styled.div`
display: flex;
flex: 1 1 0;
flex-direction: column;
gap: ${themeCssVariables.spacing[2]};
min-width: 0;
`;
const StyledTitleRow = styled.div`
align-items: center;
display: flex;
gap: ${themeCssVariables.spacing[1]};
`;
const StyledTitle = styled.span`
color: ${themeCssVariables.font.color.primary};
font-size: ${themeCssVariables.font.size.md};
font-weight: ${themeCssVariables.font.weight.medium};
`;
const StyledTitleSuffix = styled.span`
color: ${themeCssVariables.font.color.tertiary};
font-size: ${themeCssVariables.font.size.sm};
font-weight: ${themeCssVariables.font.weight.regular};
`;
const StyledNote = styled.span`
color: ${themeCssVariables.font.color.light};
font-size: ${themeCssVariables.font.size.sm};
font-weight: ${themeCssVariables.font.weight.regular};
`;
const StyledHeaderRight = styled.div`
align-items: center;
display: flex;
gap: ${themeCssVariables.spacing[2]};
`;
const StyledBadge = styled.span`
align-items: center;
background-color: ${themeCssVariables.background.tertiary};
border-radius: ${themeCssVariables.border.radius.pill};
color: ${themeCssVariables.font.color.tertiary};
display: flex;
font-size: ${themeCssVariables.font.size.sm};
font-weight: ${themeCssVariables.font.weight.medium};
padding: ${themeCssVariables.spacing[1]} ${themeCssVariables.spacing[2]};
`;
const StyledBody = styled.div`
display: flex;
flex-direction: column;
padding: ${themeCssVariables.spacing[4]} ${themeCssVariables.spacing[3]};
`;
type OnboardingPlanCardProps = {
title: string;
titleSuffix?: string;
note?: string;
badge?: string;
selected: boolean;
onSelect: () => void;
children?: ReactNode;
};
export const OnboardingPlanCard = ({
title,
titleSuffix,
note,
badge,
selected,
onSelect,
children,
}: OnboardingPlanCardProps) => {
const hasBody = isValidElement(children);
return (
<StyledCard>
<StyledHeader type="button" hasBody={hasBody} onClick={onSelect}>
<StyledHeaderLeft>
<StyledTitleRow>
<StyledTitle>{title}</StyledTitle>
{isDefined(titleSuffix) && (
<StyledTitleSuffix>{titleSuffix}</StyledTitleSuffix>
)}
</StyledTitleRow>
{isDefined(note) && <StyledNote>{note}</StyledNote>}
</StyledHeaderLeft>
<StyledHeaderRight>
{isDefined(badge) && <StyledBadge>{badge}</StyledBadge>}
<Radio checked={selected} />
</StyledHeaderRight>
</StyledHeader>
{hasBody && <StyledBody>{children}</StyledBody>}
</StyledCard>
);
};
@@ -0,0 +1,56 @@
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { IconCalendarEvent } from 'twenty-ui/icon';
import { themeCssVariables, useTheme } from 'twenty-ui/theme-constants';
const StyledTag = styled.div`
align-items: center;
background-color: ${themeCssVariables.color.green3};
border: 1px solid ${themeCssVariables.color.green4};
border-radius: ${themeCssVariables.border.radius.pill};
box-sizing: border-box;
color: ${themeCssVariables.color.green9};
display: flex;
gap: ${themeCssVariables.spacing[1]};
height: ${themeCssVariables.spacing[6]};
padding: 0 ${themeCssVariables.spacing[2]} 0
${themeCssVariables.spacing['1.5']};
`;
const StyledPrefix = styled.span`
font-size: ${themeCssVariables.font.size.sm};
font-weight: ${themeCssVariables.font.weight.regular};
`;
const StyledDuration = styled.span`
font-size: ${themeCssVariables.font.size.md};
font-weight: ${themeCssVariables.font.weight.medium};
`;
const StyledSuffix = styled.span`
font-size: ${themeCssVariables.font.size.sm};
font-weight: ${themeCssVariables.font.weight.regular};
`;
type OnboardingTrialExtensionTagProps = {
duration: number;
};
export const OnboardingTrialExtensionTag = ({
duration,
}: OnboardingTrialExtensionTagProps) => {
const { t } = useLingui();
const theme = useTheme();
return (
<StyledTag>
<IconCalendarEvent
size={theme.icon.size.md}
color={themeCssVariables.color.green9}
/>
<StyledPrefix>{t`Extended`}</StyledPrefix>
<StyledDuration>{duration}</StyledDuration>
<StyledSuffix>{t`days trial`}</StyledSuffix>
</StyledTag>
);
};