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

@ -3,50 +3,63 @@ import { Title } from '@/auth/components/Title';
import { useAuth } from '@/auth/hooks/useAuth';
import { billingCheckoutSessionState } from '@/auth/states/billingCheckoutSessionState';
import { SubscriptionBenefit } from '@/billing/components/SubscriptionBenefit';
import { SubscriptionCard } from '@/billing/components/SubscriptionCard';
import { SubscriptionPrice } from '@/billing/components/SubscriptionPrice';
import { TrialCard } from '@/billing/components/TrialCard';
import { useHandleCheckoutSession } from '@/billing/hooks/useHandleCheckoutSession';
import { billingState } from '@/client-config/states/billingState';
import { AppPath } from '@/types/AppPath';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import styled from '@emotion/styled';
import { isNonEmptyString, isNumber } from '@sniptt/guards';
import { useState } from 'react';
import { useRecoilState, useRecoilValue } from 'recoil';
import {
ActionLink,
CAL_LINK,
CardPicker,
isDefined,
Loader,
MainButton,
} from 'twenty-ui';
import {
ProductPriceEntity,
SubscriptionInterval,
useCheckoutSessionMutation,
useGetProductPricesQuery,
} from '~/generated/graphql';
import { isDefined } from '~/utils/isDefined';
import { SubscriptionInterval } from '~/generated-metadata/graphql';
import { useGetProductPricesQuery } from '~/generated/graphql';
const StyledChoosePlanContainer = styled.div`
display: flex;
flex-direction: row;
width: 100%;
margin: ${({ theme }) => theme.spacing(8)} 0
${({ theme }) => theme.spacing(2)};
gap: ${({ theme }) => theme.spacing(2)};
`;
const StyledBenefitsContainer = styled.div`
const StyledSubscriptionContainer = styled.div<{
withLongerMarginBottom: boolean;
}>`
background-color: ${({ theme }) => theme.background.secondary};
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.md};
display: flex;
flex-direction: column;
margin: ${({ theme }) => theme.spacing(8)} 0
${({ theme, withLongerMarginBottom }) =>
theme.spacing(withLongerMarginBottom ? 8 : 2)};
width: 100%;
`;
const StyledSubscriptionPriceContainer = styled.div`
align-items: center;
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
display: flex;
flex-direction: column;
margin: ${({ theme }) => theme.spacing(4)} ${({ theme }) => theme.spacing(3)}
0 ${({ theme }) => theme.spacing(4)};
padding-bottom: ${({ theme }) => theme.spacing(3)};
`;
const StyledBenefitsContainer = styled.div`
box-sizing: border-box;
display: flex;
flex-direction: column;
width: 100%;
gap: 16px;
padding: ${({ theme }) => theme.spacing(4)} ${({ theme }) => theme.spacing(3)};
`;
const StyledChooseTrialContainer = styled.div`
display: flex;
flex-direction: row;
width: 100%;
margin-bottom: ${({ theme }) => theme.spacing(8)};
gap: ${({ theme }) => theme.spacing(2)};
`;
const StyledLinkGroup = styled.div`
@ -71,58 +84,48 @@ const benefits = [
'Email integration',
'Custom objects',
'API & Webhooks',
'Frequent updates',
'And much more',
'1 000 workflow node executions',
];
export const ChooseYourPlan = () => {
const billing = useRecoilValue(billingState);
const [isSubmitting, setIsSubmitting] = useState(false);
const { enqueueSnackBar } = useSnackBar();
const { data: prices } = useGetProductPricesQuery({
variables: { product: 'base-plan' },
});
const price = prices?.getProductPrices?.productPrices.find(
(productPrice) =>
productPrice.recurringInterval === SubscriptionInterval.Month,
);
const hasWithoutCreditCardTrialPeriod = billing?.trialPeriods.some(
(trialPeriod) =>
!trialPeriod.isCreditCardRequired && trialPeriod.duration !== 0,
);
const withCreditCardTrialPeriod = billing?.trialPeriods.find(
(trialPeriod) => trialPeriod.isCreditCardRequired,
);
const [billingCheckoutSession, setBillingCheckoutSession] = useRecoilState(
billingCheckoutSessionState,
);
const [checkoutSession] = useCheckoutSessionMutation();
const { handleCheckoutSession, isSubmitting } = useHandleCheckoutSession({
recurringInterval: billingCheckoutSession.interval,
plan: billingCheckoutSession.plan,
requirePaymentMethod: billingCheckoutSession.requirePaymentMethod,
});
const handleCheckoutSession = async () => {
setIsSubmitting(true);
const { data } = await checkoutSession({
variables: {
recurringInterval: billingCheckoutSession.interval,
successUrlPath: AppPath.PlanRequiredSuccess,
plan: billingCheckoutSession.plan,
requirePaymentMethod: billingCheckoutSession.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);
};
const handleIntervalChange = (type?: SubscriptionInterval) => {
const handleTrialPeriodChange = (withCreditCard: boolean) => {
return () => {
if (isNonEmptyString(type) && billingCheckoutSession.interval !== type) {
if (
isDefined(price) &&
billingCheckoutSession.requirePaymentMethod !== withCreditCard
) {
setBillingCheckoutSession({
plan: billingCheckoutSession.plan,
interval: type,
requirePaymentMethod: billingCheckoutSession.requirePaymentMethod,
skipPlanPage: false,
interval: price.recurringInterval,
requirePaymentMethod: withCreditCard,
});
}
};
@ -130,65 +133,58 @@ export const ChooseYourPlan = () => {
const { signOut } = useAuth();
const computeInfo = (
price: ProductPriceEntity,
prices: ProductPriceEntity[],
): string => {
if (price.recurringInterval !== SubscriptionInterval.Year) {
return 'Cancel anytime';
}
const monthPrice = prices.filter(
(price) => price.recurringInterval === SubscriptionInterval.Month,
)?.[0];
if (
isDefined(monthPrice) &&
isNumber(monthPrice.unitAmount) &&
monthPrice.unitAmount > 0 &&
isNumber(price.unitAmount) &&
price.unitAmount > 0
) {
return `Save $${(12 * monthPrice.unitAmount - price.unitAmount) / 100}`;
}
return 'Cancel anytime';
};
if (billingCheckoutSession.skipPlanPage && !isSubmitting) {
handleCheckoutSession();
}
if (billingCheckoutSession.skipPlanPage && isSubmitting) {
return <Loader />;
}
return (
prices?.getProductPrices?.productPrices && (
isDefined(price) &&
isDefined(billing) && (
<>
<Title noMarginTop>Choose your Plan</Title>
<SubTitle>
Enjoy a {billing?.billingFreeTrialDurationInDays}-day free trial
</SubTitle>
<StyledChoosePlanContainer>
{prices.getProductPrices.productPrices.map((price, index) => (
<CardPicker
checked={
price.recurringInterval === billingCheckoutSession.interval
}
handleChange={handleIntervalChange(price.recurringInterval)}
key={index}
>
<SubscriptionCard
type={price.recurringInterval}
price={price.unitAmount / 100}
info={computeInfo(price, prices.getProductPrices.productPrices)}
/>
</CardPicker>
))}
</StyledChoosePlanContainer>
<StyledBenefitsContainer>
{benefits.map((benefit, index) => (
<SubscriptionBenefit key={index}>{benefit}</SubscriptionBenefit>
))}
</StyledBenefitsContainer>
<Title noMarginTop>
{hasWithoutCreditCardTrialPeriod
? 'Choose your Trial'
: 'Get your subscription'}
</Title>
{hasWithoutCreditCardTrialPeriod ? (
<SubTitle>Cancel anytime</SubTitle>
) : (
withCreditCardTrialPeriod && (
<SubTitle>{`Enjoy a ${withCreditCardTrialPeriod.duration}-days free trial`}</SubTitle>
)
)}
<StyledSubscriptionContainer
withLongerMarginBottom={!hasWithoutCreditCardTrialPeriod}
>
<StyledSubscriptionPriceContainer>
<SubscriptionPrice
type={price.recurringInterval}
price={price.unitAmount / 100}
/>
</StyledSubscriptionPriceContainer>
<StyledBenefitsContainer>
{benefits.map((benefit) => (
<SubscriptionBenefit key={benefit}>{benefit}</SubscriptionBenefit>
))}
</StyledBenefitsContainer>
</StyledSubscriptionContainer>
{hasWithoutCreditCardTrialPeriod && (
<StyledChooseTrialContainer>
{billing.trialPeriods.map((trialPeriod) => (
<CardPicker
checked={
billingCheckoutSession.requirePaymentMethod ===
trialPeriod.isCreditCardRequired
}
handleChange={handleTrialPeriodChange(
trialPeriod.isCreditCardRequired,
)}
key={trialPeriod.duration}
>
<TrialCard
duration={trialPeriod.duration}
withCreditCard={trialPeriod.isCreditCardRequired}
/>
</CardPicker>
))}
</StyledChooseTrialContainer>
)}
<MainButton
title="Continue"
onClick={handleCheckoutSession}

View File

@ -40,14 +40,14 @@ const meta: Meta<PageDecoratorArgs> = {
{
__typename: 'ProductPriceEntity',
created: 1699860608,
recurringInterval: 'month',
recurringInterval: 'Month',
stripePriceId: 'monthly8usd',
unitAmount: 900,
},
{
__typename: 'ProductPriceEntity',
created: 1701874964,
recurringInterval: 'year',
recurringInterval: 'Year',
stripePriceId: 'priceId',
unitAmount: 9000,
},
@ -56,7 +56,7 @@ const meta: Meta<PageDecoratorArgs> = {
},
});
}),
graphqlMocks.handlers,
...graphqlMocks.handlers,
],
},
},
@ -70,7 +70,7 @@ export const Default: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByText('Choose your Plan', undefined, {
await canvas.findByText('Choose your Trial', undefined, {
timeout: 3000,
});
},

View File

@ -6,7 +6,6 @@ import {
IconCalendarEvent,
IconCircleX,
IconCreditCard,
Info,
Section,
} from 'twenty-ui';
@ -15,7 +14,6 @@ import { SettingsBillingCoverImage } from '@/billing/components/SettingsBillingC
import { useOnboardingStatus } from '@/onboarding/hooks/useOnboardingStatus';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath';
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';
@ -25,7 +23,6 @@ import { useSubscriptionStatus } from '@/workspace/hooks/useSubscriptionStatus';
import {
OnboardingStatus,
SubscriptionInterval,
SubscriptionStatus,
useBillingPortalSessionQuery,
useUpdateBillingSubscriptionMutation,
} from '~/generated/graphql';
@ -87,17 +84,6 @@ export const SettingsBilling = () => {
billingPortalButtonDisabled ||
onboardingStatus !== OnboardingStatus.Completed;
const displayPaymentFailInfo =
subscriptionStatus === SubscriptionStatus.PastDue ||
subscriptionStatus === SubscriptionStatus.Unpaid;
const displaySubscriptionCanceledInfo =
subscriptionStatus === SubscriptionStatus.Canceled;
const displaySubscribeInfo =
onboardingStatus === OnboardingStatus.Completed &&
!isDefined(subscriptionStatus);
const openBillingPortal = () => {
if (isDefined(data) && isDefined(data.billingPortalSession.url)) {
window.location.replace(data.billingPortalSession.url);
@ -147,30 +133,7 @@ export const SettingsBilling = () => {
>
<SettingsPageContainer>
<SettingsBillingCoverImage />
{displayPaymentFailInfo && (
<Info
text={'Last payment failed. Please update your billing details.'}
buttonTitle={'Update'}
accent={'danger'}
onClick={openBillingPortal}
/>
)}
{displaySubscriptionCanceledInfo && (
<Info
text={'Subscription canceled. Please start a new one'}
buttonTitle={'Subscribe'}
accent={'danger'}
to={AppPath.PlanRequired}
/>
)}
{displaySubscribeInfo ? (
<Info
text={'Your workspace does not have an active subscription'}
buttonTitle={'Subscribe'}
accent={'danger'}
to={AppPath.PlanRequired}
/>
) : (
{isDefined(subscriptionStatus) && (
<>
<Section>
<H2Title