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:
committed by
GitHub
parent
3aeb2b0d5d
commit
416f4cf90e
@@ -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}
|
||||
|
||||
+10
-32
@@ -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>
|
||||
)}
|
||||
|
||||
+21
@@ -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>
|
||||
);
|
||||
+63
@@ -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>
|
||||
);
|
||||
};
|
||||
+53
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+6
-45
@@ -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>
|
||||
|
||||
+55
@@ -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>
|
||||
);
|
||||
};
|
||||
+248
@@ -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>
|
||||
);
|
||||
};
|
||||
+124
@@ -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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
+65
@@ -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>
|
||||
);
|
||||
};
|
||||
+90
@@ -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>
|
||||
);
|
||||
};
|
||||
+13
-32
@@ -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}
|
||||
|
||||
-26
@@ -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?`}
|
||||
|
||||
+7
@@ -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;
|
||||
+339
@@ -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[];
|
||||
+17
@@ -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;
|
||||
+65
-2
@@ -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,
|
||||
};
|
||||
};
|
||||
+12
@@ -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;
|
||||
};
|
||||
+34
@@ -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;
|
||||
+9
-4
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
+53
@@ -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,
|
||||
|
||||
+1
-1
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
+43
-6
@@ -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;
|
||||
}
|
||||
+8
@@ -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>
|
||||
);
|
||||
};
|
||||
+76
@@ -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],
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user