Add billing plans comparison page (#22424)

## What changed

- Added a Billing > Plans tab with a Pro vs Organization comparison
table.
- Updated subscription card CTAs so Compare plans routes to the new
Plans tab, while upgrade/downgrade actions stay inside the comparison
page.
- Added a reusable segmented control and used it for the billing period
toggle and navigation drawer tabs.
- Hid billing pages/navigation when billing is disabled, including
self-hosted environments.

<img width="1417" height="882"
alt="file-f98283057b5a700f275cde2a38831ac3"
src="https://github.com/user-attachments/assets/182a7ff4-51fa-492e-8c75-51f9dc35b59e"
/>


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22424?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->

---------

Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com>
This commit is contained in:
Thomas des Francs
2026-07-03 10:30:43 +02:00
committed by GitHub
parent 3aeb2b0d5d
commit 416f4cf90e
41 changed files with 2063 additions and 218 deletions
@@ -382,6 +382,12 @@ const SettingsBilling = lazy(() =>
})),
);
const SettingsBillingPlans = lazy(() =>
import('~/pages/settings/billing/SettingsBillingPlans').then((module) => ({
default: module.SettingsBillingPlans,
})),
);
const SettingsUsage = lazy(() =>
import('~/pages/settings/billing/SettingsUsage').then((module) => ({
default: module.SettingsUsage,
@@ -677,6 +683,10 @@ export const SettingsRoutes = ({ isAdminPageEnabled }: SettingsRoutesProps) => (
element={<SettingsWorkspaceUnsubscribeTopicDetail />}
/>
<Route path={SettingsPath.Billing} element={<SettingsBilling />} />
<Route
path={SettingsPath.BillingPlans}
element={<SettingsBillingPlans />}
/>
<Route path={SettingsPath.Usage} element={<SettingsUsage />} />
<Route
path={SettingsPath.UsageUserDetail}
@@ -1,39 +1,30 @@
import { useLingui } from '@lingui/react/macro';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useRedirect } from '@/domain-manager/hooks/useRedirect';
import { SettingsBillingCreditsSection } from '@/settings/billing/components/SettingsBillingCreditsSection';
import { SettingsBillingSubscriptionInfo } from '@/settings/billing/components/SettingsBillingSubscriptionInfo';
import { SettingsBillingTrialNoPaymentMethodBanner } from '@/settings/billing/components/SettingsBillingTrialNoPaymentMethodBanner';
import { useBillingPortalSession } from '@/settings/billing/hooks/useBillingPortalSession';
import { useGetResourceCreditUsage } from '@/settings/billing/hooks/useGetResourceCreditUsage';
import { billingHasPaymentMethodSelector } from '@/settings/billing/states/billingHasPaymentMethodSelector';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useSubscriptionStatus } from '@/workspace/hooks/useSubscriptionStatus';
import { useQuery } from '@apollo/client/react';
import { isDefined } from 'twenty-shared/utils';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import { IconCircleX, IconCreditCard } from 'twenty-ui/icon';
import { H2Title } from 'twenty-ui/typography';
import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import {
BillingPortalSessionDocument,
SubscriptionStatus,
} from '~/generated-metadata/graphql';
import { SubscriptionStatus } from '~/generated-metadata/graphql';
export const SettingsBillingContent = () => {
const { t } = useLingui();
const { redirect } = useRedirect();
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
const currentBillingSubscription =
currentWorkspace?.currentBillingSubscription;
const subscriptions = currentWorkspace?.billingSubscriptions;
const hasSubscriptions = (subscriptions?.length ?? 0) > 0;
const subscriptionStatus = useSubscriptionStatus();
const billingHasPaymentMethod = useAtomStateValue(
billingHasPaymentMethodSelector,
@@ -55,21 +46,8 @@ export const SettingsBillingContent = () => {
const canCancelCurrentSubscription =
hasNotCanceledCurrentSubscription && !hasScheduledCancellation;
const { data, loading } = useQuery(BillingPortalSessionDocument, {
variables: {
returnUrlPath: '/settings/billing',
},
skip: !hasSubscriptions,
});
const billingPortalButtonDisabled =
loading || !isDefined(data) || !isDefined(data.billingPortalSession.url);
const openBillingPortal = () => {
if (isDefined(data) && isDefined(data.billingPortalSession.url)) {
redirect(data.billingPortalSession.url);
}
};
const { isBillingPortalSessionDisabled, openBillingPortal } =
useBillingPortalSession(getSettingsPath(SettingsPath.Billing));
return (
<SettingsPageContainer>
@@ -85,7 +63,7 @@ export const SettingsBillingContent = () => {
currentWorkspace={currentWorkspace}
currentBillingSubscription={currentBillingSubscription}
onUpdatePayment={openBillingPortal}
isUpdatePaymentDisabled={billingPortalButtonDisabled}
isUpdatePaymentDisabled={isBillingPortalSessionDisabled}
/>
)}
{hasNotCanceledCurrentSubscription &&
@@ -95,7 +73,7 @@ export const SettingsBillingContent = () => {
<SettingsBillingCreditsSection
currentBillingSubscription={currentBillingSubscription}
onUpdatePayment={openBillingPortal}
isUpdatePaymentDisabled={billingPortalButtonDisabled}
isUpdatePaymentDisabled={isBillingPortalSessionDisabled}
/>
)}
<Section>
@@ -108,7 +86,7 @@ export const SettingsBillingContent = () => {
title={t`View billing details`}
variant="secondary"
onClick={openBillingPortal}
disabled={billingPortalButtonDisabled}
disabled={isBillingPortalSessionDisabled}
/>
</Section>
{canCancelCurrentSubscription && (
@@ -123,7 +101,7 @@ export const SettingsBillingContent = () => {
variant="secondary"
accent="danger"
onClick={openBillingPortal}
disabled={billingPortalButtonDisabled}
disabled={isBillingPortalSessionDisabled}
/>
</Section>
)}
@@ -0,0 +1,21 @@
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { styled } from '@linaria/react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledBillingContentSkeleton = styled.div`
background: linear-gradient(
90deg,
${themeCssVariables.background.transparent.light} 0%,
${themeCssVariables.background.transparent.lighter} 100%
);
border-radius: ${themeCssVariables.border.radius.md} 0 0
${themeCssVariables.border.radius.md};
height: ${themeCssVariables.spacing[6]};
width: 160px;
`;
export const SettingsBillingContentSkeleton = () => (
<SettingsPageContainer>
<StyledBillingContentSkeleton />
</SettingsPageContainer>
);
@@ -0,0 +1,63 @@
import { Trans, useLingui } from '@lingui/react/macro';
import { type ReactNode } from 'react';
import { Navigate } from 'react-router-dom';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import { useSnackBarOnQueryError } from '@/apollo/hooks/useSnackBarOnQueryError';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { billingState } from '@/client-config/states/billingState';
import { SettingsBillingContentSkeleton } from '@/settings/billing/components/SettingsBillingContentSkeleton';
import { SettingsBillingTabBar } from '@/settings/billing/components/SettingsBillingTabBar';
import { usePlans } from '@/settings/billing/hooks/usePlans';
import { SettingsPageLayout } from '@/settings/components/layout/SettingsPageLayout';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
export const SettingsBillingPageLayout = ({
children,
}: {
children: ReactNode;
}) => {
const { t } = useLingui();
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
const billing = useAtomStateValue(billingState);
const isBillingLoaded = isDefined(billing);
const isBillingEnabled = billing?.isBillingEnabled ?? false;
const {
error: plansError,
isPlansLoaded,
loading: arePlansLoading,
} = usePlans({ skip: !isBillingEnabled });
useSnackBarOnQueryError(plansError, t`Failed to load billing plans`);
if (isBillingLoaded && !isBillingEnabled) {
return <Navigate to={getSettingsPath(SettingsPath.General)} replace />;
}
return (
<SettingsPageLayout
title={t`Billing`}
links={[
{
children: <Trans>Workspace</Trans>,
href: getSettingsPath(SettingsPath.General),
},
{ children: <Trans>Billing</Trans> },
]}
secondaryBar={<SettingsBillingTabBar />}
>
{!isDefined(currentWorkspace) ||
!isBillingLoaded ||
(isBillingEnabled && arePlansLoading) ? (
<SettingsBillingContentSkeleton />
) : isPlansLoaded ? (
children
) : (
<></>
)}
</SettingsPageLayout>
);
};
@@ -0,0 +1,53 @@
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { SettingsBillingPlansWithSubscription } from '@/settings/billing/components/internal/SettingsBillingPlansWithSubscription';
import { SettingsBillingPlansWithoutSubscription } from '@/settings/billing/components/internal/SettingsBillingPlansWithoutSubscription';
import { useFormatPrices } from '@/settings/billing/hooks/useFormatPrices';
import { type SettingsBillingPlanInterval } from '@/settings/billing/types/settingsBillingPlanComparison.type';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import {
BillingPlanKey,
SubscriptionInterval,
} from '~/generated-metadata/graphql';
const parseCurrentPlanKey = (plan: unknown): BillingPlanKey | undefined => {
if (plan === BillingPlanKey.PRO || plan === BillingPlanKey.ENTERPRISE) {
return plan;
}
return undefined;
};
export const SettingsBillingPlansContent = () => {
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
const { formatPrices: planPrices } = useFormatPrices();
const [billingInterval, setBillingInterval] =
useState<SettingsBillingPlanInterval>(SubscriptionInterval.Year);
const currentPlanKey = parseCurrentPlanKey(
currentWorkspace?.currentBillingSubscription?.metadata?.['plan'],
);
if (
!isDefined(currentPlanKey) ||
!isDefined(currentWorkspace?.currentBillingSubscription)
) {
return (
<SettingsBillingPlansWithoutSubscription
billingInterval={billingInterval}
onBillingIntervalChange={setBillingInterval}
planPrices={planPrices}
/>
);
}
return (
<SettingsBillingPlansWithSubscription
billingInterval={billingInterval}
currentPlanKey={currentPlanKey}
onBillingIntervalChange={setBillingInterval}
planPrices={planPrices}
/>
);
};
@@ -38,7 +38,6 @@ import {
PermissionFlagType,
SubscriptionInterval,
SubscriptionStatus,
SwitchBillingPlanDocument,
SwitchSubscriptionIntervalDocument,
} from '~/generated-metadata/graphql';
import { beautifyExactDate } from '~/utils/date-utils';
@@ -69,7 +68,7 @@ export const SettingsBillingSubscriptionInfo = ({
const { currentResourceCreditBillingPrice } = useCurrentResourceCredit();
const { currentPlan, oppositPlan } = useCurrentPlan();
const { currentPlan } = useCurrentPlan();
const { isEnterprisePlan, isYearlyPlan, isMonthlyPlan, isProPlan } =
useCurrentBillingFlags();
const { splitedPhaseItemsInPrices } = useSplitPhaseItemsInPrices();
@@ -91,8 +90,6 @@ export const SettingsBillingSubscriptionInfo = ({
const {
getIntervalLabelAsAdjectiveCapitalize,
confirmationModalSwitchToProMessage,
confirmationModalSwitchToOrganizationMessage,
confirmationModalSwitchToMonthlyMessage,
confirmationModalSwitchToYearlyMessage,
confirmationModalCancelPlanSwitchingMessage,
@@ -104,8 +101,6 @@ export const SettingsBillingSubscriptionInfo = ({
SwitchSubscriptionIntervalDocument,
);
const [switchBillingPlan] = useMutation(SwitchBillingPlanDocument);
const [cancelSwitchBillingInterval] = useMutation(
CancelSwitchBillingIntervalDocument,
);
@@ -277,13 +272,12 @@ export const SettingsBillingSubscriptionInfo = ({
!shouldUpdatePayment &&
isYearlyPlan &&
(!nextInterval || currentInterval === nextInterval);
const canSwitchToOrganizationPlan =
isProPlan && (!nextPlan || currentPlan.planKey === nextPlan.planKey);
const canComparePlans =
(isProPlan || isEnterprisePlan) &&
(!nextPlan || currentPlan.planKey === nextPlan.planKey);
const canCancelIntervalSwitch =
isDefined(nextInterval) && currentInterval !== nextInterval;
const canStartSubscription = isTrialPeriod && hasPermissionToEndTrialPeriod;
const canSwitchToProPlan =
isEnterprisePlan && (!nextPlan || currentPlan.planKey === nextPlan.planKey);
const canCancelPlanSwitch =
isDefined(nextPlan) && currentPlan.planKey !== nextPlan.planKey;
@@ -325,7 +319,6 @@ export const SettingsBillingSubscriptionInfo = ({
: []),
];
const [isSwitchingInterval, setIsSwitchingInterval] = useState(false);
const [isSwitchingPlan, setIsSwitchingPlan] = useState(false);
const [isCancellingPlanSwitch, setIsCancellingPlanSwitch] = useState(false);
const [isCancellingIntervalSwitch, setIsCancellingIntervalSwitch] =
useState(false);
@@ -334,7 +327,6 @@ export const SettingsBillingSubscriptionInfo = ({
const isAnyActionLoading =
isSwitchingInterval ||
isSwitchingPlan ||
isCancellingPlanSwitch ||
isCancellingIntervalSwitch ||
isCancellingMeteredSwitch ||
@@ -413,26 +405,6 @@ export const SettingsBillingSubscriptionInfo = ({
});
};
const switchPlan = async () => {
await runBillingAction({
action: async () => {
const { data } = await switchBillingPlan();
if (isDefined(data?.switchBillingPlan.currentBillingSubscription)) {
applyBillingUpdate(data.switchBillingPlan);
}
},
getErrorMessage: () =>
t`Error while switching subscription to ${oppositPlan} Plan.`,
getSuccessMessage: () => {
return oppositPlan === BillingPlanKey.ENTERPRISE || isTrialPeriod
? t`Subscription has been switched to ${oppositPlan} Plan.`
: t`Subscription will be switched to ${oppositPlan} Plan the ${getBeautifiedRenewDate()}.`;
},
isLoading: isSwitchingPlan,
setIsLoading: setIsSwitchingPlan,
});
};
const cancelPlanSwitching = async () => {
await runBillingAction({
action: async () => {
@@ -504,15 +476,14 @@ export const SettingsBillingSubscriptionInfo = ({
<SettingsBillingSubscriptionInfoCardHeaderActions
canCancelIntervalSwitch={canCancelIntervalSwitch}
canCancelPlanSwitch={canCancelPlanSwitch}
canComparePlans={canComparePlans}
canStartSubscription={canStartSubscription}
canSwitchToOrganizationPlan={canSwitchToOrganizationPlan}
canSwitchToProPlan={canSwitchToProPlan}
isComparePlansActionPrimary={isProPlan && !isTrialPeriod}
isCancellationScheduled={isCancellationScheduled}
isEndTrialPeriodDisabled={
isEndTrialPeriodLoading || isAnyActionLoading
}
isSubscriptionActionDisabled={isSubscriptionActionDisabled}
isTrialPeriod={isTrialPeriod}
isUpdatePaymentDisabled={isUpdatePaymentDisabled}
onCancelIntervalSwitch={() =>
openModal(BILLING_MODAL_IDS.cancelSwitchBillingInterval)
@@ -521,12 +492,6 @@ export const SettingsBillingSubscriptionInfo = ({
openModal(BILLING_MODAL_IDS.cancelSwitchBillingPlan)
}
onEndTrialPeriod={() => openModal(BILLING_MODAL_IDS.endTrialPeriod)}
onSwitchToOrganization={() =>
openModal(BILLING_MODAL_IDS.switchBillingPlanToEnterprise)
}
onSwitchToPro={() =>
openModal(BILLING_MODAL_IDS.switchBillingPlanToPro)
}
onUpdatePayment={onUpdatePayment}
shouldUpdatePayment={shouldUpdatePayment}
/>
@@ -559,17 +524,13 @@ export const SettingsBillingSubscriptionInfo = ({
isCancellingPlanSwitch={isCancellingPlanSwitch}
isEndTrialPeriodLoading={isEndTrialPeriodLoading}
isSwitchingInterval={isSwitchingInterval}
isSwitchingPlan={isSwitchingPlan}
onCancelIntervalSwitching={cancelIntervalSwitching}
onCancelPlanSwitching={cancelPlanSwitching}
onCancelResourceCreditSwitching={cancelResourceCreditSwitching}
onEndTrialPeriod={endTrialPeriod}
onSwitchInterval={switchInterval}
onSwitchPlan={switchPlan}
startSubscriptionSubtitle={startSubscriptionSubtitle}
switchToMonthlySubtitle={confirmationModalSwitchToMonthlyMessage()}
switchToOrganizationSubtitle={confirmationModalSwitchToOrganizationMessage()}
switchToProSubtitle={confirmationModalSwitchToProMessage()}
switchToYearlySubtitle={confirmationModalSwitchToYearlyMessage()}
/>
</Section>
@@ -0,0 +1,55 @@
import { billingState } from '@/client-config/states/billingState';
import { TAB_LIST_GAP } from '@/ui/layout/tab-list/constants/TabListGap';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useLingui } from '@lingui/react/macro';
import { styled } from '@linaria/react';
import { matchPath, useLocation } from 'react-router-dom';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { TabButton } from 'twenty-ui/input';
import { IconColorSwatch, IconCreditCard } from 'twenty-ui/icon';
const StyledTabBar = styled.div`
display: flex;
flex: 1;
gap: ${TAB_LIST_GAP}px;
justify-content: center;
min-width: 0;
`;
export const SettingsBillingTabBar = () => {
const { t } = useLingui();
const location = useLocation();
const billing = useAtomStateValue(billingState);
const isBillingEnabled = billing?.isBillingEnabled ?? false;
if (!isBillingEnabled) {
return null;
}
const billingPath = getSettingsPath(SettingsPath.Billing);
const plansPath = getSettingsPath(SettingsPath.BillingPlans);
const isTabActive = (path: string) =>
matchPath({ path, end: true }, location.pathname) !== null;
return (
<StyledTabBar>
<TabButton
id="billing"
title={t`Billing`}
LeftIcon={IconCreditCard}
active={isTabActive(billingPath)}
to={billingPath}
/>
<TabButton
id="plans"
title={t`Plans`}
LeftIcon={IconColorSwatch}
active={isTabActive(plansPath)}
to={plansPath}
/>
</StyledTabBar>
);
};
@@ -0,0 +1,248 @@
import { AnimatedFormattedNumber } from '@/settings/billing/components/internal/AnimatedFormattedNumber';
import { SettingsBillingPlanComparisonTableRow } from '@/settings/billing/components/internal/SettingsBillingPlanComparisonTableRow';
import { SETTINGS_BILLING_COMPARED_PLAN_KEYS } from '@/settings/billing/constants/SettingsBillingComparedPlanKeys';
import { SETTINGS_BILLING_PLAN_COMPARISON_ROWS } from '@/settings/billing/constants/SettingsBillingPlanComparisonRows';
import { type SettingsBillingPlanAction } from '@/settings/billing/types/settingsBillingPlanAction.type';
import {
type SettingsBillingPlanInterval,
type SettingsBillingPlanPrices,
} from '@/settings/billing/types/settingsBillingPlanComparison.type';
import { useNumberFormat } from '@/localization/hooks/useNumberFormat';
import { useLingui } from '@lingui/react/macro';
import { styled } from '@linaria/react';
import {
Button,
SegmentedControl,
type SegmentedControlOption,
} from 'twenty-ui/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import {
BillingPlanKey,
SubscriptionInterval,
} from '~/generated-metadata/graphql';
type SettingsBillingPlanComparisonTableProps = {
billingInterval: SettingsBillingPlanInterval;
onBillingIntervalChange: (
billingInterval: SettingsBillingPlanInterval,
) => void;
planActions: Record<BillingPlanKey, SettingsBillingPlanAction>;
planPrices: SettingsBillingPlanPrices;
};
const STICKY_HEADER_TOP_OFFSET = themeCssVariables.spacing[6];
const StyledContentContainer = styled.div`
max-width: 694px;
width: 100%;
`;
const StyledStickyHeaderContainer = styled.div`
background: ${themeCssVariables.background.primary};
margin-top: calc(${STICKY_HEADER_TOP_OFFSET} * -1);
padding-top: ${STICKY_HEADER_TOP_OFFSET};
position: sticky;
top: 0;
width: 100%;
z-index: 2;
`;
const StyledComparisonTable = styled.div`
display: grid;
row-gap: ${themeCssVariables.spacing[2]};
width: 100%;
`;
const StyledComparisonHeaderGrid = styled.div`
background: ${themeCssVariables.border.color.medium};
border: 1px solid ${themeCssVariables.border.color.medium};
border-radius: ${themeCssVariables.border.radius.md};
box-sizing: border-box;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
overflow: clip;
row-gap: 1px;
width: 100%;
`;
const StyledComparisonBodyGrid = styled.div`
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
overflow: clip;
width: 100%;
`;
const StyledHeaderCell = styled.div`
align-items: flex-start;
background: ${themeCssVariables.background.secondary};
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[5]};
justify-content: space-between;
min-height: 104px;
min-width: 0;
overflow: clip;
padding: ${themeCssVariables.spacing[4]};
`;
const StyledHeaderText = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[3]};
min-width: 0;
width: 100%;
`;
const StyledPlanTitle = styled.div`
align-items: center;
color: ${themeCssVariables.font.color.primary};
display: flex;
font-size: ${themeCssVariables.font.size.md};
font-weight: ${themeCssVariables.font.weight.medium};
line-height: 1.4;
min-height: 20px;
width: 100%;
`;
const StyledComparisonTitle = styled(StyledPlanTitle)`
color: ${themeCssVariables.font.color.secondary};
font-weight: ${themeCssVariables.font.weight.regular};
`;
const StyledPlanSubtitle = styled.div`
color: ${themeCssVariables.font.color.tertiary};
font-size: ${themeCssVariables.font.size.sm};
font-weight: ${themeCssVariables.font.weight.regular};
line-height: 1.6;
`;
const StyledPriceLine = styled.div`
align-items: baseline;
color: ${themeCssVariables.font.color.primary};
display: flex;
flex-wrap: wrap;
gap: ${themeCssVariables.spacing[1]};
min-width: 0;
`;
const StyledPrice = styled.span`
font-size: ${themeCssVariables.font.size.lg};
font-weight: ${themeCssVariables.font.weight.medium};
line-height: 1.2;
`;
const StyledPriceSuffix = styled.span`
color: ${themeCssVariables.font.color.tertiary};
font-size: ${themeCssVariables.font.size.md};
font-weight: ${themeCssVariables.font.weight.regular};
line-height: 1.4;
`;
const StyledActionSlot = styled.div`
min-width: 0;
> * {
max-width: 100%;
}
`;
export const SettingsBillingPlanComparisonTable = ({
billingInterval,
onBillingIntervalChange,
planActions,
planPrices,
}: SettingsBillingPlanComparisonTableProps) => {
const { t } = useLingui();
const { formatNumber } = useNumberFormat();
const formatAnimatedPrice = (value: number) =>
formatNumber(Math.max(0, value));
const billingIntervalOptions = [
{
label: t`Annual`,
value: SubscriptionInterval.Year,
},
{
label: t`Monthly`,
value: SubscriptionInterval.Month,
},
] satisfies SegmentedControlOption<SettingsBillingPlanInterval>[];
return (
<StyledContentContainer>
<StyledComparisonTable>
<StyledStickyHeaderContainer>
<StyledComparisonHeaderGrid>
<StyledHeaderCell>
<StyledHeaderText>
<StyledComparisonTitle>{t`Compare plans`}</StyledComparisonTitle>
<StyledPlanSubtitle>
{billingInterval === SubscriptionInterval.Year
? t`Save 25% when billed yearly`
: t`Billed monthly`}
</StyledPlanSubtitle>
</StyledHeaderText>
<SegmentedControl
ariaLabel={t`Billing period`}
itemWidth="content"
onChange={onBillingIntervalChange}
options={billingIntervalOptions}
value={billingInterval}
/>
</StyledHeaderCell>
{SETTINGS_BILLING_COMPARED_PLAN_KEYS.map((planKey) => {
const action = planActions[planKey];
const price = planPrices[planKey][billingInterval];
return (
<StyledHeaderCell key={planKey}>
<StyledHeaderText>
<StyledPlanTitle>
{planKey === BillingPlanKey.PRO
? t`Pro plan`
: t`Organization`}
</StyledPlanTitle>
<StyledPriceLine>
<StyledPrice>
$
<AnimatedFormattedNumber
value={price}
formatValue={formatAnimatedPrice}
/>
</StyledPrice>
<StyledPriceSuffix>{t`/user per month`}</StyledPriceSuffix>
</StyledPriceLine>
</StyledHeaderText>
<StyledActionSlot>
<Button
Icon={action.Icon}
title={action.title}
variant={action.variant}
accent={action.accent ?? 'default'}
size="small"
disabled={action.disabled}
isLoading={action.isLoading}
onClick={action.onClick}
/>
</StyledActionSlot>
</StyledHeaderCell>
);
})}
</StyledComparisonHeaderGrid>
</StyledStickyHeaderContainer>
<StyledComparisonBodyGrid>
{SETTINGS_BILLING_PLAN_COMPARISON_ROWS.map((row, rowIndex) => (
<SettingsBillingPlanComparisonTableRow
key={`row-${rowIndex}`}
hasTopBorder={rowIndex > 0}
row={row}
/>
))}
</StyledComparisonBodyGrid>
</StyledComparisonTable>
</StyledContentContainer>
);
};
@@ -0,0 +1,124 @@
import { SETTINGS_BILLING_COMPARED_PLAN_KEYS } from '@/settings/billing/constants/SettingsBillingComparedPlanKeys';
import {
type SettingsBillingPlanComparisonCell,
type SettingsBillingPlanComparisonRow,
} from '@/settings/billing/types/settingsBillingPlanComparison.type';
import { useLingui } from '@lingui/react/macro';
import { styled } from '@linaria/react';
import { IconCheck, IconX } from 'twenty-ui/icon';
import { themeCssVariables, useTheme } from 'twenty-ui/theme-constants';
const StyledFeatureCell = styled.div<{
hasTopBorder?: boolean;
isLabel?: boolean;
}>`
align-items: center;
background: ${themeCssVariables.background.primary};
border-top: ${({ hasTopBorder }) =>
hasTopBorder ? `1px solid ${themeCssVariables.border.color.medium}` : '0'};
box-sizing: border-box;
color: ${({ isLabel }) =>
isLabel
? themeCssVariables.font.color.secondary
: themeCssVariables.font.color.primary};
display: flex;
font-size: ${themeCssVariables.font.size.md};
font-weight: ${themeCssVariables.font.weight.regular};
line-height: 1.4;
min-height: 40px;
min-width: 0;
padding: 0 ${themeCssVariables.spacing[4]};
`;
const StyledCategoryCell = styled.div<{ hasTopBorder?: boolean }>`
align-items: center;
background: ${themeCssVariables.background.secondary};
border-top: ${({ hasTopBorder }) =>
hasTopBorder ? `1px solid ${themeCssVariables.border.color.medium}` : '0'};
box-sizing: border-box;
color: ${themeCssVariables.font.color.primary};
display: flex;
font-size: ${themeCssVariables.font.size.md};
font-weight: ${themeCssVariables.font.weight.medium};
grid-column: 1 / -1;
line-height: 1.4;
min-height: 40px;
padding: 0 ${themeCssVariables.spacing[4]};
`;
const StyledCellText = styled.span`
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const StyledComparisonCellIcon = styled.div<{ isIncluded: boolean }>`
color: ${({ isIncluded }) =>
isIncluded
? themeCssVariables.accent.accent9
: themeCssVariables.font.color.tertiary};
display: flex;
`;
const SettingsBillingPlanComparisonCellContent = ({
cell,
}: {
cell: SettingsBillingPlanComparisonCell;
}) => {
const { i18n, t } = useLingui();
const theme = useTheme();
if (cell.kind === 'included') {
return (
<StyledComparisonCellIcon aria-label={t`Included`} role="img" isIncluded>
<IconCheck size={theme.icon.size.sm} />
</StyledComparisonCellIcon>
);
}
if (cell.kind === 'excluded') {
return (
<StyledComparisonCellIcon
aria-label={t`Not included`}
role="img"
isIncluded={false}
>
<IconX size={theme.icon.size.sm} />
</StyledComparisonCellIcon>
);
}
return <StyledCellText>{i18n._(cell.text)}</StyledCellText>;
};
export const SettingsBillingPlanComparisonTableRow = ({
hasTopBorder = false,
row,
}: {
hasTopBorder?: boolean;
row: SettingsBillingPlanComparisonRow;
}) => {
const { i18n } = useLingui();
if (row.type === 'category') {
return (
<StyledCategoryCell hasTopBorder={hasTopBorder}>
<StyledCellText>{i18n._(row.title)}</StyledCellText>
</StyledCategoryCell>
);
}
return (
<>
<StyledFeatureCell hasTopBorder={hasTopBorder} isLabel>
<StyledCellText>{i18n._(row.featureLabel)}</StyledCellText>
</StyledFeatureCell>
{SETTINGS_BILLING_COMPARED_PLAN_KEYS.map((planKey) => (
<StyledFeatureCell hasTopBorder={hasTopBorder} key={planKey}>
<SettingsBillingPlanComparisonCellContent cell={row.plans[planKey]} />
</StyledFeatureCell>
))}
</>
);
};
@@ -0,0 +1,65 @@
import { SettingsBillingPlanComparisonTable } from '@/settings/billing/components/internal/SettingsBillingPlanComparisonTable';
import { BILLING_MODAL_IDS } from '@/settings/billing/constants/BillingModalIds';
import { useBillingPlanActions } from '@/settings/billing/hooks/useBillingPlanActions';
import { useBillingWording } from '@/settings/billing/hooks/useBillingWording';
import {
type SettingsBillingPlanInterval,
type SettingsBillingPlanPrices,
} from '@/settings/billing/types/settingsBillingPlanComparison.type';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { useLingui } from '@lingui/react/macro';
import { BillingPlanKey } from '~/generated-metadata/graphql';
type SettingsBillingPlansWithSubscriptionProps = {
billingInterval: SettingsBillingPlanInterval;
currentPlanKey: BillingPlanKey;
onBillingIntervalChange: (
billingInterval: SettingsBillingPlanInterval,
) => void;
planPrices: SettingsBillingPlanPrices;
};
export const SettingsBillingPlansWithSubscription = ({
billingInterval,
currentPlanKey,
onBillingIntervalChange,
planPrices,
}: SettingsBillingPlansWithSubscriptionProps) => {
const { t } = useLingui();
const {
confirmationModalSwitchToOrganizationMessage,
confirmationModalSwitchToProMessage,
} = useBillingWording();
const { isSwitchingPlan, planActions, switchBillingPlan } =
useBillingPlanActions({ currentPlanKey });
return (
<SettingsPageContainer overflow="visible">
<SettingsBillingPlanComparisonTable
billingInterval={billingInterval}
onBillingIntervalChange={onBillingIntervalChange}
planActions={planActions}
planPrices={planPrices}
/>
<ConfirmationModal
modalInstanceId={BILLING_MODAL_IDS.switchBillingPlanToEnterprise}
title={t`Change to Organization Plan?`}
subtitle={confirmationModalSwitchToOrganizationMessage()}
onConfirmClick={() => switchBillingPlan(BillingPlanKey.ENTERPRISE)}
confirmButtonText={t`Confirm`}
confirmButtonAccent="blue"
loading={isSwitchingPlan}
/>
<ConfirmationModal
modalInstanceId={BILLING_MODAL_IDS.switchBillingPlanToPro}
title={t`Change to Pro Plan?`}
subtitle={confirmationModalSwitchToProMessage()}
onConfirmClick={() => switchBillingPlan(BillingPlanKey.PRO)}
confirmButtonText={t`Confirm`}
confirmButtonAccent="blue"
loading={isSwitchingPlan}
/>
</SettingsPageContainer>
);
};
@@ -0,0 +1,90 @@
import { SettingsBillingPlanComparisonTable } from '@/settings/billing/components/internal/SettingsBillingPlanComparisonTable';
import { useHandleCheckoutSession } from '@/settings/billing/hooks/useHandleCheckoutSession';
import { type SettingsBillingPlanAction } from '@/settings/billing/types/settingsBillingPlanAction.type';
import {
type SettingsBillingPlanInterval,
type SettingsBillingPlanPrices,
} from '@/settings/billing/types/settingsBillingPlanComparison.type';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { useLingui } from '@lingui/react/macro';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { BillingPlanKey } from '~/generated-metadata/graphql';
type SettingsBillingPlansWithoutSubscriptionProps = {
billingInterval: SettingsBillingPlanInterval;
onBillingIntervalChange: (
billingInterval: SettingsBillingPlanInterval,
) => void;
planPrices: SettingsBillingPlanPrices;
};
export const SettingsBillingPlansWithoutSubscription = ({
billingInterval,
onBillingIntervalChange,
planPrices,
}: SettingsBillingPlansWithoutSubscriptionProps) => {
const { t } = useLingui();
const {
handleCheckoutSession: handleProCheckoutSession,
isSubmitting: isProCheckoutSessionSubmitting,
} = useHandleCheckoutSession({
recurringInterval: billingInterval,
plan: BillingPlanKey.PRO,
requirePaymentMethod: true,
successUrlPath: getSettingsPath(SettingsPath.Billing),
});
const {
handleCheckoutSession: handleEnterpriseCheckoutSession,
isSubmitting: isEnterpriseCheckoutSessionSubmitting,
} = useHandleCheckoutSession({
recurringInterval: billingInterval,
plan: BillingPlanKey.ENTERPRISE,
requirePaymentMethod: true,
successUrlPath: getSettingsPath(SettingsPath.Billing),
});
const createChoosePlanAction = ({
handleCheckoutSession,
isDisabled,
isLoading,
}: {
handleCheckoutSession: () => Promise<void>;
isDisabled: boolean;
isLoading: boolean;
}): SettingsBillingPlanAction => ({
accent: 'blue',
disabled: isDisabled,
isLoading,
onClick: handleCheckoutSession,
title: t`Choose plan`,
variant: 'primary',
});
const isAnyCheckoutSessionSubmitting =
isProCheckoutSessionSubmitting || isEnterpriseCheckoutSessionSubmitting;
const planActions: Record<BillingPlanKey, SettingsBillingPlanAction> = {
[BillingPlanKey.PRO]: createChoosePlanAction({
handleCheckoutSession: handleProCheckoutSession,
isDisabled: isAnyCheckoutSessionSubmitting,
isLoading: isProCheckoutSessionSubmitting,
}),
[BillingPlanKey.ENTERPRISE]: createChoosePlanAction({
handleCheckoutSession: handleEnterpriseCheckoutSession,
isDisabled: isAnyCheckoutSessionSubmitting,
isLoading: isEnterpriseCheckoutSessionSubmitting,
}),
};
return (
<SettingsPageContainer overflow="visible">
<SettingsBillingPlanComparisonTable
billingInterval={billingInterval}
onBillingIntervalChange={onBillingIntervalChange}
planActions={planActions}
planPrices={planPrices}
/>
</SettingsPageContainer>
);
};
@@ -1,8 +1,10 @@
import { useLingui } from '@lingui/react/macro';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import {
IconArrowDown,
IconArrowUp,
IconCircleX,
IconColorSwatch,
IconCreditCard,
} from 'twenty-ui/icon';
import { Button } from 'twenty-ui/input';
@@ -10,19 +12,16 @@ import { Button } from 'twenty-ui/input';
export type SettingsBillingSubscriptionInfoCardHeaderActionsProps = {
canCancelIntervalSwitch: boolean;
canCancelPlanSwitch: boolean;
canComparePlans: boolean;
canStartSubscription: boolean;
canSwitchToOrganizationPlan: boolean;
canSwitchToProPlan: boolean;
isComparePlansActionPrimary: boolean;
isCancellationScheduled: boolean;
isEndTrialPeriodDisabled: boolean;
isSubscriptionActionDisabled: boolean;
isTrialPeriod: boolean;
isUpdatePaymentDisabled: boolean;
onCancelIntervalSwitch: () => void;
onCancelPlanSwitch: () => void;
onEndTrialPeriod: () => void;
onSwitchToOrganization: () => void;
onSwitchToPro: () => void;
onUpdatePayment: () => void;
shouldUpdatePayment: boolean;
};
@@ -30,19 +29,16 @@ export type SettingsBillingSubscriptionInfoCardHeaderActionsProps = {
export const SettingsBillingSubscriptionInfoCardHeaderActions = ({
canCancelIntervalSwitch,
canCancelPlanSwitch,
canComparePlans,
canStartSubscription,
canSwitchToOrganizationPlan,
canSwitchToProPlan,
isComparePlansActionPrimary,
isCancellationScheduled,
isEndTrialPeriodDisabled,
isSubscriptionActionDisabled,
isTrialPeriod,
isUpdatePaymentDisabled,
onCancelIntervalSwitch,
onCancelPlanSwitch,
onEndTrialPeriod,
onSwitchToOrganization,
onSwitchToPro,
onUpdatePayment,
shouldUpdatePayment,
}: SettingsBillingSubscriptionInfoCardHeaderActionsProps) => {
@@ -88,19 +84,14 @@ export const SettingsBillingSubscriptionInfoCardHeaderActions = ({
disabled={isSubscriptionActionDisabled}
/>
)}
{canSwitchToOrganizationPlan && (
{canComparePlans && (
<Button
Icon={IconArrowUp}
title={
isTrialPeriod
? t`Switch to Organization`
: t`Upgrade to Organization`
}
variant={isTrialPeriod ? 'secondary' : 'primary'}
accent={isTrialPeriod ? 'default' : 'blue'}
Icon={IconColorSwatch}
title={t`Compare plans`}
variant={isComparePlansActionPrimary ? 'primary' : 'secondary'}
accent={isComparePlansActionPrimary ? 'blue' : 'default'}
size="small"
onClick={onSwitchToOrganization}
disabled={isSubscriptionActionDisabled}
to={getSettingsPath(SettingsPath.BillingPlans)}
/>
)}
{canStartSubscription && (
@@ -114,16 +105,6 @@ export const SettingsBillingSubscriptionInfoCardHeaderActions = ({
disabled={isEndTrialPeriodDisabled}
/>
)}
{canSwitchToProPlan && (
<Button
Icon={IconArrowDown}
title={t`Switch to Pro`}
variant="secondary"
size="small"
onClick={onSwitchToPro}
disabled={isSubscriptionActionDisabled}
/>
)}
{canCancelPlanSwitch && (
<Button
Icon={IconCircleX}
@@ -10,17 +10,13 @@ type SettingsBillingSubscriptionInfoModalsProps = {
isCancellingPlanSwitch: boolean;
isEndTrialPeriodLoading: boolean;
isSwitchingInterval: boolean;
isSwitchingPlan: boolean;
onCancelIntervalSwitching: () => void;
onCancelPlanSwitching: () => void;
onCancelResourceCreditSwitching: () => void;
onEndTrialPeriod: () => void;
onSwitchInterval: () => void;
onSwitchPlan: () => void;
startSubscriptionSubtitle: string;
switchToMonthlySubtitle: string;
switchToOrganizationSubtitle: string;
switchToProSubtitle: string;
switchToYearlySubtitle: string;
};
@@ -32,17 +28,13 @@ export const SettingsBillingSubscriptionInfoModals = ({
isCancellingPlanSwitch,
isEndTrialPeriodLoading,
isSwitchingInterval,
isSwitchingPlan,
onCancelIntervalSwitching,
onCancelPlanSwitching,
onCancelResourceCreditSwitching,
onEndTrialPeriod,
onSwitchInterval,
onSwitchPlan,
startSubscriptionSubtitle,
switchToMonthlySubtitle,
switchToOrganizationSubtitle,
switchToProSubtitle,
switchToYearlySubtitle,
}: SettingsBillingSubscriptionInfoModalsProps) => {
const { t } = useLingui();
@@ -76,24 +68,6 @@ export const SettingsBillingSubscriptionInfoModals = ({
confirmButtonAccent="blue"
loading={isCancellingIntervalSwitch}
/>
<ConfirmationModal
modalInstanceId={BILLING_MODAL_IDS.switchBillingPlanToEnterprise}
title={t`Change to Organization Plan?`}
subtitle={switchToOrganizationSubtitle}
onConfirmClick={onSwitchPlan}
confirmButtonText={t`Confirm`}
confirmButtonAccent="blue"
loading={isSwitchingPlan}
/>
<ConfirmationModal
modalInstanceId={BILLING_MODAL_IDS.switchBillingPlanToPro}
title={t`Change to Pro Plan?`}
subtitle={switchToProSubtitle}
onConfirmClick={onSwitchPlan}
confirmButtonText={t`Confirm`}
confirmButtonAccent="blue"
loading={isSwitchingPlan}
/>
<ConfirmationModal
modalInstanceId={BILLING_MODAL_IDS.cancelSwitchBillingPlan}
title={t`Cancel plan switching?`}
@@ -0,0 +1,7 @@
import { BillingPlanKey } from '~/generated-metadata/graphql';
// Column order of the plan comparison table.
export const SETTINGS_BILLING_COMPARED_PLAN_KEYS = [
BillingPlanKey.PRO,
BillingPlanKey.ENTERPRISE,
] as const;
@@ -0,0 +1,339 @@
import { msg } from '@lingui/core/macro';
import { type SettingsBillingPlanComparisonRow } from '@/settings/billing/types/settingsBillingPlanComparison.type';
import { BillingPlanKey } from '~/generated-metadata/graphql';
export const SETTINGS_BILLING_PLAN_COMPARISON_ROWS = [
{
featureLabel: msg`Seats limit`,
plans: {
[BillingPlanKey.ENTERPRISE]: { kind: 'text', text: msg`Unlimited` },
[BillingPlanKey.PRO]: { kind: 'text', text: msg`Unlimited` },
},
type: 'feature',
},
{ title: msg`Workspace`, type: 'category' },
{
featureLabel: msg`Custom objects`,
plans: {
[BillingPlanKey.ENTERPRISE]: { kind: 'text', text: msg`Unlimited` },
[BillingPlanKey.PRO]: { kind: 'text', text: msg`Unlimited` },
},
type: 'feature',
},
{
featureLabel: msg`Custom fields`,
plans: {
[BillingPlanKey.ENTERPRISE]: { kind: 'text', text: msg`Unlimited` },
[BillingPlanKey.PRO]: { kind: 'text', text: msg`Unlimited` },
},
type: 'feature',
},
{
featureLabel: msg`Custom views`,
plans: {
[BillingPlanKey.ENTERPRISE]: { kind: 'text', text: msg`Unlimited` },
[BillingPlanKey.PRO]: { kind: 'text', text: msg`Unlimited` },
},
type: 'feature',
},
{
featureLabel: msg`View types`,
plans: {
[BillingPlanKey.ENTERPRISE]: {
kind: 'text',
text: msg`Table, Kanban, Calendar`,
},
[BillingPlanKey.PRO]: {
kind: 'text',
text: msg`Table, Kanban, Calendar`,
},
},
type: 'feature',
},
{
featureLabel: msg`Custom layout`,
plans: {
[BillingPlanKey.ENTERPRISE]: { kind: 'text', text: msg`Unlimited` },
[BillingPlanKey.PRO]: { kind: 'text', text: msg`Unlimited` },
},
type: 'feature',
},
{
featureLabel: msg`Records`,
plans: {
[BillingPlanKey.ENTERPRISE]: { kind: 'text', text: msg`Unlimited` },
[BillingPlanKey.PRO]: { kind: 'text', text: msg`Unlimited` },
},
type: 'feature',
},
{
featureLabel: msg`CSV import & export`,
plans: {
[BillingPlanKey.ENTERPRISE]: { kind: 'included' },
[BillingPlanKey.PRO]: { kind: 'included' },
},
type: 'feature',
},
{
featureLabel: msg`Languages`,
plans: {
[BillingPlanKey.ENTERPRISE]: { kind: 'text', text: msg`30+` },
[BillingPlanKey.PRO]: { kind: 'text', text: msg`30+` },
},
type: 'feature',
},
{ title: msg`Reports`, type: 'category' },
{
featureLabel: msg`Number of dashboards`,
plans: {
[BillingPlanKey.ENTERPRISE]: { kind: 'text', text: msg`Unlimited` },
[BillingPlanKey.PRO]: { kind: 'text', text: msg`Unlimited` },
},
type: 'feature',
},
{ title: msg`Emails & Calendar`, type: 'category' },
{
featureLabel: msg`Internet accounts per user`,
plans: {
[BillingPlanKey.ENTERPRISE]: { kind: 'text', text: msg`Unlimited` },
[BillingPlanKey.PRO]: { kind: 'text', text: msg`Unlimited` },
},
type: 'feature',
},
{
featureLabel: msg`Folder/Label import`,
plans: {
[BillingPlanKey.ENTERPRISE]: { kind: 'included' },
[BillingPlanKey.PRO]: { kind: 'included' },
},
type: 'feature',
},
{
featureLabel: msg`Email sharing`,
plans: {
[BillingPlanKey.ENTERPRISE]: {
kind: 'text',
text: msg`Fully customizable`,
},
[BillingPlanKey.PRO]: { kind: 'text', text: msg`Fully customizable` },
},
type: 'feature',
},
{ title: msg`AI & Automations`, type: 'category' },
{
featureLabel: msg`Workflows`,
plans: {
[BillingPlanKey.ENTERPRISE]: { kind: 'included' },
[BillingPlanKey.PRO]: { kind: 'included' },
},
type: 'feature',
},
{
featureLabel: msg`AI agents`,
plans: {
[BillingPlanKey.ENTERPRISE]: { kind: 'included' },
[BillingPlanKey.PRO]: { kind: 'included' },
},
type: 'feature',
},
{ title: msg`Security`, type: 'category' },
{
featureLabel: msg`Two-factor authentication`,
plans: {
[BillingPlanKey.ENTERPRISE]: { kind: 'included' },
[BillingPlanKey.PRO]: { kind: 'included' },
},
type: 'feature',
},
{
featureLabel: msg`User roles`,
plans: {
[BillingPlanKey.ENTERPRISE]: { kind: 'text', text: msg`Unlimited` },
[BillingPlanKey.PRO]: { kind: 'text', text: msg`Unlimited` },
},
type: 'feature',
},
{
featureLabel: msg`Read/Edit/Delete permissions`,
plans: {
[BillingPlanKey.ENTERPRISE]: { kind: 'text', text: msg`Unlimited` },
[BillingPlanKey.PRO]: { kind: 'text', text: msg`Unlimited` },
},
type: 'feature',
},
{
featureLabel: msg`Field-level permissions`,
plans: {
[BillingPlanKey.ENTERPRISE]: { kind: 'text', text: msg`Unlimited` },
[BillingPlanKey.PRO]: { kind: 'text', text: msg`Unlimited` },
},
type: 'feature',
},
{
featureLabel: msg`Row-level permissions`,
plans: {
[BillingPlanKey.ENTERPRISE]: { kind: 'text', text: msg`Unlimited` },
[BillingPlanKey.PRO]: { kind: 'excluded' },
},
type: 'feature',
},
{
featureLabel: msg`SSO`,
plans: {
[BillingPlanKey.ENTERPRISE]: { kind: 'included' },
[BillingPlanKey.PRO]: { kind: 'excluded' },
},
type: 'feature',
},
{
featureLabel: msg`Encryption key rotation`,
plans: {
[BillingPlanKey.ENTERPRISE]: { kind: 'included' },
[BillingPlanKey.PRO]: { kind: 'excluded' },
},
type: 'feature',
},
{
featureLabel: msg`Audit logs`,
plans: {
[BillingPlanKey.ENTERPRISE]: { kind: 'included' },
[BillingPlanKey.PRO]: { kind: 'excluded' },
},
type: 'feature',
},
{
featureLabel: msg`Environments`,
plans: {
[BillingPlanKey.ENTERPRISE]: {
kind: 'text',
text: msg`Local, Production`,
},
[BillingPlanKey.PRO]: { kind: 'text', text: msg`Local, Production` },
},
type: 'feature',
},
{
featureLabel: msg`Impersonate users`,
plans: {
[BillingPlanKey.ENTERPRISE]: { kind: 'included' },
[BillingPlanKey.PRO]: { kind: 'included' },
},
type: 'feature',
},
{ title: msg`Support`, type: 'category' },
{
featureLabel: msg`Community`,
plans: {
[BillingPlanKey.ENTERPRISE]: { kind: 'included' },
[BillingPlanKey.PRO]: { kind: 'included' },
},
type: 'feature',
},
{
featureLabel: msg`Help center`,
plans: {
[BillingPlanKey.ENTERPRISE]: { kind: 'included' },
[BillingPlanKey.PRO]: { kind: 'included' },
},
type: 'feature',
},
{
featureLabel: msg`Email and Chat`,
plans: {
[BillingPlanKey.ENTERPRISE]: { kind: 'included' },
[BillingPlanKey.PRO]: { kind: 'included' },
},
type: 'feature',
},
{
featureLabel: msg`Priority support`,
plans: {
[BillingPlanKey.ENTERPRISE]: { kind: 'included' },
[BillingPlanKey.PRO]: { kind: 'excluded' },
},
type: 'feature',
},
{
featureLabel: msg`Onboarding Packs`,
plans: {
[BillingPlanKey.ENTERPRISE]: { kind: 'included' },
[BillingPlanKey.PRO]: { kind: 'included' },
},
type: 'feature',
},
{
featureLabel: msg`Implementation partners`,
plans: {
[BillingPlanKey.ENTERPRISE]: { kind: 'included' },
[BillingPlanKey.PRO]: { kind: 'excluded' },
},
type: 'feature',
},
{ title: msg`Customization`, type: 'category' },
{
featureLabel: msg`Custom apps`,
plans: {
[BillingPlanKey.ENTERPRISE]: { kind: 'text', text: msg`Unlimited` },
[BillingPlanKey.PRO]: { kind: 'text', text: msg`Unlimited` },
},
type: 'feature',
},
{
featureLabel: msg`Subdomain (yourco.twenty.com)`,
plans: {
[BillingPlanKey.ENTERPRISE]: { kind: 'included' },
[BillingPlanKey.PRO]: { kind: 'included' },
},
type: 'feature',
},
{
featureLabel: msg`Custom domain (crm.yourco.com)`,
plans: {
[BillingPlanKey.ENTERPRISE]: { kind: 'included' },
[BillingPlanKey.PRO]: { kind: 'excluded' },
},
type: 'feature',
},
{ title: msg`Developers`, type: 'category' },
{
featureLabel: msg`REST & GraphQL API`,
plans: {
[BillingPlanKey.ENTERPRISE]: { kind: 'included' },
[BillingPlanKey.PRO]: { kind: 'included' },
},
type: 'feature',
},
{
featureLabel: msg`Webhooks`,
plans: {
[BillingPlanKey.ENTERPRISE]: { kind: 'included' },
[BillingPlanKey.PRO]: { kind: 'included' },
},
type: 'feature',
},
{
featureLabel: msg`MCP server`,
plans: {
[BillingPlanKey.ENTERPRISE]: { kind: 'included' },
[BillingPlanKey.PRO]: { kind: 'included' },
},
type: 'feature',
},
{
featureLabel: msg`Install shared tarball app`,
plans: {
[BillingPlanKey.ENTERPRISE]: { kind: 'included' },
[BillingPlanKey.PRO]: { kind: 'excluded' },
},
type: 'feature',
},
{
featureLabel: msg`API calls`,
plans: {
[BillingPlanKey.ENTERPRISE]: { kind: 'text', text: msg`100 per minute` },
[BillingPlanKey.PRO]: { kind: 'text', text: msg`50 per minute` },
},
type: 'feature',
},
] satisfies SettingsBillingPlanComparisonRow[];
@@ -0,0 +1,17 @@
import {
BillingPlanKey,
SubscriptionInterval,
} from '~/generated-metadata/graphql';
import { type SettingsBillingPlanPrices } from '@/settings/billing/types/settingsBillingPlanComparison.type';
export const SETTINGS_BILLING_PLAN_PRICE_FALLBACKS = {
[BillingPlanKey.PRO]: {
[SubscriptionInterval.Month]: 12,
[SubscriptionInterval.Year]: 9,
},
[BillingPlanKey.ENTERPRISE]: {
[SubscriptionInterval.Month]: 25,
[SubscriptionInterval.Year]: 19,
},
} satisfies SettingsBillingPlanPrices;
@@ -1,7 +1,70 @@
import { useFormatPrices } from '@/settings/billing/hooks/useFormatPrices';
import {
BillingPlanKey,
SubscriptionInterval,
} from '~/generated-metadata/graphql';
const mockGetBaseLicensedPriceByPlanKeyAndInterval = jest.fn();
jest.mock(
'@/settings/billing/hooks/useBaseLicensedPriceByPlanKeyAndInterval',
() => ({
useBaseLicensedPriceByPlanKeyAndInterval: () => ({
getBaseLicensedPriceByPlanKeyAndInterval:
mockGetBaseLicensedPriceByPlanKeyAndInterval,
}),
}),
);
describe('useFormatPrices', () => {
it('should be a function', () => {
expect(typeof useFormatPrices).toBe('function');
beforeEach(() => {
mockGetBaseLicensedPriceByPlanKeyAndInterval.mockReset();
});
it('formats monthly prices from Stripe unit amounts', () => {
mockGetBaseLicensedPriceByPlanKeyAndInterval.mockImplementation(
(planKey: BillingPlanKey, interval: SubscriptionInterval) => ({
unitAmount:
planKey === BillingPlanKey.PRO
? interval === SubscriptionInterval.Year
? 10800
: 1200
: interval === SubscriptionInterval.Year
? 22800
: 2500,
}),
);
const { formatPrices } = useFormatPrices();
expect(formatPrices).toEqual({
[BillingPlanKey.ENTERPRISE]: {
[SubscriptionInterval.Month]: 25,
[SubscriptionInterval.Year]: 19,
},
[BillingPlanKey.PRO]: {
[SubscriptionInterval.Month]: 12,
[SubscriptionInterval.Year]: 9,
},
});
});
it('uses fallback prices when plan data is incomplete', () => {
mockGetBaseLicensedPriceByPlanKeyAndInterval.mockImplementation(() => {
throw new Error('Base licensed price not found');
});
const { formatPrices } = useFormatPrices();
expect(formatPrices).toEqual({
[BillingPlanKey.ENTERPRISE]: {
[SubscriptionInterval.Month]: 25,
[SubscriptionInterval.Year]: 19,
},
[BillingPlanKey.PRO]: {
[SubscriptionInterval.Month]: 12,
[SubscriptionInterval.Year]: 9,
},
});
});
});
@@ -0,0 +1,136 @@
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { BILLING_MODAL_IDS } from '@/settings/billing/constants/BillingModalIds';
import { useBillingPortalSession } from '@/settings/billing/hooks/useBillingPortalSession';
import { useNextPlan } from '@/settings/billing/hooks/useNextPlan';
import { useSwitchBillingPlan } from '@/settings/billing/hooks/useSwitchBillingPlan';
import { type SettingsBillingPlanAction } from '@/settings/billing/types/settingsBillingPlanAction.type';
import { usePermissionFlagMap } from '@/settings/roles/hooks/usePermissionFlagMap';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useSubscriptionStatus } from '@/workspace/hooks/useSubscriptionStatus';
import { useLingui } from '@lingui/react/macro';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import { IconArrowDown, IconArrowUp, IconCheck } from 'twenty-ui/icon';
import {
BillingPlanKey,
PermissionFlagType,
SubscriptionStatus,
} from '~/generated-metadata/graphql';
type UseBillingPlanActionsParams = {
currentPlanKey: BillingPlanKey;
};
export const useBillingPlanActions = ({
currentPlanKey,
}: UseBillingPlanActionsParams) => {
const { t } = useLingui();
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
const subscriptionStatus = useSubscriptionStatus();
const { openModal } = useModal();
const { nextPlan } = useNextPlan();
const permissionMap = usePermissionFlagMap();
const { isSwitchingPlan, switchBillingPlan } = useSwitchBillingPlan();
const { isBillingPortalSessionDisabled, openBillingPortal } =
useBillingPortalSession(getSettingsPath(SettingsPath.BillingPlans));
const currentBillingSubscription =
currentWorkspace?.currentBillingSubscription;
const hasPermissionToManageBilling =
permissionMap[PermissionFlagType.BILLING] ?? false;
const shouldUpdatePayment =
subscriptionStatus === SubscriptionStatus.PastDue ||
subscriptionStatus === SubscriptionStatus.Unpaid;
const isSubscriptionCanceled =
currentBillingSubscription?.status === SubscriptionStatus.Canceled ||
subscriptionStatus === SubscriptionStatus.Canceled;
const isCancellationScheduled =
currentBillingSubscription?.status !== SubscriptionStatus.Canceled &&
isDefined(currentBillingSubscription?.cancelAt);
const canSwitchSubscription =
isDefined(currentBillingSubscription) &&
!shouldUpdatePayment &&
!isSubscriptionCanceled &&
!isCancellationScheduled &&
hasPermissionToManageBilling;
const createBillingPortalAction = (
title: string,
): SettingsBillingPlanAction => ({
disabled: isBillingPortalSessionDisabled,
onClick: openBillingPortal,
title,
variant: 'secondary',
});
const getPlanAction = (
planKey: BillingPlanKey,
): SettingsBillingPlanAction => {
if (isSubscriptionCanceled) {
return createBillingPortalAction(t`Manage billing`);
}
if (currentPlanKey === planKey) {
return {
disabled: true,
Icon: IconCheck,
title: t`Current`,
variant: 'secondary',
};
}
if (nextPlan?.planKey === planKey) {
return {
disabled: true,
title: t`Scheduled`,
variant: 'secondary',
};
}
if (shouldUpdatePayment) {
return createBillingPortalAction(t`Update payment`);
}
if (isCancellationScheduled) {
return createBillingPortalAction(t`Manage billing`);
}
if (!canSwitchSubscription) {
return {
disabled: true,
title: hasPermissionToManageBilling ? t`Unavailable` : t`Contact admin`,
variant: 'secondary',
};
}
const isSwitchingToOrganizationPlan = planKey === BillingPlanKey.ENTERPRISE;
return {
disabled: isSwitchingPlan,
Icon: isSwitchingToOrganizationPlan ? IconArrowUp : IconArrowDown,
isLoading: isSwitchingPlan,
onClick: () =>
openModal(
isSwitchingToOrganizationPlan
? BILLING_MODAL_IDS.switchBillingPlanToEnterprise
: BILLING_MODAL_IDS.switchBillingPlanToPro,
),
title: isSwitchingToOrganizationPlan ? t`Upgrade` : t`Switch to Pro`,
variant: isSwitchingToOrganizationPlan ? 'primary' : 'secondary',
accent: isSwitchingToOrganizationPlan ? 'blue' : 'default',
};
};
return {
isSwitchingPlan,
planActions: {
[BillingPlanKey.PRO]: getPlanAction(BillingPlanKey.PRO),
[BillingPlanKey.ENTERPRISE]: getPlanAction(BillingPlanKey.ENTERPRISE),
},
switchBillingPlan,
};
};
@@ -0,0 +1,63 @@
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useRedirect } from '@/domain-manager/hooks/useRedirect';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useLazyQuery } from '@apollo/client/react';
import { useLingui } from '@lingui/react/macro';
import { isDefined } from 'twenty-shared/utils';
import { BillingPortalSessionDocument } from '~/generated-metadata/graphql';
export const useBillingPortalSession = (returnUrlPath: string) => {
const { t } = useLingui();
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
const { redirect } = useRedirect();
const { enqueueErrorSnackBar } = useSnackBar();
const hasSubscriptions =
(currentWorkspace?.billingSubscriptions.length ?? 0) > 0;
const [getBillingPortalSession, { loading }] = useLazyQuery(
BillingPortalSessionDocument,
{
fetchPolicy: 'network-only',
},
);
const showBillingPortalSessionError = () => {
enqueueErrorSnackBar({
message: t`Billing portal session error. Please retry or contact Twenty team`,
});
};
const isBillingPortalSessionDisabled = !hasSubscriptions || loading;
const openBillingPortal = async () => {
if (isBillingPortalSessionDisabled) {
return;
}
try {
const { data } = await getBillingPortalSession({
variables: {
returnUrlPath,
},
});
const billingPortalSessionUrl = data?.billingPortalSession.url;
if (!isDefined(billingPortalSessionUrl)) {
showBillingPortalSessionError();
return;
}
redirect(billingPortalSessionUrl);
} catch {
showBillingPortalSessionError();
}
};
return {
isBillingPortalSessionDisabled,
openBillingPortal,
};
};
@@ -2,40 +2,53 @@ import {
BillingPlanKey,
SubscriptionInterval,
} from '~/generated-metadata/graphql';
import { SETTINGS_BILLING_PLAN_PRICE_FALLBACKS } from '@/settings/billing/constants/SettingsBillingPlanPriceFallbacks';
import { useBaseLicensedPriceByPlanKeyAndInterval } from '@/settings/billing/hooks/useBaseLicensedPriceByPlanKeyAndInterval';
import { type SettingsBillingPlanPrices } from '@/settings/billing/types/settingsBillingPlanComparison.type';
export const useFormatPrices = () => {
const { getBaseLicensedPriceByPlanKeyAndInterval } =
useBaseLicensedPriceByPlanKeyAndInterval();
const enterpriseYearPrice = getBaseLicensedPriceByPlanKeyAndInterval(
BillingPlanKey.ENTERPRISE,
SubscriptionInterval.Year,
);
const getFormattedPrice = (
planKey: BillingPlanKey,
interval: SubscriptionInterval.Month | SubscriptionInterval.Year,
) => {
try {
const price = getBaseLicensedPriceByPlanKeyAndInterval(planKey, interval);
const formattedPrice =
price.unitAmount /
100 /
(interval === SubscriptionInterval.Year ? 12 : 1);
const enterpriseMonthPrice = getBaseLicensedPriceByPlanKeyAndInterval(
BillingPlanKey.ENTERPRISE,
SubscriptionInterval.Month,
);
return Number.isFinite(formattedPrice)
? formattedPrice
: SETTINGS_BILLING_PLAN_PRICE_FALLBACKS[planKey][interval];
} catch {
return SETTINGS_BILLING_PLAN_PRICE_FALLBACKS[planKey][interval];
}
};
const proYearPrice = getBaseLicensedPriceByPlanKeyAndInterval(
BillingPlanKey.PRO,
SubscriptionInterval.Year,
);
const proMonthPrice = getBaseLicensedPriceByPlanKeyAndInterval(
BillingPlanKey.PRO,
SubscriptionInterval.Month,
);
const formatPrices = {
const formatPrices: SettingsBillingPlanPrices = {
[BillingPlanKey.ENTERPRISE]: {
[SubscriptionInterval.Year]: enterpriseYearPrice?.unitAmount / 100 / 12,
[SubscriptionInterval.Month]: enterpriseMonthPrice?.unitAmount / 100,
[SubscriptionInterval.Year]: getFormattedPrice(
BillingPlanKey.ENTERPRISE,
SubscriptionInterval.Year,
),
[SubscriptionInterval.Month]: getFormattedPrice(
BillingPlanKey.ENTERPRISE,
SubscriptionInterval.Month,
),
},
[BillingPlanKey.PRO]: {
[SubscriptionInterval.Year]: proYearPrice?.unitAmount / 100 / 12,
[SubscriptionInterval.Month]: proMonthPrice?.unitAmount / 100,
[SubscriptionInterval.Year]: getFormattedPrice(
BillingPlanKey.PRO,
SubscriptionInterval.Year,
),
[SubscriptionInterval.Month]: getFormattedPrice(
BillingPlanKey.PRO,
SubscriptionInterval.Month,
),
},
};
@@ -2,8 +2,14 @@ import { useQuery } from '@apollo/client/react';
import { ListPlansDocument } from '~/generated-metadata/graphql';
import { isDefined } from 'twenty-shared/utils';
export const usePlans = () => {
const { data, loading, error } = useQuery(ListPlansDocument);
type UsePlansOptions = {
skip?: boolean;
};
export const usePlans = (options?: UsePlansOptions) => {
const { data, loading, error } = useQuery(ListPlansDocument, {
skip: options?.skip,
});
const isPlansLoaded = isDefined(data?.listPlans);
@@ -0,0 +1,75 @@
import { useApplyCurrentWorkspaceBillingUpdate } from '@/settings/billing/hooks/useApplyCurrentWorkspaceBillingUpdate';
import { useBillingWording } from '@/settings/billing/hooks/useBillingWording';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useSubscriptionStatus } from '@/workspace/hooks/useSubscriptionStatus';
import { CombinedGraphQLErrors } from '@apollo/client/errors';
import { useMutation } from '@apollo/client/react';
import { useLingui } from '@lingui/react/macro';
import { useState } from 'react';
import {
BillingPlanKey,
SubscriptionStatus,
SwitchBillingPlanDocument,
} from '~/generated-metadata/graphql';
export const useSwitchBillingPlan = () => {
const { t } = useLingui();
const subscriptionStatus = useSubscriptionStatus();
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
const { applyCurrentWorkspaceBillingUpdate } =
useApplyCurrentWorkspaceBillingUpdate();
const { getBeautifiedRenewDate } = useBillingWording();
const [switchBillingPlanMutation] = useMutation(SwitchBillingPlanDocument);
const [isSwitchingPlan, setIsSwitchingPlan] = useState(false);
const getTargetPlanLabel = (targetPlanKey: BillingPlanKey) =>
targetPlanKey === BillingPlanKey.ENTERPRISE ? t`Organization` : t`Pro`;
const getSuccessMessage = (targetPlanKey: BillingPlanKey) =>
targetPlanKey === BillingPlanKey.ENTERPRISE ||
subscriptionStatus === SubscriptionStatus.Trialing
? t`Subscription has been switched to ${getTargetPlanLabel(targetPlanKey)} Plan.`
: t`Subscription will be switched to ${getTargetPlanLabel(targetPlanKey)} Plan the ${getBeautifiedRenewDate()}.`;
const switchBillingPlan = async (targetPlanKey: BillingPlanKey) => {
if (isSwitchingPlan) {
return;
}
setIsSwitchingPlan(true);
try {
const { data } = await switchBillingPlanMutation();
const isBillingUpdateApplied = applyCurrentWorkspaceBillingUpdate(
data?.switchBillingPlan,
);
if (!isBillingUpdateApplied) {
enqueueErrorSnackBar({
message: t`Error while switching subscription.`,
});
return;
}
enqueueSuccessSnackBar({
message: getSuccessMessage(targetPlanKey),
});
} catch (error) {
enqueueErrorSnackBar({
message: t`Error while switching subscription.`,
});
if (!CombinedGraphQLErrors.is(error)) {
throw error;
}
} finally {
setIsSwitchingPlan(false);
}
};
return {
isSwitchingPlan,
switchBillingPlan,
};
};
@@ -0,0 +1,12 @@
import { type IconComponent } from 'twenty-ui/icon';
import { type ButtonAccent, type ButtonVariant } from 'twenty-ui/input';
export type SettingsBillingPlanAction = {
accent?: ButtonAccent;
disabled?: boolean;
Icon?: IconComponent;
isLoading?: boolean;
onClick?: () => void;
title: string;
variant: ButtonVariant;
};
@@ -0,0 +1,34 @@
import { type MessageDescriptor } from '@lingui/core';
import {
type BillingPlanKey,
type SubscriptionInterval,
} from '~/generated-metadata/graphql';
export type SettingsBillingPlanInterval =
| SubscriptionInterval.Month
| SubscriptionInterval.Year;
export type SettingsBillingPlanPrices = Record<
BillingPlanKey,
Record<SettingsBillingPlanInterval, number>
>;
export type SettingsBillingPlanComparisonCell =
| { kind: 'excluded' }
| { kind: 'included' }
| { kind: 'text'; text: MessageDescriptor };
export type SettingsBillingPlanComparisonCategoryRow = {
title: MessageDescriptor;
type: 'category';
};
export type SettingsBillingPlanComparisonFeatureRow = {
featureLabel: MessageDescriptor;
plans: Record<BillingPlanKey, SettingsBillingPlanComparisonCell>;
type: 'feature';
};
export type SettingsBillingPlanComparisonRow =
| SettingsBillingPlanComparisonCategoryRow
| SettingsBillingPlanComparisonFeatureRow;
@@ -1,4 +1,5 @@
import { currentUserState } from '@/auth/states/currentUserState';
import { billingState } from '@/client-config/states/billingState';
import { SettingsOptionCardContentButton } from '@/settings/components/SettingsOptions/SettingsOptionCardContentButton';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { SettingsPath } from 'twenty-shared/types';
@@ -17,9 +18,15 @@ export const SettingsEnterpriseFeatureGateCard = ({
buttonTitle: string;
}) => {
const currentUser = useAtomStateValue(currentUserState);
const billing = useAtomStateValue(billingState);
const navigateSettings = useNavigateSettings();
const isBillingEnabled = billing?.isBillingEnabled ?? false;
const canAccessAdminPanel = currentUser?.canAccessFullAdminPanel === true;
const canDisplayUpgradeButton = isBillingEnabled || canAccessAdminPanel;
const upgradeSettingsPath = isBillingEnabled
? SettingsPath.BillingPlans
: SettingsPath.AdminPanelEnterprise;
return (
<Card rounded>
@@ -28,16 +35,14 @@ export const SettingsEnterpriseFeatureGateCard = ({
title={title}
description={description}
Button={
canAccessAdminPanel ? (
canDisplayUpgradeButton ? (
<Button
title={buttonTitle}
variant="primary"
accent="blue"
size="small"
Icon={IconArrowUp}
onClick={() =>
navigateSettings(SettingsPath.AdminPanelEnterprise)
}
onClick={() => navigateSettings(upgradeSettingsPath)}
/>
) : undefined
}
@@ -2,17 +2,29 @@ import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import { useScrollRestoration } from '@/ui/utilities/scroll/hooks/useScrollRestoration';
import { styled } from '@linaria/react';
import { type ReactNode, useMemo } from 'react';
import { type ReactNode } from 'react';
import { matchPath, useLocation } from 'react-router-dom';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const SETTINGS_CONTENT_MAX_WIDTH = 760;
const SETTINGS_PATHS_BY_LENGTH = Object.values(SettingsPath).sort(
(a, b) => b.length - a.length,
);
const getMatchingSettingsPath = (pathname: string) =>
SETTINGS_PATHS_BY_LENGTH.find((path) => {
const settingsPath = getSettingsPath(path);
const match = matchPath(settingsPath, pathname);
return isDefined(match);
});
const StyledSettingsPageContainer = styled.div<{
width?: number;
isMobile?: boolean;
overflow?: 'auto' | 'visible';
}>`
box-sizing: border-box;
display: flex;
@@ -20,7 +32,7 @@ const StyledSettingsPageContainer = styled.div<{
gap: ${themeCssVariables.spacing[8]};
margin: 0 auto;
max-width: ${SETTINGS_CONTENT_MAX_WIDTH}px;
overflow: auto;
overflow: ${({ overflow = 'auto' }) => overflow};
padding: ${themeCssVariables.spacing[6]} ${themeCssVariables.spacing[8]}
${themeCssVariables.spacing[8]};
padding-bottom: ${themeCssVariables.spacing[20]};
@@ -37,22 +49,14 @@ const StyledSettingsPageContainer = styled.div<{
export const SettingsPageContainer = ({
children,
overflow = 'auto',
}: {
children: ReactNode;
overflow?: 'auto' | 'visible';
}) => {
const isMobile = useIsMobile();
const location = useLocation();
const settingsPath = useMemo(() => {
const sortedPaths = Object.values(SettingsPath).sort(
(a, b) => b.length - a.length,
);
return sortedPaths.find((path) => {
const settingsPath = getSettingsPath(path);
const match = matchPath(settingsPath, location.pathname);
return isDefined(match);
});
}, [location.pathname]);
const settingsPath = getMatchingSettingsPath(location.pathname);
const componentInstanceId = `scroll-wrapper-settings-page-container-${settingsPath}`;
@@ -60,7 +64,7 @@ export const SettingsPageContainer = ({
return (
<ScrollWrapper componentInstanceId={componentInstanceId}>
<StyledSettingsPageContainer isMobile={isMobile}>
<StyledSettingsPageContainer isMobile={isMobile} overflow={overflow}>
{children}
</StyledSettingsPageContainer>
</ScrollWrapper>
@@ -179,7 +179,7 @@ export const SettingsLogs = () => {
onClick={() =>
navigateSettings(
isBillingEnabled
? SettingsPath.Billing
? SettingsPath.BillingPlans
: SettingsPath.AdminPanelEnterprise,
)
}
@@ -126,6 +126,59 @@ describe('useSettingsNavigationItems', () => {
expect(workspaceSection?.items.some((item) => !item.isHidden)).toBe(true);
});
it('should hide billing navigation when billing is disabled', () => {
(usePermissionFlagMap as jest.Mock).mockImplementation(() => ({
[PermissionFlagType.WORKSPACE]: true,
[PermissionFlagType.WORKSPACE_MEMBERS]: true,
[PermissionFlagType.DATA_MODEL]: true,
[PermissionFlagType.API_KEYS_AND_WEBHOOKS]: true,
[PermissionFlagType.ROLES]: true,
[PermissionFlagType.SECURITY]: true,
[PermissionFlagType.CONNECTED_ACCOUNTS]: true,
}));
const { result } = renderHook(() => useSettingsNavigationItems(), {
wrapper: Wrapper,
});
const workspaceSection = result.current.find(
(section) => section.label === 'Workspace',
);
const billingItem = workspaceSection?.items.find(
(item) => item.label === 'Billing',
);
expect(billingItem?.isHidden).toBe(true);
expect(billingItem?.path).toBe(SettingsPath.Billing);
});
it('should hide billing navigation until billing config is loaded', () => {
jotaiStore.set(billingState.atom, null);
(usePermissionFlagMap as jest.Mock).mockImplementation(() => ({
[PermissionFlagType.WORKSPACE]: true,
[PermissionFlagType.WORKSPACE_MEMBERS]: true,
[PermissionFlagType.DATA_MODEL]: true,
[PermissionFlagType.API_KEYS_AND_WEBHOOKS]: true,
[PermissionFlagType.ROLES]: true,
[PermissionFlagType.SECURITY]: true,
[PermissionFlagType.CONNECTED_ACCOUNTS]: true,
}));
const { result } = renderHook(() => useSettingsNavigationItems(), {
wrapper: Wrapper,
});
const workspaceSection = result.current.find(
(section) => section.label === 'Workspace',
);
const billingItem = workspaceSection?.items.find(
(item) => item.label === 'Billing',
);
expect(billingItem?.isHidden).toBe(true);
});
it('should show user section items regardless of permissions', () => {
(usePermissionFlagMap as jest.Mock).mockImplementation(() => ({
[PermissionFlagType.WORKSPACE]: false,
@@ -66,7 +66,7 @@ export const SettingsRolePermissionsObjectLevelRecordLevelSection = ({
onClick={() =>
navigateSettings(
isBillingEnabled
? SettingsPath.Billing
? SettingsPath.BillingPlans
: SettingsPath.AdminPanelEnterprise,
)
}
@@ -1,31 +1,8 @@
import { Trans, useLingui } from '@lingui/react/macro';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { SettingsPageLayout } from '@/settings/components/layout/SettingsPageLayout';
import { SettingsBillingContent } from '@/settings/billing/components/SettingsBillingContent';
import { getSettingsPath } from 'twenty-shared/utils';
import { SettingsPath } from 'twenty-shared/types';
import { usePlans } from '@/settings/billing/hooks/usePlans';
import { SettingsBillingPageLayout } from '@/settings/billing/components/SettingsBillingPageLayout';
export const SettingsBilling = () => {
const { t } = useLingui();
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
const { isPlansLoaded } = usePlans();
return (
<SettingsPageLayout
title={t`Billing`}
links={[
{
children: <Trans>Workspace</Trans>,
href: getSettingsPath(SettingsPath.General),
},
{ children: <Trans>Billing</Trans> },
]}
>
{currentWorkspace && isPlansLoaded ? <SettingsBillingContent /> : <></>}
</SettingsPageLayout>
);
};
export const SettingsBilling = () => (
<SettingsBillingPageLayout>
<SettingsBillingContent />
</SettingsBillingPageLayout>
);
@@ -0,0 +1,8 @@
import { SettingsBillingPageLayout } from '@/settings/billing/components/SettingsBillingPageLayout';
import { SettingsBillingPlansContent } from '@/settings/billing/components/SettingsBillingPlansContent';
export const SettingsBillingPlans = () => (
<SettingsBillingPageLayout>
<SettingsBillingPlansContent />
</SettingsBillingPageLayout>
);
@@ -65,7 +65,7 @@ export const SettingsWorkspaceEmail = () => {
onClick={() =>
navigateSettings(
isBillingEnabled
? SettingsPath.Billing
? SettingsPath.BillingPlans
: SettingsPath.AdminPanelEnterprise,
)
}
@@ -1,9 +1,15 @@
import { IconLock } from 'twenty-ui/icon';
import { t } from '@lingui/core/macro';
import { styled } from '@linaria/react';
import { css, cx } from '@linaria/core';
import { Link } from 'react-router-dom';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { IconLock } from 'twenty-ui/icon';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledPillContainer = styled.span`
import { billingState } from '@/client-config/states/billingState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
const pillClassName = css`
align-items: center;
background: ${themeCssVariables.background.secondary};
border: 1px solid ${themeCssVariables.border.color.light};
@@ -15,9 +21,40 @@ const StyledPillContainer = styled.span`
padding: ${themeCssVariables.spacing[1]} ${themeCssVariables.spacing[2]};
`;
export const OrganizationAdornment = () => (
<StyledPillContainer>
const pillLinkClassName = css`
text-decoration: none;
&:hover {
background: ${themeCssVariables.background.transparent.lighter};
color: ${themeCssVariables.font.color.secondary};
}
`;
const OrganizationAdornmentContent = () => (
<>
<IconLock size={12} />
{t`Organization`}
</StyledPillContainer>
</>
);
export const OrganizationAdornment = () => {
const billing = useAtomStateValue(billingState);
const isBillingEnabled = billing?.isBillingEnabled ?? false;
if (isBillingEnabled) {
return (
<Link
className={cx(pillClassName, pillLinkClassName)}
to={getSettingsPath(SettingsPath.BillingPlans)}
>
<OrganizationAdornmentContent />
</Link>
);
}
return (
<span className={pillClassName}>
<OrganizationAdornmentContent />
</span>
);
};
@@ -43,6 +43,9 @@ describe('title-utils', () => {
expect(getPageTitleFromPath('/settings/accounts/emails/:accountUuid')).toBe(
'Account - Settings',
);
expect(getPageTitleFromPath('/settings/billing/plans')).toBe(
'Billing - Settings',
);
expect(getPageTitleFromPath('/settings/members')).toBe(
'Members - Settings',
);
@@ -3,6 +3,7 @@ import { AppBasePath, AppPath, SettingsPath } from 'twenty-shared/types';
enum SettingsPathPrefixes {
Accounts = `${AppBasePath.Settings}/${SettingsPath.Accounts}`,
Billing = `${AppBasePath.Settings}/${SettingsPath.Billing}`,
Experience = `${AppBasePath.Settings}/${SettingsPath.Experience}`,
Profile = `${AppBasePath.Settings}/${SettingsPath.ProfilePage}`,
Objects = `${AppBasePath.Settings}/${SettingsPath.Objects}`,
@@ -43,6 +44,8 @@ export const getPageTitleFromPath = (pathname: string): string => {
return t`Experience - Settings`;
case SettingsPathPrefixes.Accounts:
return t`Account - Settings`;
case SettingsPathPrefixes.Billing:
return t`Billing - Settings`;
case SettingsPathPrefixes.Profile:
return t`Profile - Settings`;
case SettingsPathPrefixes.Members:
@@ -10,6 +10,7 @@ export enum SettingsPath {
NewImapSmtpCaldavConnection = 'accounts/new-imap-smtp-caldav-connection',
EditImapSmtpCaldavConnection = 'accounts/edit-imap-smtp-caldav-connection/:connectedAccountId',
Billing = 'billing',
BillingPlans = 'billing/plans',
Usage = 'billing/usage',
UsageUserDetail = 'billing/usage/user/:userWorkspaceId',
Enterprise = 'enterprise',
@@ -0,0 +1,79 @@
.container {
align-items: center;
background: var(--t-background-secondary);
border: 1px solid var(--t-border-color-medium);
border-radius: var(--t-border-radius-pill);
box-sizing: border-box;
display: inline-flex;
flex-shrink: 0;
gap: 2px;
height: var(--t-spacing-7);
padding: 3px;
width: var(--segmented-control-width, auto);
}
.item {
align-items: center;
background: transparent;
border: 0;
border-radius: var(--t-border-radius-pill);
color: var(--t-font-color-secondary);
cursor: pointer;
display: flex;
flex: 1;
font-family: var(--t-font-family);
font-size: var(--t-font-size-md);
font-weight: var(--t-font-weight-medium);
gap: var(--t-spacing-1);
height: 100%;
justify-content: center;
line-height: 1.4;
min-width: 0;
padding: 0 var(--t-spacing-2);
transition:
background 0.1s ease,
color 0.1s ease;
white-space: nowrap;
&:hover {
background: var(--t-background-transparent-lighter);
}
&:disabled {
cursor: not-allowed;
}
&[data-active] {
background: var(--t-background-transparent-light);
color: var(--t-font-color-primary);
&:hover {
background: var(--t-background-transparent-light);
}
}
}
.item[data-item-width='content'] {
flex: 0 0 max-content;
}
.iconOnly {
min-width: var(--t-spacing-5);
padding: 0;
}
.icon {
align-items: center;
display: flex;
flex-shrink: 0;
height: var(--t-spacing-5);
justify-content: center;
width: var(--t-spacing-5);
}
.label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -0,0 +1,8 @@
declare const classNames: {
readonly container: 'container';
readonly icon: 'icon';
readonly iconOnly: 'iconOnly';
readonly item: 'item';
readonly label: 'label';
};
export default classNames;
@@ -0,0 +1,198 @@
import { type CSSProperties, type KeyboardEvent, type ReactNode } from 'react';
import { type IconComponent } from '@ui/icon';
import { useTheme } from '@ui/theme-constants';
import { isDefined } from '@ui/utilities/utils/isDefined';
import { clsx } from 'clsx';
import styles from './SegmentedControl.module.scss';
type SegmentedControlOptionBase<Value extends string> = {
disabled?: boolean;
value: Value;
};
type SegmentedControlOptionWithLabel<Value extends string> =
SegmentedControlOptionBase<Value> & {
Icon?: IconComponent;
ariaLabel?: string;
label: NonNullable<ReactNode>;
};
type SegmentedControlOptionIconOnly<Value extends string> =
SegmentedControlOptionBase<Value> & {
Icon: IconComponent;
ariaLabel: string;
label?: never;
};
export type SegmentedControlOption<Value extends string> =
| SegmentedControlOptionWithLabel<Value>
| SegmentedControlOptionIconOnly<Value>;
type SegmentedControlOptionButtonProps<Value extends string> = {
Icon?: IconComponent;
ariaLabel?: string;
disabled?: boolean;
label?: ReactNode;
value: Value;
};
type SegmentedControlItemWidth = 'content' | 'equal';
type SegmentedControlStyle = CSSProperties & {
'--segmented-control-width'?: string;
};
export type SegmentedControlProps<Value extends string> = {
ariaLabel: string;
itemWidth?: SegmentedControlItemWidth;
onChange: (value: Value) => void;
options: SegmentedControlOption<Value>[];
role?: 'group' | 'tablist';
value: Value;
width?: number | string;
};
export const SegmentedControl = <Value extends string>({
ariaLabel,
itemWidth = 'equal',
onChange,
options,
role = 'group',
value,
width,
}: SegmentedControlProps<Value>) => {
const theme = useTheme();
const isTabList = role === 'tablist';
const segmentedControlStyle: SegmentedControlStyle | undefined = isDefined(
width,
)
? {
'--segmented-control-width':
typeof width === 'number' ? `${width}px` : width,
}
: undefined;
const focusAndSelectOption = ({
event,
optionIndex,
optionValue,
}: {
event: KeyboardEvent<HTMLButtonElement>;
optionIndex: number;
optionValue: Value;
}) => {
onChange(optionValue);
const enabledOptionButtons = Array.from(
event.currentTarget.parentElement?.querySelectorAll<HTMLButtonElement>(
'button:not(:disabled)',
) ?? [],
);
enabledOptionButtons[optionIndex]?.focus();
};
const handleTabKeyDown = (
event: KeyboardEvent<HTMLButtonElement>,
optionValue: Value,
) => {
if (!isTabList) {
return;
}
const enabledOptions = options.filter((option) => !option.disabled);
const currentOptionIndex = enabledOptions.findIndex(
(option) => option.value === optionValue,
);
if (currentOptionIndex === -1 || enabledOptions.length === 0) {
return;
}
const lastOptionIndex = enabledOptions.length - 1;
const optionIndexByKey: Partial<Record<string, number>> = {
ArrowDown: (currentOptionIndex + 1) % enabledOptions.length,
ArrowLeft:
currentOptionIndex === 0 ? lastOptionIndex : currentOptionIndex - 1,
ArrowRight: (currentOptionIndex + 1) % enabledOptions.length,
ArrowUp:
currentOptionIndex === 0 ? lastOptionIndex : currentOptionIndex - 1,
End: lastOptionIndex,
Home: 0,
};
const nextOptionIndex = optionIndexByKey[event.key];
if (!isDefined(nextOptionIndex)) {
return;
}
event.preventDefault();
focusAndSelectOption({
event,
optionIndex: nextOptionIndex,
optionValue: enabledOptions[nextOptionIndex].value,
});
};
return (
<div
className={styles.container}
role={role}
aria-label={ariaLabel}
style={segmentedControlStyle}
>
{options.map(
({
Icon,
ariaLabel: itemAriaLabel,
disabled,
label,
value: optionValue,
}: SegmentedControlOptionButtonProps<Value>) => {
const isSelected = optionValue === value;
const inferredAriaLabel =
itemAriaLabel ?? (typeof label === 'string' ? label : undefined);
const hasLabel = isDefined(label);
return (
<button
aria-label={inferredAriaLabel}
aria-pressed={!isTabList ? isSelected : undefined}
aria-selected={isTabList ? isSelected : undefined}
className={clsx(styles.item, !hasLabel && styles.iconOnly)}
data-active={isSelected || undefined}
disabled={disabled}
data-item-width={itemWidth}
key={optionValue}
onClick={() => onChange(optionValue)}
onKeyDown={(event) => handleTabKeyDown(event, optionValue)}
role={isTabList ? 'tab' : undefined}
tabIndex={isTabList ? (isSelected ? 0 : -1) : undefined}
type="button"
>
{Icon && (
<span className={styles.icon}>
<Icon
aria-hidden={hasLabel || isDefined(inferredAriaLabel)}
color={
isSelected
? theme.font.color.primary
: theme.font.color.secondary
}
size={theme.icon.size.md}
/>
</span>
)}
{hasLabel && <span className={styles.label}>{label}</span>}
</button>
);
},
)}
</div>
);
};
@@ -0,0 +1,76 @@
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { IconComment, IconHome } from '@ui/icon';
import { ComponentDecorator } from '@ui/testing';
import { useState } from 'react';
import {
SegmentedControl,
type SegmentedControlOption,
} from '@ui/input/SegmentedControl/SegmentedControl';
type InteractiveSegmentedControlProps = {
initialValue: string;
options: SegmentedControlOption<string>[];
role?: 'group' | 'tablist';
};
const InteractiveSegmentedControl = ({
initialValue,
options,
role,
}: InteractiveSegmentedControlProps) => {
const [value, setValue] = useState(initialValue);
return (
<SegmentedControl
ariaLabel="Segmented control"
onChange={setValue}
options={options}
role={role}
value={value}
/>
);
};
const meta: Meta<typeof InteractiveSegmentedControl> = {
title: 'UI/Input/SegmentedControl',
component: InteractiveSegmentedControl,
};
export default meta;
type Story = StoryObj<typeof InteractiveSegmentedControl>;
export const Default: Story = {
args: {
initialValue: 'annual',
options: [
{ label: 'Annual', value: 'annual' },
{ label: 'Monthly', value: 'monthly' },
],
},
decorators: [ComponentDecorator],
};
export const IconOnlyTabList: Story = {
args: {
initialValue: 'home',
options: [
{ Icon: IconHome, ariaLabel: 'Home', value: 'home' },
{ Icon: IconComment, ariaLabel: 'Chat', value: 'chat' },
],
role: 'tablist',
},
decorators: [ComponentDecorator],
};
export const WithDisabledOption: Story = {
args: {
initialValue: 'annual',
options: [
{ label: 'Annual', value: 'annual' },
{ label: 'Monthly', value: 'monthly' },
{ disabled: true, label: 'Weekly', value: 'weekly' },
],
},
decorators: [ComponentDecorator],
};
+5
View File
@@ -95,6 +95,11 @@ export type { RoundedIconButtonSize } from './RoundedIconButton/RoundedIconButto
export { RoundedIconButton } from './RoundedIconButton/RoundedIconButton';
export type { SearchInputProps } from './SearchInput/SearchInput';
export { SearchInput } from './SearchInput/SearchInput';
export type {
SegmentedControlOption,
SegmentedControlProps,
} from './SegmentedControl/SegmentedControl';
export { SegmentedControl } from './SegmentedControl/SegmentedControl';
export type { SliderColor, SliderProps } from './Slider/Slider';
export { Slider } from './Slider/Slider';
export type { TabContentProps } from './TabButton/TabButton';