diff --git a/packages/twenty-front/src/hooks/__tests__/usePageChangeEffectNavigateLocation.test.ts b/packages/twenty-front/src/hooks/__tests__/usePageChangeEffectNavigateLocation.test.ts index 9cb9f1be46..0c57d8b6f1 100644 --- a/packages/twenty-front/src/hooks/__tests__/usePageChangeEffectNavigateLocation.test.ts +++ b/packages/twenty-front/src/hooks/__tests__/usePageChangeEffectNavigateLocation.test.ts @@ -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(); + }); }); diff --git a/packages/twenty-front/src/hooks/usePageChangeEffectNavigateLocation.ts b/packages/twenty-front/src/hooks/usePageChangeEffectNavigateLocation.ts index 7e4381f8f0..b49493717d 100644 --- a/packages/twenty-front/src/hooks/usePageChangeEffectNavigateLocation.ts +++ b/packages/twenty-front/src/hooks/usePageChangeEffectNavigateLocation.ts @@ -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]) diff --git a/packages/twenty-front/src/modules/apollo/hooks/useApolloFactory.ts b/packages/twenty-front/src/modules/apollo/hooks/useApolloFactory.ts index bacdf041b3..d52bf45d39 100644 --- a/packages/twenty-front/src/modules/apollo/hooks/useApolloFactory.ts +++ b/packages/twenty-front/src/modules/apollo/hooks/useApolloFactory.ts @@ -39,6 +39,9 @@ export const useApolloFactory = (options: Partial = {}) => { 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 = {}) => { 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); diff --git a/packages/twenty-front/src/modules/app/hooks/useCreateAppRouter.tsx b/packages/twenty-front/src/modules/app/hooks/useCreateAppRouter.tsx index 38d051085c..1ed6014281 100644 --- a/packages/twenty-front/src/modules/app/hooks/useCreateAppRouter.tsx +++ b/packages/twenty-front/src/modules/app/hooks/useCreateAppRouter.tsx @@ -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 = ( } /> + } /> } /> + + + + } + /> - + {messages.map((message, index) => { const stepOffset = index - messageIndex; diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignUpInNewWorkspace.ts b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignUpInNewWorkspace.ts index 6fedcafcde..237f636903 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignUpInNewWorkspace.ts +++ b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignUpInNewWorkspace.ts @@ -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', ); diff --git a/packages/twenty-front/src/modules/captcha/constants/CaptchaProtectedPaths.ts b/packages/twenty-front/src/modules/captcha/constants/CaptchaProtectedPaths.ts index 179410fe7a..b71a6d6a0d 100644 --- a/packages/twenty-front/src/modules/captcha/constants/CaptchaProtectedPaths.ts +++ b/packages/twenty-front/src/modules/captcha/constants/CaptchaProtectedPaths.ts @@ -4,6 +4,7 @@ export const CAPTCHA_PROTECTED_PATHS: string[] = [ AppPath.SignInUp, AppPath.SignInUpV2, AppPath.Verify, + AppPath.VerifyV2, AppPath.VerifyEmail, AppPath.ResetPassword, AppPath.Invite, diff --git a/packages/twenty-front/src/modules/client-config/types/OnboardingConfig.ts b/packages/twenty-front/src/modules/client-config/types/OnboardingConfig.ts index d27420553b..c25ceb2455 100644 --- a/packages/twenty-front/src/modules/client-config/types/OnboardingConfig.ts +++ b/packages/twenty-front/src/modules/client-config/types/OnboardingConfig.ts @@ -2,4 +2,5 @@ export type OnboardingConfig = { importContactsCreditsReward: number; inviteTeamMaxCreditsReward: number; inviteTeamCreditsRewardPerUser: number; + upgradeCreditsReward: number; }; diff --git a/packages/twenty-front/src/modules/metadata-store/components/MinimalMetadataGater.tsx b/packages/twenty-front/src/modules/metadata-store/components/MinimalMetadataGater.tsx index bea8f30c99..705ab42a25 100644 --- a/packages/twenty-front/src/modules/metadata-store/components/MinimalMetadataGater.tsx +++ b/packages/twenty-front/src/modules/metadata-store/components/MinimalMetadataGater.tsx @@ -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) || diff --git a/packages/twenty-front/src/modules/onboarding/components/OnboardingPulsingLogo.tsx b/packages/twenty-front/src/modules/onboarding/components/OnboardingPulsingLogo.tsx new file mode 100644 index 0000000000..0dacca9349 --- /dev/null +++ b/packages/twenty-front/src/modules/onboarding/components/OnboardingPulsingLogo.tsx @@ -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 = () => ( + +); diff --git a/packages/twenty-front/src/modules/onboarding/components/upgrade-free-trial/OnboardingPlanCard.tsx b/packages/twenty-front/src/modules/onboarding/components/upgrade-free-trial/OnboardingPlanCard.tsx new file mode 100644 index 0000000000..42f2f5fceb --- /dev/null +++ b/packages/twenty-front/src/modules/onboarding/components/upgrade-free-trial/OnboardingPlanCard.tsx @@ -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 ( + + + + + {title} + {isDefined(titleSuffix) && ( + {titleSuffix} + )} + + {isDefined(note) && {note}} + + + {isDefined(badge) && {badge}} + + + + {hasBody && {children}} + + ); +}; diff --git a/packages/twenty-front/src/modules/onboarding/components/upgrade-free-trial/OnboardingTrialExtensionTag.tsx b/packages/twenty-front/src/modules/onboarding/components/upgrade-free-trial/OnboardingTrialExtensionTag.tsx new file mode 100644 index 0000000000..aeb80cf5f2 --- /dev/null +++ b/packages/twenty-front/src/modules/onboarding/components/upgrade-free-trial/OnboardingTrialExtensionTag.tsx @@ -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 ( + + + {t`Extended`} + {duration} + {t`days trial`} + + ); +}; diff --git a/packages/twenty-front/src/modules/settings/billing/hooks/useHandleCheckoutSession.ts b/packages/twenty-front/src/modules/settings/billing/hooks/useHandleCheckoutSession.ts index 26570dc283..d7bb7aaf45 100644 --- a/packages/twenty-front/src/modules/settings/billing/hooks/useHandleCheckoutSession.ts +++ b/packages/twenty-front/src/modules/settings/billing/hooks/useHandleCheckoutSession.ts @@ -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 }; }; diff --git a/packages/twenty-front/src/pages/onboarding/ChooseYourPlanV2.tsx b/packages/twenty-front/src/pages/onboarding/ChooseYourPlanV2.tsx new file mode 100644 index 0000000000..fc1e625794 --- /dev/null +++ b/packages/twenty-front/src/pages/onboarding/ChooseYourPlanV2.tsx @@ -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 ( + + {isDefined(billing) && isPlansLoaded ? ( + + ) : ( + + )} + + ); +}; diff --git a/packages/twenty-front/src/pages/onboarding/UpgradeFreeTrial.tsx b/packages/twenty-front/src/pages/onboarding/UpgradeFreeTrial.tsx new file mode 100644 index 0000000000..900c9f49be --- /dev/null +++ b/packages/twenty-front/src/pages/onboarding/UpgradeFreeTrial.tsx @@ -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 ( + + + {t`Upgrade your free trial`} + + {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`} + + + {isDefined(trialDuration) && ( + + )} + {isDefined(creditsReward) && ( + + )} + + + + + + {requirePaymentMethod && isDefined(baseProductPrice) && ( + + )} + + + {isDefined(withoutCreditCardTrialPeriod) && ( + + )} + + + + {!requirePaymentMethod && ( + (isSubmitting ? : null)} + disabled={isSubmitting} + /> + )} + + + Log out + + + + Book a Call + + + + + ); +}; diff --git a/packages/twenty-front/src/pages/onboarding/VerifyV2.tsx b/packages/twenty-front/src/pages/onboarding/VerifyV2.tsx new file mode 100644 index 0000000000..a9b5d29a49 --- /dev/null +++ b/packages/twenty-front/src/pages/onboarding/VerifyV2.tsx @@ -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 ( + + + + {t`Verifying your email`} + + ); +}; diff --git a/packages/twenty-front/src/pages/onboarding/__stories__/ChooseYourPlanV2.stories.tsx b/packages/twenty-front/src/pages/onboarding/__stories__/ChooseYourPlanV2.stories.tsx new file mode 100644 index 0000000000..0cd4a8ca24 --- /dev/null +++ b/packages/twenty-front/src/pages/onboarding/__stories__/ChooseYourPlanV2.stories.tsx @@ -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 = { + 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; + +export const Default: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement.ownerDocument.body); + + await canvas.findByText('Upgrade your free trial', undefined, { + timeout: 3000, + }); + }, +}; diff --git a/packages/twenty-front/src/testing/constants/UntestedAppPaths.ts b/packages/twenty-front/src/testing/constants/UntestedAppPaths.ts index 1f02879df7..506ac47763 100644 --- a/packages/twenty-front/src/testing/constants/UntestedAppPaths.ts +++ b/packages/twenty-front/src/testing/constants/UntestedAppPaths.ts @@ -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, diff --git a/packages/twenty-front/src/testing/mock-data/config.ts b/packages/twenty-front/src/testing/mock-data/config.ts index 8897d77300..f535dec1e8 100644 --- a/packages/twenty-front/src/testing/mock-data/config.ts +++ b/packages/twenty-front/src/testing/mock-data/config.ts @@ -48,6 +48,7 @@ export const mockedClientConfig: ClientConfig = { importContactsCreditsReward: 2, inviteTeamMaxCreditsReward: 9, inviteTeamCreditsRewardPerUser: 3, + upgradeCreditsReward: 5, }, canManageFeatureFlags: true, publicFeatureFlags: [], diff --git a/packages/twenty-front/src/utils/title-utils.ts b/packages/twenty-front/src/utils/title-utils.ts index 864741c0ab..4a4178c988 100644 --- a/packages/twenty-front/src/utils/title-utils.ts +++ b/packages/twenty-front/src/utils/title-utils.ts @@ -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: diff --git a/packages/twenty-server/src/engine/core-modules/client-config/client-config.controller.spec.ts b/packages/twenty-server/src/engine/core-modules/client-config/client-config.controller.spec.ts index 1dc990f132..3fae6da4a5 100644 --- a/packages/twenty-server/src/engine/core-modules/client-config/client-config.controller.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/client-config/client-config.controller.spec.ts @@ -89,6 +89,7 @@ describe('ClientConfigController', () => { importContactsCreditsReward: 2, inviteTeamMaxCreditsReward: 9, inviteTeamCreditsRewardPerUser: 3, + upgradeCreditsReward: 5, }, isAttachmentPreviewEnabled: true, analyticsEnabled: false, diff --git a/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts b/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts index a576edebbb..24d17e0b42 100644 --- a/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts @@ -214,6 +214,8 @@ export class OnboardingConfig { inviteTeamMaxCreditsReward: number; inviteTeamCreditsRewardPerUser: number; + + upgradeCreditsReward: number; } @ObjectType() diff --git a/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.spec.ts b/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.spec.ts index d5ea09a8a7..4172ec547f 100644 --- a/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.spec.ts @@ -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, diff --git a/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.ts b/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.ts index fee2f38342..274de60dbf 100644 --- a/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.ts +++ b/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.ts @@ -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', diff --git a/packages/twenty-server/src/engine/core-modules/onboarding/onboarding.service.ts b/packages/twenty-server/src/engine/core-modules/onboarding/onboarding.service.ts index 0b270aaa8a..c751e46aa7 100644 --- a/packages/twenty-server/src/engine/core-modules/onboarding/onboarding.service.ts +++ b/packages/twenty-server/src/engine/core-modules/onboarding/onboarding.service.ts @@ -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', diff --git a/packages/twenty-shared/src/types/AppPath.ts b/packages/twenty-shared/src/types/AppPath.ts index 2c52555df6..28d06f54e5 100644 --- a/packages/twenty-shared/src/types/AppPath.ts +++ b/packages/twenty-shared/src/types/AppPath.ts @@ -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',