From 416f4cf90e177d228aaba6c9a791c5d3757e004c Mon Sep 17 00:00:00 2001 From: Thomas des Francs Date: Fri, 3 Jul 2026 10:30:43 +0200 Subject: [PATCH] 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. file-f98283057b5a700f275cde2a38831ac3 Review in cubic --------- Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com> --- .../modules/app/components/SettingsRoutes.tsx | 10 + .../components/SettingsBillingContent.tsx | 42 +-- .../SettingsBillingContentSkeleton.tsx | 21 ++ .../components/SettingsBillingPageLayout.tsx | 63 ++++ .../SettingsBillingPlansContent.tsx | 53 +++ .../SettingsBillingSubscriptionInfo.tsx | 51 +-- .../components/SettingsBillingTabBar.tsx | 55 +++ .../SettingsBillingPlanComparisonTable.tsx | 248 +++++++++++++ .../SettingsBillingPlanComparisonTableRow.tsx | 124 +++++++ .../SettingsBillingPlansWithSubscription.tsx | 65 ++++ ...ettingsBillingPlansWithoutSubscription.tsx | 90 +++++ ...llingSubscriptionInfoCardHeaderActions.tsx | 45 +-- .../SettingsBillingSubscriptionInfoModals.tsx | 26 -- .../SettingsBillingComparedPlanKeys.ts | 7 + .../SettingsBillingPlanComparisonRows.ts | 339 ++++++++++++++++++ .../SettingsBillingPlanPriceFallbacks.ts | 17 + .../hooks/__tests__/useFormatPrices.test.ts | 67 +++- .../billing/hooks/useBillingPlanActions.ts | 136 +++++++ .../billing/hooks/useBillingPortalSession.ts | 63 ++++ .../settings/billing/hooks/useFormatPrices.ts | 59 +-- .../settings/billing/hooks/usePlans.ts | 10 +- .../billing/hooks/useSwitchBillingPlan.ts | 75 ++++ .../types/settingsBillingPlanAction.type.ts | 12 + .../settingsBillingPlanComparison.type.ts | 34 ++ .../SettingsEnterpriseFeatureGateCard.tsx | 13 +- .../components/SettingsPageContainer.tsx | 32 +- .../event-logs/components/SettingsLogs.tsx | 2 +- .../useSettingsNavigationItems.test.tsx | 53 +++ ...rmissionsObjectLevelRecordLevelSection.tsx | 2 +- .../settings/billing/SettingsBilling.tsx | 35 +- .../settings/billing/SettingsBillingPlans.tsx | 8 + .../settings/email/SettingsWorkspaceEmail.tsx | 2 +- .../components/OrganizationAdornment.tsx | 49 ++- .../src/utils/__tests__/title-utils.test.ts | 3 + .../twenty-front/src/utils/title-utils.ts | 3 + .../twenty-shared/src/types/SettingsPath.ts | 1 + .../SegmentedControl.module.scss | 79 ++++ .../SegmentedControl.module.scss.d.ts | 8 + .../SegmentedControl/SegmentedControl.tsx | 198 ++++++++++ .../__stories__/SegmentedControl.stories.tsx | 76 ++++ packages/twenty-ui/src/input/index.ts | 5 + 41 files changed, 2063 insertions(+), 218 deletions(-) create mode 100644 packages/twenty-front/src/modules/settings/billing/components/SettingsBillingContentSkeleton.tsx create mode 100644 packages/twenty-front/src/modules/settings/billing/components/SettingsBillingPageLayout.tsx create mode 100644 packages/twenty-front/src/modules/settings/billing/components/SettingsBillingPlansContent.tsx create mode 100644 packages/twenty-front/src/modules/settings/billing/components/SettingsBillingTabBar.tsx create mode 100644 packages/twenty-front/src/modules/settings/billing/components/internal/SettingsBillingPlanComparisonTable.tsx create mode 100644 packages/twenty-front/src/modules/settings/billing/components/internal/SettingsBillingPlanComparisonTableRow.tsx create mode 100644 packages/twenty-front/src/modules/settings/billing/components/internal/SettingsBillingPlansWithSubscription.tsx create mode 100644 packages/twenty-front/src/modules/settings/billing/components/internal/SettingsBillingPlansWithoutSubscription.tsx create mode 100644 packages/twenty-front/src/modules/settings/billing/constants/SettingsBillingComparedPlanKeys.ts create mode 100644 packages/twenty-front/src/modules/settings/billing/constants/SettingsBillingPlanComparisonRows.ts create mode 100644 packages/twenty-front/src/modules/settings/billing/constants/SettingsBillingPlanPriceFallbacks.ts create mode 100644 packages/twenty-front/src/modules/settings/billing/hooks/useBillingPlanActions.ts create mode 100644 packages/twenty-front/src/modules/settings/billing/hooks/useBillingPortalSession.ts create mode 100644 packages/twenty-front/src/modules/settings/billing/hooks/useSwitchBillingPlan.ts create mode 100644 packages/twenty-front/src/modules/settings/billing/types/settingsBillingPlanAction.type.ts create mode 100644 packages/twenty-front/src/modules/settings/billing/types/settingsBillingPlanComparison.type.ts create mode 100644 packages/twenty-front/src/pages/settings/billing/SettingsBillingPlans.tsx create mode 100644 packages/twenty-ui/src/input/SegmentedControl/SegmentedControl.module.scss create mode 100644 packages/twenty-ui/src/input/SegmentedControl/SegmentedControl.module.scss.d.ts create mode 100644 packages/twenty-ui/src/input/SegmentedControl/SegmentedControl.tsx create mode 100644 packages/twenty-ui/src/input/SegmentedControl/__stories__/SegmentedControl.stories.tsx diff --git a/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx b/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx index 9b85797554..2827e4ab86 100644 --- a/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx +++ b/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx @@ -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={} /> } /> + } + /> } /> { 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 ( @@ -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 = () => { )}
@@ -108,7 +86,7 @@ export const SettingsBillingContent = () => { title={t`View billing details`} variant="secondary" onClick={openBillingPortal} - disabled={billingPortalButtonDisabled} + disabled={isBillingPortalSessionDisabled} />
{canCancelCurrentSubscription && ( @@ -123,7 +101,7 @@ export const SettingsBillingContent = () => { variant="secondary" accent="danger" onClick={openBillingPortal} - disabled={billingPortalButtonDisabled} + disabled={isBillingPortalSessionDisabled} /> )} diff --git a/packages/twenty-front/src/modules/settings/billing/components/SettingsBillingContentSkeleton.tsx b/packages/twenty-front/src/modules/settings/billing/components/SettingsBillingContentSkeleton.tsx new file mode 100644 index 0000000000..f4ba29e05e --- /dev/null +++ b/packages/twenty-front/src/modules/settings/billing/components/SettingsBillingContentSkeleton.tsx @@ -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 = () => ( + + + +); diff --git a/packages/twenty-front/src/modules/settings/billing/components/SettingsBillingPageLayout.tsx b/packages/twenty-front/src/modules/settings/billing/components/SettingsBillingPageLayout.tsx new file mode 100644 index 0000000000..462acde034 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/billing/components/SettingsBillingPageLayout.tsx @@ -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 ; + } + + return ( + Workspace, + href: getSettingsPath(SettingsPath.General), + }, + { children: Billing }, + ]} + secondaryBar={} + > + {!isDefined(currentWorkspace) || + !isBillingLoaded || + (isBillingEnabled && arePlansLoading) ? ( + + ) : isPlansLoaded ? ( + children + ) : ( + <> + )} + + ); +}; diff --git a/packages/twenty-front/src/modules/settings/billing/components/SettingsBillingPlansContent.tsx b/packages/twenty-front/src/modules/settings/billing/components/SettingsBillingPlansContent.tsx new file mode 100644 index 0000000000..ff254ad50e --- /dev/null +++ b/packages/twenty-front/src/modules/settings/billing/components/SettingsBillingPlansContent.tsx @@ -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(SubscriptionInterval.Year); + + const currentPlanKey = parseCurrentPlanKey( + currentWorkspace?.currentBillingSubscription?.metadata?.['plan'], + ); + + if ( + !isDefined(currentPlanKey) || + !isDefined(currentWorkspace?.currentBillingSubscription) + ) { + return ( + + ); + } + + return ( + + ); +}; diff --git a/packages/twenty-front/src/modules/settings/billing/components/SettingsBillingSubscriptionInfo.tsx b/packages/twenty-front/src/modules/settings/billing/components/SettingsBillingSubscriptionInfo.tsx index 91b517fe84..a2a604a781 100644 --- a/packages/twenty-front/src/modules/settings/billing/components/SettingsBillingSubscriptionInfo.tsx +++ b/packages/twenty-front/src/modules/settings/billing/components/SettingsBillingSubscriptionInfo.tsx @@ -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 = ({ 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()} /> diff --git a/packages/twenty-front/src/modules/settings/billing/components/SettingsBillingTabBar.tsx b/packages/twenty-front/src/modules/settings/billing/components/SettingsBillingTabBar.tsx new file mode 100644 index 0000000000..b24ded7755 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/billing/components/SettingsBillingTabBar.tsx @@ -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 ( + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/settings/billing/components/internal/SettingsBillingPlanComparisonTable.tsx b/packages/twenty-front/src/modules/settings/billing/components/internal/SettingsBillingPlanComparisonTable.tsx new file mode 100644 index 0000000000..f7d04ee8b2 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/billing/components/internal/SettingsBillingPlanComparisonTable.tsx @@ -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; + 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[]; + + return ( + + + + + + + {t`Compare plans`} + + {billingInterval === SubscriptionInterval.Year + ? t`Save 25% when billed yearly` + : t`Billed monthly`} + + + + + + {SETTINGS_BILLING_COMPARED_PLAN_KEYS.map((planKey) => { + const action = planActions[planKey]; + const price = planPrices[planKey][billingInterval]; + + return ( + + + + {planKey === BillingPlanKey.PRO + ? t`Pro plan` + : t`Organization`} + + + + $ + + + {t`/user per month`} + + + + + ); + }, + )} + + ); +}; diff --git a/packages/twenty-ui/src/input/SegmentedControl/__stories__/SegmentedControl.stories.tsx b/packages/twenty-ui/src/input/SegmentedControl/__stories__/SegmentedControl.stories.tsx new file mode 100644 index 0000000000..6e9bb2606d --- /dev/null +++ b/packages/twenty-ui/src/input/SegmentedControl/__stories__/SegmentedControl.stories.tsx @@ -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[]; + role?: 'group' | 'tablist'; +}; + +const InteractiveSegmentedControl = ({ + initialValue, + options, + role, +}: InteractiveSegmentedControlProps) => { + const [value, setValue] = useState(initialValue); + + return ( + + ); +}; + +const meta: Meta = { + title: 'UI/Input/SegmentedControl', + component: InteractiveSegmentedControl, +}; + +export default meta; +type Story = StoryObj; + +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], +}; diff --git a/packages/twenty-ui/src/input/index.ts b/packages/twenty-ui/src/input/index.ts index 1e28c2cdf7..9cff442961 100644 --- a/packages/twenty-ui/src/input/index.ts +++ b/packages/twenty-ui/src/input/index.ts @@ -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';