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:
@ -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}
|
||||
|
||||
@ -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,
|
||||
});
|
||||
},
|
||||
|
||||
@ -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
|
||||
|
||||
Reference in New Issue
Block a user