Update billing page ctas (#12459)
## Before  ## After <img width="1056" alt="image" src="https://github.com/user-attachments/assets/4a51b7c7-898b-485f-95e8-97911292f2b1" /> <img width="1299" alt="image" src="https://github.com/user-attachments/assets/44e5e545-a660-455a-91be-3b139ccb9f30" /> <img width="1180" alt="image" src="https://github.com/user-attachments/assets/0ca765a7-1d9a-473a-b7d2-c6f9b1a72417" /> <img width="963" alt="image" src="https://github.com/user-attachments/assets/b620fd8a-61c9-4dd3-a3b1-e4ba940371e4" /> <img width="863" alt="image" src="https://github.com/user-attachments/assets/a0d2dcb5-19e5-4f83-80d4-ad5a715f1e5f" /> --------- Co-authored-by: Charles Bochet <charlesBochet@users.noreply.github.com>
This commit is contained in:
@ -10,18 +10,7 @@ import { BACKGROUND_LIGHT, COLOR } from 'twenty-ui/theme';
|
||||
import { SubscriptionStatus } from '~/generated/graphql';
|
||||
import { formatAmount } from '~/utils/format/formatAmount';
|
||||
import { formatNumber } from '~/utils/format/number';
|
||||
|
||||
const StyledMonthlyCreditsContainer = styled.div`
|
||||
border-radius: ${({ theme }) => theme.border.radius.md};
|
||||
border: 1px solid ${({ theme }) => theme.border.color.medium};
|
||||
background-color: ${({ theme }) => theme.background.secondary};
|
||||
padding: ${({ theme }) => theme.spacing(3)};
|
||||
width: 100%;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${({ theme }) => theme.spacing(3)};
|
||||
`;
|
||||
import { SubscriptionInfoContainer } from '@/billing/components/SubscriptionInfoContainer';
|
||||
|
||||
const StyledLineSeparator = styled.div`
|
||||
width: 100%;
|
||||
@ -57,7 +46,7 @@ export const SettingsBillingMonthlyCreditsSection = () => {
|
||||
title={t`Monthly Credits`}
|
||||
description={t`Track your monthly workflow credit consumption.`}
|
||||
/>
|
||||
<StyledMonthlyCreditsContainer>
|
||||
<SubscriptionInfoContainer>
|
||||
<SettingsBillingLabelValueItem
|
||||
label={t`Free Credits Used`}
|
||||
value={`${formattedFreeUsageQuantity}/${formatAmount(includedFreeQuantity)}`}
|
||||
@ -91,7 +80,7 @@ export const SettingsBillingMonthlyCreditsSection = () => {
|
||||
value={`$${formatNumber(totalCostCents / 100, 2)}`}
|
||||
/>
|
||||
)}
|
||||
</StyledMonthlyCreditsContainer>
|
||||
</SubscriptionInfoContainer>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
@ -0,0 +1,238 @@
|
||||
import { SubscriptionInfoContainer } from '@/billing/components/SubscriptionInfoContainer';
|
||||
import { SubscriptionInfoRowContainer } from '@/billing/components/SubscriptionInfoRowContainer';
|
||||
|
||||
import {
|
||||
H2Title,
|
||||
IconCalendarEvent,
|
||||
IconTag,
|
||||
IconUsers,
|
||||
IconArrowUp,
|
||||
} from 'twenty-ui/display';
|
||||
import { Button } from 'twenty-ui/input';
|
||||
import { Section } from 'twenty-ui/layout';
|
||||
import styled from '@emotion/styled';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import {
|
||||
BillingPlanKey,
|
||||
BillingPlanOutput,
|
||||
BillingProductKey,
|
||||
SubscriptionInterval,
|
||||
SubscriptionStatus,
|
||||
useBillingBaseProductPricesQuery,
|
||||
useSwitchSubscriptionToEnterprisePlanMutation,
|
||||
useSwitchSubscriptionToYearlyIntervalMutation,
|
||||
} from '~/generated/graphql';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
|
||||
import { Tag } from 'twenty-ui/components';
|
||||
import { useModal } from '@/ui/layout/modal/hooks/useModal';
|
||||
import { useSubscriptionStatus } from '@/workspace/hooks/useSubscriptionStatus';
|
||||
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
|
||||
import { formatMonthlyPrices } from '@/billing/utils/formatMonthlyPrices';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
|
||||
const SWITCH_BILLING_INTERVAL_MODAL_ID = 'switch-billing-interval-modal';
|
||||
|
||||
const SWITCH_BILLING_PLAN_MODAL_ID = 'switch-billing-plan-modal';
|
||||
|
||||
const StyledSwitchButtonContainer = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: ${({ theme }) => theme.spacing(2)};
|
||||
margin-top: ${({ theme }) => theme.spacing(4)};
|
||||
`;
|
||||
|
||||
export const SettingsBillingSubscriptionInfo = () => {
|
||||
const { t } = useLingui();
|
||||
|
||||
const { openModal } = useModal();
|
||||
|
||||
const { enqueueSnackBar } = useSnackBar();
|
||||
|
||||
const subscriptionStatus = useSubscriptionStatus();
|
||||
|
||||
const { data: pricesData } = useBillingBaseProductPricesQuery();
|
||||
|
||||
const [switchToYearlyInterval] =
|
||||
useSwitchSubscriptionToYearlyIntervalMutation();
|
||||
|
||||
const [switchToEnterprisePlan] =
|
||||
useSwitchSubscriptionToEnterprisePlanMutation();
|
||||
|
||||
const [currentWorkspace, setCurrentWorkspace] = useRecoilState(
|
||||
currentWorkspaceState,
|
||||
);
|
||||
|
||||
const isMonthlyPlan =
|
||||
currentWorkspace?.currentBillingSubscription?.interval ===
|
||||
SubscriptionInterval.Month;
|
||||
|
||||
const isYearlyPlan =
|
||||
currentWorkspace?.currentBillingSubscription?.interval ===
|
||||
SubscriptionInterval.Year;
|
||||
|
||||
const isProPlan =
|
||||
currentWorkspace?.currentBillingSubscription?.metadata['plan'] ===
|
||||
BillingPlanKey.PRO;
|
||||
|
||||
const isEnterprisePlan =
|
||||
currentWorkspace?.currentBillingSubscription?.metadata['plan'] ===
|
||||
BillingPlanKey.ENTERPRISE;
|
||||
|
||||
const canSwitchSubscription =
|
||||
subscriptionStatus !== SubscriptionStatus.PastDue;
|
||||
|
||||
const planTag = isProPlan ? (
|
||||
<Tag color={'sky'} text={t`Pro`} />
|
||||
) : isEnterprisePlan ? (
|
||||
<Tag color={'purple'} text={t`Organization`} />
|
||||
) : undefined;
|
||||
|
||||
const intervalLabel = isMonthlyPlan
|
||||
? t`Monthly`
|
||||
: isYearlyPlan
|
||||
? t`Yearly`
|
||||
: undefined;
|
||||
|
||||
const seats =
|
||||
currentWorkspace?.currentBillingSubscription?.billingSubscriptionItems?.find(
|
||||
(item) =>
|
||||
item.billingProduct?.metadata.productKey ===
|
||||
BillingProductKey.BASE_PRODUCT,
|
||||
)?.quantity as number | undefined;
|
||||
|
||||
const baseProductPrices = pricesData?.plans as BillingPlanOutput[];
|
||||
|
||||
const formattedPrices = formatMonthlyPrices(baseProductPrices);
|
||||
|
||||
const yearlyPrice =
|
||||
formattedPrices?.[
|
||||
currentWorkspace?.currentBillingSubscription?.metadata[
|
||||
'plan'
|
||||
] as BillingPlanKey
|
||||
]?.[SubscriptionInterval.Year];
|
||||
|
||||
const enterprisePrice =
|
||||
formattedPrices?.[BillingPlanKey.ENTERPRISE]?.[
|
||||
currentWorkspace?.currentBillingSubscription?.interval as
|
||||
| SubscriptionInterval.Month
|
||||
| SubscriptionInterval.Year
|
||||
];
|
||||
|
||||
const switchInterval = async () => {
|
||||
try {
|
||||
await switchToYearlyInterval();
|
||||
if (isDefined(currentWorkspace?.currentBillingSubscription)) {
|
||||
const newCurrentWorkspace = {
|
||||
...currentWorkspace,
|
||||
currentBillingSubscription: {
|
||||
...currentWorkspace?.currentBillingSubscription,
|
||||
interval: SubscriptionInterval.Year,
|
||||
},
|
||||
};
|
||||
setCurrentWorkspace(newCurrentWorkspace);
|
||||
}
|
||||
enqueueSnackBar(t`Subscription has been switched to Yearly.`, {
|
||||
variant: SnackBarVariant.Success,
|
||||
});
|
||||
} catch (error: any) {
|
||||
enqueueSnackBar(t`Error while switching subscription to Yearly.`, {
|
||||
variant: SnackBarVariant.Error,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const switchPlan = async () => {
|
||||
try {
|
||||
await switchToEnterprisePlan();
|
||||
if (isDefined(currentWorkspace?.currentBillingSubscription)) {
|
||||
const newCurrentWorkspace = {
|
||||
...currentWorkspace,
|
||||
currentBillingSubscription: {
|
||||
...currentWorkspace?.currentBillingSubscription,
|
||||
metadata: {
|
||||
...currentWorkspace?.currentBillingSubscription.metadata,
|
||||
plan: BillingPlanKey.ENTERPRISE,
|
||||
},
|
||||
},
|
||||
};
|
||||
setCurrentWorkspace(newCurrentWorkspace);
|
||||
}
|
||||
enqueueSnackBar(t`Subscription has been switched to Organization Plan.`, {
|
||||
variant: SnackBarVariant.Success,
|
||||
});
|
||||
} catch (error: any) {
|
||||
enqueueSnackBar(
|
||||
t`Error while switching subscription to Organization Plan.`,
|
||||
{
|
||||
variant: SnackBarVariant.Error,
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Section>
|
||||
<H2Title title={t`Subscription`} description={t`About my subscription`} />
|
||||
<SubscriptionInfoContainer>
|
||||
<SubscriptionInfoRowContainer
|
||||
label={t`Plan`}
|
||||
Icon={IconTag}
|
||||
value={planTag}
|
||||
/>
|
||||
<SubscriptionInfoRowContainer
|
||||
label={t`Billing interval`}
|
||||
Icon={IconCalendarEvent}
|
||||
value={intervalLabel}
|
||||
/>
|
||||
<SubscriptionInfoRowContainer
|
||||
label={t`Seats`}
|
||||
Icon={IconUsers}
|
||||
value={seats}
|
||||
/>
|
||||
</SubscriptionInfoContainer>
|
||||
<StyledSwitchButtonContainer>
|
||||
{isMonthlyPlan && (
|
||||
<Button
|
||||
Icon={IconArrowUp}
|
||||
title={t`Switch to Yearly`}
|
||||
variant="secondary"
|
||||
onClick={() => openModal(SWITCH_BILLING_INTERVAL_MODAL_ID)}
|
||||
disabled={!canSwitchSubscription}
|
||||
/>
|
||||
)}
|
||||
{isProPlan && (
|
||||
<Button
|
||||
Icon={IconArrowUp}
|
||||
title={t`Switch to Organization`}
|
||||
variant="secondary"
|
||||
onClick={() => openModal(SWITCH_BILLING_PLAN_MODAL_ID)}
|
||||
disabled={!canSwitchSubscription}
|
||||
/>
|
||||
)}
|
||||
</StyledSwitchButtonContainer>
|
||||
<ConfirmationModal
|
||||
modalId={SWITCH_BILLING_INTERVAL_MODAL_ID}
|
||||
title={t`Change to Yearly?`}
|
||||
subtitle={t`You will be charged $${yearlyPrice} per user per month billed annually. A prorata with your current subscription will be applied.`}
|
||||
onConfirmClick={switchInterval}
|
||||
confirmButtonText={t`Confirm`}
|
||||
confirmButtonAccent={'blue'}
|
||||
/>
|
||||
<ConfirmationModal
|
||||
modalId={SWITCH_BILLING_PLAN_MODAL_ID}
|
||||
title={t`Change to Organization Plan?`}
|
||||
subtitle={
|
||||
isYearlyPlan
|
||||
? t`You will be charged $${enterprisePrice} per user per month billed annually.`
|
||||
: t`You will be charged $${enterprisePrice} per user per month.`
|
||||
}
|
||||
onConfirmClick={switchPlan}
|
||||
confirmButtonText={t`Confirm`}
|
||||
confirmButtonAccent={'blue'}
|
||||
/>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,14 @@
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
const StyledSubscriptionInfoContainer = styled.div`
|
||||
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;
|
||||
gap: ${({ theme }) => theme.spacing(3)};
|
||||
padding: ${({ theme }) => theme.spacing(3)};
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export { StyledSubscriptionInfoContainer as SubscriptionInfoContainer };
|
||||
@ -0,0 +1,48 @@
|
||||
import { IconComponent } from 'twenty-ui/display';
|
||||
import React from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import { useTheme } from '@emotion/react';
|
||||
|
||||
type SubscriptionInfoRowContainerProps = {
|
||||
Icon: IconComponent;
|
||||
label: string;
|
||||
value: React.ReactNode;
|
||||
};
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
align-items: center;
|
||||
gap: ${({ theme }) => theme.spacing(1)};
|
||||
color: ${({ theme }) => theme.font.color.primary};
|
||||
display: flex;
|
||||
`;
|
||||
|
||||
const StyledIconLabelContainer = styled.div`
|
||||
align-items: center;
|
||||
gap: ${({ theme }) => theme.spacing(1)};
|
||||
color: ${({ theme }) => theme.font.color.tertiary};
|
||||
display: flex;
|
||||
width: 120px;
|
||||
`;
|
||||
|
||||
const StyledLabelContainer = styled.div`
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
export const SubscriptionInfoRowContainer = ({
|
||||
Icon,
|
||||
label,
|
||||
value,
|
||||
}: SubscriptionInfoRowContainerProps) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<StyledContainer>
|
||||
<StyledIconLabelContainer>
|
||||
<Icon size={theme.icon.size.md} />
|
||||
<StyledLabelContainer>{label}</StyledLabelContainer>
|
||||
</StyledIconLabelContainer>
|
||||
{value}
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,9 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const SWITCH_SUBSCRIPTION_TO_ENTERPRISE_PLAN = gql`
|
||||
mutation SwitchSubscriptionToEnterprisePlan {
|
||||
switchToEnterprisePlan {
|
||||
success
|
||||
}
|
||||
}
|
||||
`;
|
||||
@ -0,0 +1,46 @@
|
||||
import {
|
||||
BillingPlanKey,
|
||||
SubscriptionInterval,
|
||||
BillingPlanOutput,
|
||||
BillingPriceLicensedDto,
|
||||
} from '~/generated/graphql';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
export const formatMonthlyPrices = (plans: BillingPlanOutput[] | undefined) => {
|
||||
if (!isDefined(plans)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const enterprisePlan = plans.find(
|
||||
(plan) => plan.planKey === BillingPlanKey.ENTERPRISE,
|
||||
);
|
||||
|
||||
const enterpriseYearPrice = enterprisePlan?.baseProduct.prices?.find(
|
||||
(price) => price.recurringInterval === SubscriptionInterval.Year,
|
||||
) as BillingPriceLicensedDto;
|
||||
|
||||
const enterpriseMonthPrice = enterprisePlan?.baseProduct.prices?.find(
|
||||
(price) => price.recurringInterval === SubscriptionInterval.Month,
|
||||
) as BillingPriceLicensedDto;
|
||||
|
||||
const proPlan = plans.find((plan) => plan.planKey === BillingPlanKey.PRO);
|
||||
|
||||
const proYearPrice = proPlan?.baseProduct.prices?.find(
|
||||
(price) => price.recurringInterval === SubscriptionInterval.Year,
|
||||
) as BillingPriceLicensedDto;
|
||||
|
||||
const proMonthPrice = proPlan?.baseProduct.prices?.find(
|
||||
(price) => price.recurringInterval === SubscriptionInterval.Month,
|
||||
) as BillingPriceLicensedDto;
|
||||
|
||||
return {
|
||||
[BillingPlanKey.ENTERPRISE]: {
|
||||
[SubscriptionInterval.Year]: enterpriseYearPrice?.unitAmount / 100 / 12,
|
||||
[SubscriptionInterval.Month]: enterpriseMonthPrice?.unitAmount / 100,
|
||||
},
|
||||
[BillingPlanKey.PRO]: {
|
||||
[SubscriptionInterval.Year]: proYearPrice?.unitAmount / 100 / 12,
|
||||
[SubscriptionInterval.Month]: proMonthPrice?.unitAmount / 100,
|
||||
},
|
||||
};
|
||||
};
|
||||
@ -38,11 +38,13 @@ const Wrapper = getJestMetadataAndApolloMocksAndActionMenuWrapper({
|
||||
id: '1',
|
||||
interval: SubscriptionInterval.Month,
|
||||
status: SubscriptionStatus.Active,
|
||||
metadata: {},
|
||||
},
|
||||
billingSubscriptions: [
|
||||
{
|
||||
id: '1',
|
||||
status: SubscriptionStatus.Active,
|
||||
metadata: {},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@ -37,7 +37,7 @@ const renderHooks = (
|
||||
result.current.setCurrentWorkspace({
|
||||
...mockCurrentWorkspace,
|
||||
currentBillingSubscription: withCurrentBillingSubscription
|
||||
? { id: v4(), status: SubscriptionStatus.Active }
|
||||
? { id: v4(), status: SubscriptionStatus.Active, metadata: {} }
|
||||
: undefined,
|
||||
workspaceMembersCount: withOneWorkspaceMember ? 1 : 2,
|
||||
});
|
||||
|
||||
@ -60,9 +60,11 @@ export const USER_QUERY_FRAGMENT = gql`
|
||||
id
|
||||
status
|
||||
interval
|
||||
metadata
|
||||
billingSubscriptionItems {
|
||||
id
|
||||
hasReachedCurrentPeriodCap
|
||||
quantity
|
||||
billingProduct {
|
||||
name
|
||||
description
|
||||
@ -77,6 +79,7 @@ export const USER_QUERY_FRAGMENT = gql`
|
||||
billingSubscriptions {
|
||||
id
|
||||
status
|
||||
metadata
|
||||
}
|
||||
workspaceMembersCount
|
||||
defaultRole {
|
||||
|
||||
@ -50,6 +50,7 @@ describe('useSubscriptionStatus', () => {
|
||||
currentBillingSubscription: {
|
||||
id: v4(),
|
||||
status: subscriptionStatus,
|
||||
metadata: {},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user