Update ChooseYourPlan page with new trial period options (#9628)

### Context
- Update /plan-required page to let users get free trial without credit
card plan
- Update usePageChangeEffectNavigateLocation to redirect paused and
canceled subscription (suspended workspace) to /settings/billing page

### To do

- [x] Update usePageChangeEffectNavigateLocation test
- [x] Update ChooseYourPlan sb test



closes #9520

---------

Co-authored-by: etiennejouan <jouan.etienne@gmail.com>
This commit is contained in:
Etienne
2025-01-16 11:10:36 +01:00
committed by GitHub
parent c79cb14132
commit 26058f3e25
40 changed files with 722 additions and 596 deletions

View File

@ -19,7 +19,6 @@ describe('useSignInWithGoogle', () => {
plan: BillingPlanKey.Pro,
interval: SubscriptionInterval.Month,
requirePaymentMethod: true,
skipPlanPage: false,
};
const Wrapper = getJestMetadataAndApolloMocksWrapper({
@ -31,7 +30,7 @@ describe('useSignInWithGoogle', () => {
const mockUseParams = { workspaceInviteHash: 'testHash' };
const mockSearchParams = new URLSearchParams(
'inviteToken=testToken&billingCheckoutSessionState={"plan":"Pro","interval":"Month","requirePaymentMethod":true,"skipPlanPage":false}',
'inviteToken=testToken&billingCheckoutSessionState={"plan":"Pro","interval":"Month","requirePaymentMethod":true}',
);
(useParams as jest.Mock).mockReturnValue(mockUseParams);

View File

@ -22,7 +22,6 @@ describe('useSignInWithMicrosoft', () => {
plan: 'PRO',
interval: 'Month',
requirePaymentMethod: true,
skipPlanPage: false,
};
it('should call signInWithMicrosoft with the correct parameters', () => {

View File

@ -12,7 +12,6 @@ export const useSignInWithGoogle = () => {
plan: 'PRO',
interval: 'Month',
requirePaymentMethod: true,
skipPlanPage: false,
} as BillingCheckoutSession;
const { signInWithGoogle } = useAuth();

View File

@ -1,16 +1,11 @@
import { BillingCheckoutSession } from '@/auth/types/billingCheckoutSession.type';
import { BILLING_CHECKOUT_SESSION_DEFAULT_VALUE } from '@/billing/constants/BillingCheckoutSessionDefaultValue';
import { createState } from '@ui/utilities/state/utils/createState';
import { syncEffect } from 'recoil-sync';
import { BillingPlanKey, SubscriptionInterval } from '~/generated/graphql';
export const billingCheckoutSessionState = createState<BillingCheckoutSession>({
key: 'billingCheckoutSessionState',
defaultValue: {
plan: BillingPlanKey.Pro,
interval: SubscriptionInterval.Month,
requirePaymentMethod: true,
skipPlanPage: false,
},
defaultValue: BILLING_CHECKOUT_SESSION_DEFAULT_VALUE,
effects: [
syncEffect({
refine: (value: unknown) => {
@ -19,8 +14,7 @@ export const billingCheckoutSessionState = createState<BillingCheckoutSession>({
value !== null &&
'plan' in value &&
'interval' in value &&
'requirePaymentMethod' in value &&
'skipPlanPage' in value
'requirePaymentMethod' in value
) {
return {
type: 'success',

View File

@ -5,5 +5,4 @@ export type BillingCheckoutSession = {
plan: BillingPlanKey;
interval: SubscriptionInterval;
requirePaymentMethod: boolean;
skipPlanPage: boolean;
};

View File

@ -1,41 +0,0 @@
import styled from '@emotion/styled';
import { SubscriptionCardPrice } from '@/billing/components/SubscriptionCardPrice';
import { capitalize } from 'twenty-shared';
type SubscriptionCardProps = {
type?: string;
price: number;
info: string;
};
const StyledSubscriptionCardContainer = styled.div`
display: flex;
flex-direction: column;
`;
const StyledTypeContainer = styled.div`
color: ${({ theme }) => theme.font.color.secondary};
font-size: ${({ theme }) => theme.font.size.sm};
display: flex;
`;
const StyledInfoContainer = styled.div`
color: ${({ theme }) => theme.font.color.tertiary};
font-size: ${({ theme }) => theme.font.size.sm};
display: flex;
`;
export const SubscriptionCard = ({
type,
price,
info,
}: SubscriptionCardProps) => {
return (
<StyledSubscriptionCardContainer>
<StyledTypeContainer>{capitalize(type || '')}</StyledTypeContainer>
<SubscriptionCardPrice price={price} />
<StyledInfoContainer>{info}</StyledInfoContainer>
</StyledSubscriptionCardContainer>
);
};

View File

@ -1,33 +0,0 @@
import styled from '@emotion/styled';
type SubscriptionCardPriceProps = {
price: number;
};
const StyledSubscriptionCardPriceContainer = styled.div`
align-items: baseline;
display: flex;
gap: ${({ theme }) => theme.betweenSiblingsGap};
margin: ${({ theme }) => theme.spacing(1)} 0
${({ theme }) => theme.spacing(2)};
`;
const StyledPriceSpan = styled.span`
color: ${({ theme }) => theme.font.color.primary};
font-size: ${({ theme }) => theme.font.size.xl};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
`;
const StyledSeatSpan = styled.span`
color: ${({ theme }) => theme.font.color.light};
font-size: ${({ theme }) => theme.font.size.md};
font-weight: ${({ theme }) => theme.font.weight.medium};
`;
export const SubscriptionCardPrice = ({
price,
}: SubscriptionCardPriceProps) => {
return (
<StyledSubscriptionCardPriceContainer>
<StyledPriceSpan>${price}</StyledPriceSpan>
<StyledSeatSpan>/</StyledSeatSpan>
<StyledSeatSpan>seat</StyledSeatSpan>
</StyledSubscriptionCardPriceContainer>
);
};

View File

@ -0,0 +1,29 @@
import styled from '@emotion/styled';
import { SubscriptionInterval } from '~/generated-metadata/graphql';
type SubscriptionPriceProps = {
type: SubscriptionInterval;
price: number;
};
const StyledPriceSpan = styled.span`
color: ${({ theme }) => theme.font.color.primary};
font-size: ${({ theme }) => theme.font.size.xxl};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
margin-bottom: ${({ theme }) => theme.spacing(1)};
`;
const StyledPriceUnitSpan = styled.span`
color: ${({ theme }) => theme.font.color.light};
font-size: ${({ theme }) => theme.font.size.md};
font-weight: ${({ theme }) => theme.font.weight.medium};
`;
export const SubscriptionPrice = ({ type, price }: SubscriptionPriceProps) => {
return (
<>
<StyledPriceSpan>{`$${price}`}</StyledPriceSpan>
<StyledPriceUnitSpan>{`seat / ${type.toLocaleLowerCase()}`}</StyledPriceUnitSpan>
</>
);
};

View File

@ -0,0 +1,33 @@
import styled from '@emotion/styled';
type TrialCardProps = {
duration: number;
withCreditCard: boolean;
};
const StyledTrialCardContainer = styled.div`
display: flex;
flex-direction: column;
`;
const StyledTrialDurationContainer = styled.div`
color: ${({ theme }) => theme.font.color.secondary};
font-size: ${({ theme }) => theme.font.size.sm};
display: flex;
margin-bottom: ${({ theme }) => theme.spacing(4)};
`;
const StyledCreditCardRequirementContainer = styled.div`
color: ${({ theme }) => theme.font.color.tertiary};
font-size: ${({ theme }) => theme.font.size.sm};
display: flex;
`;
export const TrialCard = ({ duration, withCreditCard }: TrialCardProps) => {
return (
<StyledTrialCardContainer>
<StyledTrialDurationContainer>{`${duration} days trial`}</StyledTrialDurationContainer>
<StyledCreditCardRequirementContainer>{`${withCreditCard ? 'With Credit Card' : 'Without Credit Card'}`}</StyledCreditCardRequirementContainer>
</StyledTrialCardContainer>
);
};

View File

@ -0,0 +1,11 @@
import { BillingCheckoutSession } from '@/auth/types/billingCheckoutSession.type';
import {
BillingPlanKey,
SubscriptionInterval,
} from '~/generated-metadata/graphql';
export const BILLING_CHECKOUT_SESSION_DEFAULT_VALUE: BillingCheckoutSession = {
plan: BillingPlanKey.Pro,
interval: SubscriptionInterval.Month,
requirePaymentMethod: true,
};

View File

@ -0,0 +1,50 @@
import { AppPath } from '@/types/AppPath';
import { SettingsPath } from '@/types/SettingsPath';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useState } from 'react';
import {
BillingPlanKey,
SubscriptionInterval,
} from '~/generated-metadata/graphql';
import { useCheckoutSessionMutation } from '~/generated/graphql';
export const useHandleCheckoutSession = ({
recurringInterval,
plan,
requirePaymentMethod,
}: {
recurringInterval: SubscriptionInterval;
plan: BillingPlanKey;
requirePaymentMethod: boolean;
}) => {
const { enqueueSnackBar } = useSnackBar();
const [checkoutSession] = useCheckoutSessionMutation();
const [isSubmitting, setIsSubmitting] = useState(false);
const handleCheckoutSession = async () => {
setIsSubmitting(true);
const { data } = await checkoutSession({
variables: {
recurringInterval,
successUrlPath: `${AppPath.Settings}/${SettingsPath.Billing}`,
plan,
requirePaymentMethod,
},
});
setIsSubmitting(false);
if (!data?.checkoutSession.url) {
enqueueSnackBar(
'Checkout session error. Please retry or contact Twenty team',
{
variant: SnackBarVariant.Error,
},
);
return;
}
window.location.replace(data.checkoutSession.url);
};
return { isSubmitting, handleCheckoutSession };
};

View File

@ -6,7 +6,10 @@ export const GET_CLIENT_CONFIG = gql`
billing {
isBillingEnabled
billingUrl
billingFreeTrialDurationInDays
trialPeriods {
duration
isCreditCardRequired
}
}
authProviders {
google

View File

@ -17,12 +17,14 @@ export const InformationBanner = ({
buttonTitle,
buttonIcon,
buttonOnClick,
isButtonDisabled = false,
}: {
message: string;
variant?: BannerVariant;
buttonTitle?: string;
buttonIcon?: IconComponent;
buttonOnClick?: () => void;
isButtonDisabled?: boolean;
}) => {
return (
<StyledBanner variant={variant}>
@ -35,6 +37,7 @@ export const InformationBanner = ({
size="small"
inverted
onClick={buttonOnClick}
disabled={isButtonDisabled}
/>
)}
</StyledBanner>

View File

@ -1,6 +1,13 @@
import { InformationBannerBillingSubscriptionPaused } from '@/information-banner/components/billing/InformationBannerBillingSubscriptionPaused';
import { InformationBannerFailPaymentInfo } from '@/information-banner/components/billing/InformationBannerFailPaymentInfo';
import { InformationBannerNoBillingSubscription } from '@/information-banner/components/billing/InformationBannerNoBillingSubscription';
import { InformationBannerReconnectAccountEmailAliases } from '@/information-banner/components/reconnect-account/InformationBannerReconnectAccountEmailAliases';
import { InformationBannerReconnectAccountInsufficientPermissions } from '@/information-banner/components/reconnect-account/InformationBannerReconnectAccountInsufficientPermissions';
import { useIsWorkspaceActivationStatusSuspended } from '@/workspace/hooks/useIsWorkspaceActivationStatusSuspended';
import { useSubscriptionStatus } from '@/workspace/hooks/useSubscriptionStatus';
import styled from '@emotion/styled';
import { isDefined } from 'twenty-ui';
import { SubscriptionStatus } from '~/generated-metadata/graphql';
const StyledInformationBannerWrapper = styled.div`
height: 40px;
@ -12,10 +19,30 @@ const StyledInformationBannerWrapper = styled.div`
`;
export const InformationBannerWrapper = () => {
const subscriptionStatus = useSubscriptionStatus();
const isWorkspaceSuspended = useIsWorkspaceActivationStatusSuspended();
const displayBillingSubscriptionPausedBanner =
isWorkspaceSuspended && subscriptionStatus === SubscriptionStatus.Paused;
const displayBillingSubscriptionCanceledBanner =
isWorkspaceSuspended && !isDefined(subscriptionStatus);
const displayFailPaymentInfoBanner =
subscriptionStatus === SubscriptionStatus.PastDue ||
subscriptionStatus === SubscriptionStatus.Unpaid;
return (
<StyledInformationBannerWrapper>
<InformationBannerReconnectAccountInsufficientPermissions />
<InformationBannerReconnectAccountEmailAliases />
{displayBillingSubscriptionPausedBanner && (
<InformationBannerBillingSubscriptionPaused />
)}
{displayBillingSubscriptionCanceledBanner && (
<InformationBannerNoBillingSubscription />
)}
{displayFailPaymentInfoBanner && <InformationBannerFailPaymentInfo />}
</StyledInformationBannerWrapper>
);
};

View File

@ -0,0 +1,29 @@
import { InformationBanner } from '@/information-banner/components/InformationBanner';
import { AppPath } from '@/types/AppPath';
import { SettingsPath } from '@/types/SettingsPath';
import { isDefined } from 'twenty-ui';
import { useBillingPortalSessionQuery } from '~/generated/graphql';
export const InformationBannerBillingSubscriptionPaused = () => {
const { data, loading } = useBillingPortalSessionQuery({
variables: {
returnUrlPath: `${AppPath.Settings}/${SettingsPath.Billing}`,
},
});
const openBillingPortal = () => {
if (isDefined(data) && isDefined(data.billingPortalSession.url)) {
window.location.replace(data.billingPortalSession.url);
}
};
return (
<InformationBanner
variant="danger"
message={'Trial expired. Please update your billing details'}
buttonTitle="Update"
buttonOnClick={() => openBillingPortal()}
isButtonDisabled={loading || !isDefined(data)}
/>
);
};

View File

@ -0,0 +1,29 @@
import { InformationBanner } from '@/information-banner/components/InformationBanner';
import { AppPath } from '@/types/AppPath';
import { SettingsPath } from '@/types/SettingsPath';
import { isDefined } from 'twenty-ui';
import { useBillingPortalSessionQuery } from '~/generated/graphql';
export const InformationBannerFailPaymentInfo = () => {
const { data, loading } = useBillingPortalSessionQuery({
variables: {
returnUrlPath: `${AppPath.Settings}/${SettingsPath.Billing}`,
},
});
const openBillingPortal = () => {
if (isDefined(data) && isDefined(data.billingPortalSession.url)) {
window.location.replace(data.billingPortalSession.url);
}
};
return (
<InformationBanner
variant="danger"
message={'Last payment failed. Please update your billing details.'}
buttonTitle="Update"
buttonOnClick={() => openBillingPortal()}
isButtonDisabled={loading || !isDefined(data)}
/>
);
};

View File

@ -0,0 +1,21 @@
import { BILLING_CHECKOUT_SESSION_DEFAULT_VALUE } from '@/billing/constants/BillingCheckoutSessionDefaultValue';
import { useHandleCheckoutSession } from '@/billing/hooks/useHandleCheckoutSession';
import { InformationBanner } from '@/information-banner/components/InformationBanner';
export const InformationBannerNoBillingSubscription = () => {
const { handleCheckoutSession, isSubmitting } = useHandleCheckoutSession({
recurringInterval: BILLING_CHECKOUT_SESSION_DEFAULT_VALUE.interval,
plan: BILLING_CHECKOUT_SESSION_DEFAULT_VALUE.plan,
requirePaymentMethod: true,
});
return (
<InformationBanner
variant="danger"
message={`Your workspace does not have an active subscription`}
buttonTitle="Subscribe"
buttonOnClick={() => handleCheckoutSession()}
isButtonDisabled={isSubmitting}
/>
);
};

View File

@ -10,11 +10,14 @@ import { PREFETCH_CONFIG } from '@/prefetch/constants/PrefetchConfig';
import { usePrefetchRunQuery } from '@/prefetch/hooks/internal/usePrefetchRunQuery';
import { PrefetchKey } from '@/prefetch/types/PrefetchKey';
import { View } from '@/views/types/View';
import { useIsWorkspaceActivationStatusSuspended } from '@/workspace/hooks/useIsWorkspaceActivationStatusSuspended';
import { isDefined } from '~/utils/isDefined';
export const PrefetchRunQueriesEffect = () => {
const currentUser = useRecoilValue(currentUserState);
const isWorkspaceSuspended = useIsWorkspaceActivationStatusSuspended();
const { upsertRecordsInCache: upsertViewsInCache } =
usePrefetchRunQuery<View>({
prefetchKey: PrefetchKey.AllViews,
@ -42,7 +45,7 @@ export const PrefetchRunQueriesEffect = () => {
const { result } = useCombinedFindManyRecords({
operationSignatures,
skip: !currentUser,
skip: !currentUser || isWorkspaceSuspended,
});
useEffect(() => {

View File

@ -1,8 +1,10 @@
import { prefetchIsLoadedFamilyState } from '@/prefetch/states/prefetchIsLoadedFamilyState';
import { PrefetchKey } from '@/prefetch/types/PrefetchKey';
import { useIsWorkspaceActivationStatusSuspended } from '@/workspace/hooks/useIsWorkspaceActivationStatusSuspended';
import { useRecoilValue } from 'recoil';
export const useIsPrefetchLoading = () => {
const isWorkspaceSuspended = useIsWorkspaceActivationStatusSuspended();
const isFavoriteFoldersPrefetched = useRecoilValue(
prefetchIsLoadedFamilyState(PrefetchKey.AllFavoritesFolders),
);
@ -15,8 +17,9 @@ export const useIsPrefetchLoading = () => {
);
return (
!areViewsPrefetched ||
!areFavoritesPrefetched ||
!isFavoriteFoldersPrefetched
!isWorkspaceSuspended &&
(!areViewsPrefetched ||
!areFavoritesPrefetched ||
!isFavoriteFoldersPrefetched)
);
};

View File

@ -6,6 +6,7 @@ import { IconX, UndecoratedLink } from 'twenty-ui';
import { isNavigationDrawerExpandedState } from '@/ui/navigation/states/isNavigationDrawerExpanded';
import { navigationDrawerExpandedMemorizedState } from '@/ui/navigation/states/navigationDrawerExpandedMemorizedState';
import { navigationMemorizedUrlState } from '@/ui/navigation/states/navigationMemorizedUrlState';
import { useIsWorkspaceActivationStatusSuspended } from '@/workspace/hooks/useIsWorkspaceActivationStatusSuspended';
type NavigationDrawerBackButtonProps = {
title: string;
@ -51,6 +52,11 @@ export const NavigationDrawerBackButton = ({
navigationDrawerExpandedMemorizedState,
);
const isWorkspaceSuspended = useIsWorkspaceActivationStatusSuspended();
if (isWorkspaceSuspended) {
return <StyledContainer />;
}
return (
<StyledContainer>
<UndecoratedLink

View File

@ -0,0 +1,11 @@
import { useRecoilValue } from 'recoil';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { WorkspaceActivationStatus } from '~/generated/graphql';
export const useIsWorkspaceActivationStatusSuspended = (): boolean => {
const currentWorkspace = useRecoilValue(currentWorkspaceState);
return (
currentWorkspace?.activationStatus === WorkspaceActivationStatus.Suspended
);
};