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:
@@ -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])
|
||||
|
||||
@@ -39,6 +39,9 @@ export const useApolloFactory = (options: Partial<Options> = {}) => {
|
||||
|
||||
const setReturnToPath = useSetAtomState(returnToPathState);
|
||||
const location = useLocation();
|
||||
// oxlint-disable-next-line twenty/no-state-useref
|
||||
const locationRef = useRef(location);
|
||||
locationRef.current = location;
|
||||
|
||||
const { enqueueErrorSnackBar } = useSnackBar();
|
||||
|
||||
@@ -72,12 +75,13 @@ export const useApolloFactory = (options: Partial<Options> = {}) => {
|
||||
setCurrentWorkspace(null);
|
||||
setCurrentUserWorkspace(null);
|
||||
if (
|
||||
!isMatchingLocation(location, AppPath.Verify) &&
|
||||
!isMatchingLocation(location, AppPath.SignInUp) &&
|
||||
!isMatchingLocation(location, AppPath.Invite) &&
|
||||
!isMatchingLocation(location, AppPath.ResetPassword)
|
||||
!isMatchingLocation(locationRef.current, AppPath.Verify) &&
|
||||
!isMatchingLocation(locationRef.current, AppPath.VerifyV2) &&
|
||||
!isMatchingLocation(locationRef.current, AppPath.SignInUp) &&
|
||||
!isMatchingLocation(locationRef.current, AppPath.Invite) &&
|
||||
!isMatchingLocation(locationRef.current, AppPath.ResetPassword)
|
||||
) {
|
||||
const path = `${location.pathname}${location.search}${location.hash}`;
|
||||
const path = `${locationRef.current.pathname}${locationRef.current.search}${locationRef.current.hash}`;
|
||||
|
||||
if (isValidReturnToPath(path)) {
|
||||
setReturnToPath(path);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { VerifyLoginTokenEffect } from '@/auth/components/VerifyLoginTokenEffect
|
||||
|
||||
import { VerifyEmailEffect } from '@/auth/components/VerifyEmailEffect';
|
||||
import indexAppPath from '@/navigation/utils/indexAppPath';
|
||||
import { VerifyV2 } from '~/pages/onboarding/VerifyV2';
|
||||
import { RecordIndexSkeletonLoader } from '@/object-record/record-index/components/RecordIndexSkeletonLoader';
|
||||
import { BlankLayout } from '@/ui/layout/page/components/BlankLayout';
|
||||
import { DefaultLayout } from '@/ui/layout/page/components/DefaultLayout';
|
||||
@@ -110,6 +111,12 @@ const ChooseYourPlan = lazy(() =>
|
||||
})),
|
||||
);
|
||||
|
||||
const ChooseYourPlanV2 = lazy(() =>
|
||||
import('~/pages/onboarding/ChooseYourPlanV2').then((module) => ({
|
||||
default: module.ChooseYourPlanV2,
|
||||
})),
|
||||
);
|
||||
|
||||
const PaymentSuccess = lazy(() =>
|
||||
import('~/pages/onboarding/PaymentSuccess').then((module) => ({
|
||||
default: module.PaymentSuccess,
|
||||
@@ -306,6 +313,7 @@ export const useCreateAppRouter = (
|
||||
</LazyRoute>
|
||||
}
|
||||
/>
|
||||
<Route path={AppPath.VerifyV2} element={<VerifyV2 />} />
|
||||
<Route
|
||||
path={AppPath.WorkspaceActivationV2}
|
||||
element={
|
||||
@@ -338,6 +346,14 @@ export const useCreateAppRouter = (
|
||||
</LazyRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path={AppPath.PlanRequiredV2}
|
||||
element={
|
||||
<LazyRoute fallback={null}>
|
||||
<ChooseYourPlanV2 />
|
||||
</LazyRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path={AppPath.Authorize}
|
||||
element={
|
||||
|
||||
@@ -10,6 +10,7 @@ export const ONBOARDING_PATHS = [
|
||||
AppPath.InviteTeam,
|
||||
AppPath.InviteTeamV2,
|
||||
AppPath.PlanRequired,
|
||||
AppPath.PlanRequiredV2,
|
||||
AppPath.PlanRequiredSuccess,
|
||||
AppPath.BookCallDecision,
|
||||
AppPath.BookCall,
|
||||
|
||||
@@ -6,4 +6,5 @@ export const ONGOING_USER_CREATION_PATHS = [
|
||||
AppPath.SignInUpV2,
|
||||
AppPath.VerifyEmail,
|
||||
AppPath.Verify,
|
||||
AppPath.VerifyV2,
|
||||
];
|
||||
|
||||
+3
-24
@@ -1,38 +1,17 @@
|
||||
import { SubTitle } from '@/auth/components/SubTitle';
|
||||
import { WORKSPACE_ACTIVATION_MESSAGES } from '@/auth/sign-in-up/constants/WorkspaceActivationMessages';
|
||||
import { OnboardingPulsingLogo } from '@/onboarding/components/OnboardingPulsingLogo';
|
||||
import { styled } from '@linaria/react';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { motion, useReducedMotion } from 'framer-motion';
|
||||
import { useContext } from 'react';
|
||||
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
import { ThemeContext } from 'twenty-ui/theme-constants';
|
||||
|
||||
const STEP_OPACITIES = [1, 0.4, 0.12];
|
||||
const VISIBLE_STEP_COUNT = STEP_OPACITIES.length;
|
||||
const STEP_HEIGHT_IN_PX = 28;
|
||||
const STEPS_CONTAINER_HEIGHT_IN_PX = STEP_HEIGHT_IN_PX * VISIBLE_STEP_COUNT;
|
||||
|
||||
const StyledLogo = styled.img`
|
||||
animation: signInUpWorkspaceActivationLogoPulse 0.8s ease-in-out infinite
|
||||
alternate;
|
||||
height: ${themeCssVariables.spacing[12]};
|
||||
margin-bottom: ${themeCssVariables.spacing[8]};
|
||||
width: ${themeCssVariables.spacing[12]};
|
||||
|
||||
@keyframes signInUpWorkspaceActivationLogoPulse {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
animation: none;
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledStepsContainer = styled.div`
|
||||
height: ${STEPS_CONTAINER_HEIGHT_IN_PX}px;
|
||||
position: relative;
|
||||
@@ -65,7 +44,7 @@ export const SignInUpWorkspaceActivationV2 = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<StyledLogo src="/images/integrations/twenty-logo.svg" alt="" />
|
||||
<OnboardingPulsingLogo />
|
||||
<StyledStepsContainer>
|
||||
{messages.map((message, index) => {
|
||||
const stepOffset = index - messageIndex;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useAuth } from '@/auth/hooks/useAuth';
|
||||
import { isOnboardingV2State } from '@/auth/states/isOnboardingV2State';
|
||||
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
|
||||
import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
import { getWorkspaceUrl } from '~/utils/getWorkspaceUrl';
|
||||
import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { useStore } from 'jotai';
|
||||
|
||||
export const useSignUpInNewWorkspace = () => {
|
||||
const { redirectToWorkspaceDomain } = useRedirectToWorkspaceDomain();
|
||||
@@ -20,6 +22,7 @@ export const useSignUpInNewWorkspace = () => {
|
||||
const isMultiWorkspaceEnabled = useAtomStateValue(
|
||||
isMultiWorkspaceEnabledState,
|
||||
);
|
||||
const store = useStore();
|
||||
const { enqueueErrorSnackBar } = useSnackBar();
|
||||
const { t } = useLingui();
|
||||
|
||||
@@ -73,9 +76,11 @@ export const useSignUpInNewWorkspace = () => {
|
||||
return true;
|
||||
}
|
||||
|
||||
const isOnboardingV2 = store.get(isOnboardingV2State.atom);
|
||||
|
||||
await redirectToWorkspaceDomain(
|
||||
getWorkspaceUrl(data.signUpInNewWorkspace.workspace.workspaceUrls),
|
||||
AppPath.Verify,
|
||||
isOnboardingV2 ? AppPath.VerifyV2 : AppPath.Verify,
|
||||
{ loginToken },
|
||||
'_self',
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ export const CAPTCHA_PROTECTED_PATHS: string[] = [
|
||||
AppPath.SignInUp,
|
||||
AppPath.SignInUpV2,
|
||||
AppPath.Verify,
|
||||
AppPath.VerifyV2,
|
||||
AppPath.VerifyEmail,
|
||||
AppPath.ResetPassword,
|
||||
AppPath.Invite,
|
||||
|
||||
@@ -2,4 +2,5 @@ export type OnboardingConfig = {
|
||||
importContactsCreditsReward: number;
|
||||
inviteTeamMaxCreditsReward: number;
|
||||
inviteTeamCreditsRewardPerUser: number;
|
||||
upgradeCreditsReward: number;
|
||||
};
|
||||
|
||||
@@ -17,6 +17,7 @@ export const MinimalMetadataGater = ({ children }: React.PropsWithChildren) => {
|
||||
|
||||
const isOnExcludedPath =
|
||||
isMatchingLocation(location, AppPath.Verify) ||
|
||||
isMatchingLocation(location, AppPath.VerifyV2) ||
|
||||
isMatchingLocation(location, AppPath.VerifyEmail) ||
|
||||
isMatchingLocation(location, AppPath.SignInUp) ||
|
||||
isMatchingLocation(location, AppPath.SignInUpV2) ||
|
||||
|
||||
@@ -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="" />
|
||||
);
|
||||
+129
@@ -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>
|
||||
);
|
||||
};
|
||||
+56
@@ -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>
|
||||
);
|
||||
};
|
||||
+19
-12
@@ -30,22 +30,29 @@ export const useHandleCheckoutSession = ({
|
||||
|
||||
const handleCheckoutSession = async () => {
|
||||
setIsSubmitting(true);
|
||||
const { data } = await checkoutSession({
|
||||
variables: {
|
||||
recurringInterval,
|
||||
successUrlPath,
|
||||
plan,
|
||||
requirePaymentMethod,
|
||||
},
|
||||
});
|
||||
setIsSubmitting(false);
|
||||
if (!data?.checkoutSession.url) {
|
||||
try {
|
||||
const { data } = await checkoutSession({
|
||||
variables: {
|
||||
recurringInterval,
|
||||
successUrlPath,
|
||||
plan,
|
||||
requirePaymentMethod,
|
||||
},
|
||||
});
|
||||
if (!data?.checkoutSession.url) {
|
||||
enqueueErrorSnackBar({
|
||||
message: t`Checkout session error. Please retry or contact Twenty team`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
redirect(data.checkoutSession.url);
|
||||
} catch {
|
||||
enqueueErrorSnackBar({
|
||||
message: t`Checkout session error. Please retry or contact Twenty team`,
|
||||
});
|
||||
return;
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
redirect(data.checkoutSession.url);
|
||||
};
|
||||
return { isSubmitting, handleCheckoutSession };
|
||||
};
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { billingState } from '@/client-config/states/billingState';
|
||||
import { onboardingConfigState } from '@/client-config/states/onboardingConfigState';
|
||||
import { OnboardingV2Layout } from '@/onboarding/components/OnboardingV2Layout';
|
||||
import { usePlans } from '@/settings/billing/hooks/usePlans';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { styled } from '@linaria/react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { UpgradeFreeTrial } from '~/pages/onboarding/UpgradeFreeTrial';
|
||||
|
||||
const UPGRADE_TRIAL_FREE_CREDITS = 0;
|
||||
|
||||
const StyledPlaceholder = styled.div`
|
||||
flex: 1 1 0;
|
||||
`;
|
||||
|
||||
export const ChooseYourPlanV2 = () => {
|
||||
const { isPlansLoaded } = usePlans();
|
||||
const billing = useAtomStateValue(billingState);
|
||||
const onboardingConfig = useAtomStateValue(onboardingConfigState);
|
||||
|
||||
return (
|
||||
<OnboardingV2Layout freeCredits={UPGRADE_TRIAL_FREE_CREDITS}>
|
||||
{isDefined(billing) && isPlansLoaded ? (
|
||||
<UpgradeFreeTrial
|
||||
billing={billing}
|
||||
creditsReward={onboardingConfig?.upgradeCreditsReward}
|
||||
/>
|
||||
) : (
|
||||
<StyledPlaceholder />
|
||||
)}
|
||||
</OnboardingV2Layout>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,233 @@
|
||||
import { verifyEmailRedirectPathState } from '@/app/states/verifyEmailRedirectPathState';
|
||||
import { useAuth } from '@/auth/hooks/useAuth';
|
||||
import { billingCheckoutSessionState } from '@/auth/states/billingCheckoutSessionState';
|
||||
import { calendarBookingPageIdState } from '@/client-config/states/calendarBookingPageIdState';
|
||||
import { OnboardingCreditsRewardTag } from '@/onboarding/components/import-contacts/OnboardingCreditsRewardTag';
|
||||
import { OnboardingPlanCard } from '@/onboarding/components/upgrade-free-trial/OnboardingPlanCard';
|
||||
import { OnboardingTrialExtensionTag } from '@/onboarding/components/upgrade-free-trial/OnboardingTrialExtensionTag';
|
||||
import { SubscriptionPaymentForm } from '@/settings/billing/components/SubscriptionPaymentForm';
|
||||
import { useBaseLicensedPriceByPlanKeyAndInterval } from '@/settings/billing/hooks/useBaseLicensedPriceByPlanKeyAndInterval';
|
||||
import { useHandleCheckoutSession } from '@/settings/billing/hooks/useHandleCheckoutSession';
|
||||
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { styled } from '@linaria/react';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { AppPath } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { Loader } from 'twenty-ui/feedback';
|
||||
import { MainButton } from 'twenty-ui/input';
|
||||
import { CAL_LINK, ClickToActionLink } from 'twenty-ui/navigation';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
import { type Billing } from '~/generated-metadata/graphql';
|
||||
|
||||
const CONTENT_BLOCK_WIDTH = 340;
|
||||
|
||||
const StyledPage = styled.div`
|
||||
align-items: center;
|
||||
background-color: ${themeCssVariables.background.secondary};
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex: 1 1 0;
|
||||
flex-direction: column;
|
||||
gap: ${themeCssVariables.spacing[14]};
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: ${themeCssVariables.spacing[16]} ${themeCssVariables.spacing[8]};
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledHeading = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${themeCssVariables.spacing[4]};
|
||||
width: ${CONTENT_BLOCK_WIDTH}px;
|
||||
`;
|
||||
|
||||
const StyledTitle = styled.h1`
|
||||
color: ${themeCssVariables.font.color.primary};
|
||||
font-size: ${themeCssVariables.font.size.xl};
|
||||
font-weight: ${themeCssVariables.font.weight.semiBold};
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
const StyledSubtitle = styled.p`
|
||||
color: ${themeCssVariables.font.color.secondary};
|
||||
font-size: ${themeCssVariables.font.size.md};
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
const StyledTagsRow = styled.div`
|
||||
display: flex;
|
||||
gap: ${themeCssVariables.spacing[1]};
|
||||
padding-top: ${themeCssVariables.spacing[1]};
|
||||
`;
|
||||
|
||||
const StyledCards = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${themeCssVariables.spacing[4]};
|
||||
width: ${CONTENT_BLOCK_WIDTH}px;
|
||||
`;
|
||||
|
||||
const StyledFooter = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${themeCssVariables.spacing[4]};
|
||||
width: ${CONTENT_BLOCK_WIDTH}px;
|
||||
`;
|
||||
|
||||
const StyledLinkGroup = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: ${themeCssVariables.spacing[1]};
|
||||
justify-content: center;
|
||||
|
||||
> span {
|
||||
background-color: ${themeCssVariables.font.color.light};
|
||||
border-radius: 50%;
|
||||
height: 2px;
|
||||
width: 2px;
|
||||
}
|
||||
`;
|
||||
|
||||
type UpgradeFreeTrialProps = {
|
||||
billing: Billing;
|
||||
creditsReward?: number;
|
||||
};
|
||||
|
||||
export const UpgradeFreeTrial = ({
|
||||
billing,
|
||||
creditsReward,
|
||||
}: UpgradeFreeTrialProps) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
const { getBaseLicensedPriceByPlanKeyAndInterval } =
|
||||
useBaseLicensedPriceByPlanKeyAndInterval();
|
||||
|
||||
const [billingCheckoutSession, setBillingCheckoutSession] = useAtomState(
|
||||
billingCheckoutSessionState,
|
||||
);
|
||||
|
||||
const calendarBookingPageId = useAtomStateValue(calendarBookingPageIdState);
|
||||
|
||||
const [verifyEmailRedirectPath, setVerifyEmailRedirectPath] = useAtomState(
|
||||
verifyEmailRedirectPathState,
|
||||
);
|
||||
if (isDefined(verifyEmailRedirectPath)) {
|
||||
setVerifyEmailRedirectPath(undefined);
|
||||
}
|
||||
|
||||
const { signOut } = useAuth();
|
||||
|
||||
const currentPlanKey = billingCheckoutSession.plan;
|
||||
const baseProductPrice = getBaseLicensedPriceByPlanKeyAndInterval(
|
||||
currentPlanKey,
|
||||
billingCheckoutSession.interval,
|
||||
);
|
||||
|
||||
const withCreditCardTrialPeriod = billing.trialPeriods.find(
|
||||
(trialPeriod) => trialPeriod.isCreditCardRequired,
|
||||
);
|
||||
const withoutCreditCardTrialPeriod = billing.trialPeriods.find(
|
||||
(trialPeriod) =>
|
||||
!trialPeriod.isCreditCardRequired && trialPeriod.duration !== 0,
|
||||
);
|
||||
|
||||
const { handleCheckoutSession, isSubmitting } = useHandleCheckoutSession({
|
||||
recurringInterval: billingCheckoutSession.interval,
|
||||
plan: billingCheckoutSession.plan,
|
||||
requirePaymentMethod: billingCheckoutSession.requirePaymentMethod,
|
||||
successUrlPath: AppPath.PlanRequiredSuccess,
|
||||
});
|
||||
|
||||
const selectTrialPeriod = (withCreditCard: boolean) => () => {
|
||||
if (
|
||||
isDefined(baseProductPrice) &&
|
||||
billingCheckoutSession.requirePaymentMethod !== withCreditCard
|
||||
) {
|
||||
setBillingCheckoutSession({
|
||||
plan: currentPlanKey,
|
||||
interval: baseProductPrice.recurringInterval,
|
||||
requirePaymentMethod: withCreditCard,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const requirePaymentMethod = billingCheckoutSession.requirePaymentMethod;
|
||||
const trialDuration = withCreditCardTrialPeriod?.duration;
|
||||
|
||||
return (
|
||||
<StyledPage>
|
||||
<StyledHeading>
|
||||
<StyledTitle>{t`Upgrade your free trial`}</StyledTitle>
|
||||
<StyledSubtitle>
|
||||
{isDefined(trialDuration)
|
||||
? t`Insert your billing details to get a ${trialDuration}-day free trial and more AI credits`
|
||||
: t`Insert your billing details to get a free trial and more AI credits`}
|
||||
</StyledSubtitle>
|
||||
<StyledTagsRow>
|
||||
{isDefined(trialDuration) && (
|
||||
<OnboardingTrialExtensionTag duration={trialDuration} />
|
||||
)}
|
||||
{isDefined(creditsReward) && (
|
||||
<OnboardingCreditsRewardTag amount={creditsReward} />
|
||||
)}
|
||||
</StyledTagsRow>
|
||||
</StyledHeading>
|
||||
|
||||
<StyledCards>
|
||||
<OnboardingPlanCard
|
||||
title={t`Upgraded`}
|
||||
titleSuffix={t`· FREE`}
|
||||
note={t`No charge will be made. You'll receive an email reminder 7 days before it ends.`}
|
||||
selected={requirePaymentMethod}
|
||||
onSelect={selectTrialPeriod(true)}
|
||||
>
|
||||
{requirePaymentMethod && isDefined(baseProductPrice) && (
|
||||
<SubscriptionPaymentForm
|
||||
plan={billingCheckoutSession.plan}
|
||||
recurringInterval={billingCheckoutSession.interval}
|
||||
amount={baseProductPrice.unitAmount}
|
||||
/>
|
||||
)}
|
||||
</OnboardingPlanCard>
|
||||
|
||||
{isDefined(withoutCreditCardTrialPeriod) && (
|
||||
<OnboardingPlanCard
|
||||
title={t`Basic`}
|
||||
titleSuffix={t`without credit card`}
|
||||
badge={t`${withoutCreditCardTrialPeriod.duration} days`}
|
||||
selected={!requirePaymentMethod}
|
||||
onSelect={selectTrialPeriod(false)}
|
||||
/>
|
||||
)}
|
||||
</StyledCards>
|
||||
|
||||
<StyledFooter>
|
||||
{!requirePaymentMethod && (
|
||||
<MainButton
|
||||
title={t`Continue`}
|
||||
onClick={handleCheckoutSession}
|
||||
fullWidth
|
||||
Icon={() => (isSubmitting ? <Loader /> : null)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
)}
|
||||
<StyledLinkGroup>
|
||||
<ClickToActionLink onClick={signOut}>
|
||||
<Trans>Log out</Trans>
|
||||
</ClickToActionLink>
|
||||
<span />
|
||||
<ClickToActionLink
|
||||
href={calendarBookingPageId ? AppPath.BookCall : CAL_LINK}
|
||||
target={calendarBookingPageId ? '_self' : '_blank'}
|
||||
rel={calendarBookingPageId ? '' : 'noreferrer'}
|
||||
>
|
||||
<Trans>Book a Call</Trans>
|
||||
</ClickToActionLink>
|
||||
</StyledLinkGroup>
|
||||
</StyledFooter>
|
||||
</StyledPage>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import { SubTitle } from '@/auth/components/SubTitle';
|
||||
import { VerifyLoginTokenEffect } from '@/auth/components/VerifyLoginTokenEffect';
|
||||
import { OnboardingPulsingLogo } from '@/onboarding/components/OnboardingPulsingLogo';
|
||||
import { styled } from '@linaria/react';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
align-items: center;
|
||||
background: ${themeCssVariables.background.primary};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export const VerifyV2 = () => {
|
||||
const { t } = useLingui();
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
<VerifyLoginTokenEffect />
|
||||
<OnboardingPulsingLogo />
|
||||
<SubTitle>{t`Verifying your email`}</SubTitle>
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
import { getOperationName } from '~/utils/getOperationName';
|
||||
import { type Meta, type StoryObj } from '@storybook/react-vite';
|
||||
import { HttpResponse, graphql } from 'msw';
|
||||
import { within } from 'storybook/test';
|
||||
|
||||
import { GET_CURRENT_USER } from '@/users/graphql/queries/getCurrentUser';
|
||||
import { AppPath } from 'twenty-shared/types';
|
||||
import { OnboardingStatus } from '~/generated-metadata/graphql';
|
||||
import { ChooseYourPlanV2 } from '~/pages/onboarding/ChooseYourPlanV2';
|
||||
import {
|
||||
PageDecorator,
|
||||
type PageDecoratorArgs,
|
||||
} from '~/testing/decorators/PageDecorator';
|
||||
import { graphqlMocks } from '~/testing/graphqlMocks';
|
||||
import { mockedOnboardingUserData } from '~/testing/mock-data/users';
|
||||
|
||||
const meta: Meta<PageDecoratorArgs> = {
|
||||
title: 'Pages/Onboarding/ChooseYourPlanV2',
|
||||
component: ChooseYourPlanV2,
|
||||
decorators: [PageDecorator],
|
||||
args: { routePath: AppPath.PlanRequiredV2 },
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
graphql.query(getOperationName(GET_CURRENT_USER) ?? '', () => {
|
||||
return HttpResponse.json({
|
||||
data: {
|
||||
currentUser: mockedOnboardingUserData(OnboardingStatus.COMPLETED),
|
||||
},
|
||||
});
|
||||
}),
|
||||
...graphqlMocks.handlers,
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
export type Story = StoryObj<typeof ChooseYourPlanV2>;
|
||||
|
||||
export const Default: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement.ownerDocument.body);
|
||||
|
||||
await canvas.findByText('Upgrade your free trial', undefined, {
|
||||
timeout: 3000,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -3,10 +3,12 @@ import { AppPath } from 'twenty-shared/types';
|
||||
export const UNTESTED_APP_PATHS = [
|
||||
AppPath.Settings,
|
||||
AppPath.Developers,
|
||||
AppPath.VerifyV2,
|
||||
AppPath.WorkspaceActivationV2,
|
||||
AppPath.CreateProfileV2,
|
||||
AppPath.SyncEmailsV2,
|
||||
AppPath.InviteTeamV2,
|
||||
AppPath.PlanRequiredV2,
|
||||
// Public, unauthenticated redirect route handled in useCreateAppRouter — not
|
||||
// part of the onboarding/auth page-change navigation matrix.
|
||||
AppPath.Dpa,
|
||||
|
||||
@@ -48,6 +48,7 @@ export const mockedClientConfig: ClientConfig = {
|
||||
importContactsCreditsReward: 2,
|
||||
inviteTeamMaxCreditsReward: 9,
|
||||
inviteTeamCreditsRewardPerUser: 3,
|
||||
upgradeCreditsReward: 5,
|
||||
},
|
||||
canManageFeatureFlags: true,
|
||||
publicFeatureFlags: [],
|
||||
|
||||
@@ -27,6 +27,7 @@ export const getPageTitleFromPath = (pathname: string): string => {
|
||||
const pathnameOrPrefix = getPathnameOrPrefix(pathname);
|
||||
switch (pathnameOrPrefix) {
|
||||
case AppPath.Verify:
|
||||
case AppPath.VerifyV2:
|
||||
return t`Verify`;
|
||||
case AppPath.SignInUp:
|
||||
case AppPath.SignInUpV2:
|
||||
|
||||
+1
@@ -89,6 +89,7 @@ describe('ClientConfigController', () => {
|
||||
importContactsCreditsReward: 2,
|
||||
inviteTeamMaxCreditsReward: 9,
|
||||
inviteTeamCreditsRewardPerUser: 3,
|
||||
upgradeCreditsReward: 5,
|
||||
},
|
||||
isAttachmentPreviewEnabled: true,
|
||||
analyticsEnabled: false,
|
||||
|
||||
@@ -214,6 +214,8 @@ export class OnboardingConfig {
|
||||
inviteTeamMaxCreditsReward: number;
|
||||
|
||||
inviteTeamCreditsRewardPerUser: number;
|
||||
|
||||
upgradeCreditsReward: number;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
|
||||
+2
@@ -95,6 +95,7 @@ describe('ClientConfigService', () => {
|
||||
ONBOARDING_IMPORT_CONTACTS_CREDITS_REWARD: 2_000_000,
|
||||
ONBOARDING_INVITE_TEAM_MAX_CREDITS_REWARD: 9_000_000,
|
||||
ONBOARDING_INVITE_TEAM_CREDITS_REWARD_PER_USER: 3_000_000,
|
||||
BILLING_FREE_WORKFLOW_CREDITS_FOR_TRIAL_PERIOD_WITH_CREDIT_CARD: 5_000_000,
|
||||
IS_ATTACHMENT_PREVIEW_ENABLED: true,
|
||||
ANALYTICS_ENABLED: true,
|
||||
MESSAGING_PROVIDER_MICROSOFT_ENABLED: false,
|
||||
@@ -172,6 +173,7 @@ describe('ClientConfigService', () => {
|
||||
importContactsCreditsReward: 2,
|
||||
inviteTeamMaxCreditsReward: 9,
|
||||
inviteTeamCreditsRewardPerUser: 3,
|
||||
upgradeCreditsReward: 5,
|
||||
},
|
||||
isAttachmentPreviewEnabled: true,
|
||||
analyticsEnabled: true,
|
||||
|
||||
+5
@@ -235,6 +235,11 @@ export class ClientConfigService {
|
||||
'ONBOARDING_INVITE_TEAM_CREDITS_REWARD_PER_USER',
|
||||
),
|
||||
),
|
||||
upgradeCreditsReward: toDisplayCredits(
|
||||
this.twentyConfigService.get(
|
||||
'BILLING_FREE_WORKFLOW_CREDITS_FOR_TRIAL_PERIOD_WITH_CREDIT_CARD',
|
||||
),
|
||||
),
|
||||
},
|
||||
isAttachmentPreviewEnabled: this.twentyConfigService.get(
|
||||
'IS_ATTACHMENT_PREVIEW_ENABLED',
|
||||
|
||||
@@ -64,14 +64,6 @@ export class OnboardingService {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
await this.billingService.isSubscriptionIncompleteOnboardingStatus(
|
||||
workspace.id,
|
||||
)
|
||||
) {
|
||||
return OnboardingStatus.PLAN_REQUIRED;
|
||||
}
|
||||
|
||||
if (this.isWorkspaceActivationPending(workspace)) {
|
||||
return OnboardingStatus.WORKSPACE_ACTIVATION;
|
||||
}
|
||||
@@ -108,6 +100,14 @@ export class OnboardingService {
|
||||
return OnboardingStatus.INVITE_TEAM;
|
||||
}
|
||||
|
||||
if (
|
||||
await this.billingService.isSubscriptionIncompleteOnboardingStatus(
|
||||
workspace.id,
|
||||
)
|
||||
) {
|
||||
return OnboardingStatus.PLAN_REQUIRED;
|
||||
}
|
||||
|
||||
if (isBookOnboardingPending) {
|
||||
const calendarBookingPageId = this.twentyConfigService.get(
|
||||
'CALENDAR_BOOKING_PAGE_ID',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export enum AppPath {
|
||||
// Not logged-in
|
||||
Verify = '/verify',
|
||||
VerifyV2 = '/verify-v2',
|
||||
VerifyEmail = '/verify-email',
|
||||
SignInUp = '/welcome',
|
||||
SignInUpV2 = '/welcome-v2',
|
||||
@@ -17,6 +18,7 @@ export enum AppPath {
|
||||
InviteTeam = '/invite-team',
|
||||
InviteTeamV2 = '/invite-team-v2',
|
||||
PlanRequired = '/plan-required',
|
||||
PlanRequiredV2 = '/plan-required-v2',
|
||||
PlanRequiredSuccess = '/plan-required/payment-success',
|
||||
BookCallDecision = '/book-call-decision',
|
||||
BookCall = '/book-call',
|
||||
|
||||
Reference in New Issue
Block a user