0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-30 20:33:54 -05:00

refactor(console): refactor console paywall guards (#6863)

* refactor(console): refactor console paywall guards

refactor console paywall guards

* refactor(console): refactor the paywall logic

refactor the paywall logic

* fix(console): replace hardcode pro plan id

replace hardcoded pro plan id

* chore(console): add some comments

add some comments
This commit is contained in:
simeng-li 2024-12-12 14:26:51 +08:00 committed by GitHub
parent 788270b5a7
commit 69986bc179
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 124 additions and 88 deletions

View file

@ -1,6 +1,6 @@
import { type AdminConsoleKey } from '@logto/phrases'; import { type AdminConsoleKey } from '@logto/phrases';
import type { Application } from '@logto/schemas'; import type { Application } from '@logto/schemas';
import { ApplicationType, ReservedPlanId } from '@logto/schemas'; import { ApplicationType } from '@logto/schemas';
import { conditional } from '@silverhand/essentials'; import { conditional } from '@silverhand/essentials';
import { type ReactElement, useContext, useMemo } from 'react'; import { type ReactElement, useContext, useMemo } from 'react';
import { useController, useForm } from 'react-hook-form'; import { useController, useForm } from 'react-hook-form';
@ -11,6 +11,7 @@ import { useSWRConfig } from 'swr';
import { GtagConversionId, reportConversion } from '@/components/Conversion/utils'; import { GtagConversionId, reportConversion } from '@/components/Conversion/utils';
import { isDevFeaturesEnabled } from '@/consts/env'; import { isDevFeaturesEnabled } from '@/consts/env';
import { latestProPlanId } from '@/consts/subscriptions';
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider'; import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import DynamicT from '@/ds-components/DynamicT'; import DynamicT from '@/ds-components/DynamicT';
import FormField from '@/ds-components/FormField'; import FormField from '@/ds-components/FormField';
@ -142,10 +143,7 @@ function CreateForm({
title="applications.create" title="applications.create"
subtitle={subtitleElement} subtitle={subtitleElement}
paywall={conditional( paywall={conditional(
isPaidTenant && !isPaidTenant && watch('type') === ApplicationType.MachineToMachine && latestProPlanId
watch('type') === ApplicationType.MachineToMachine &&
planId !== ReservedPlanId.Pro &&
ReservedPlanId.Pro
)} )}
hasAddOnTag={ hasAddOnTag={
isPaidTenant && isPaidTenant &&

View file

@ -14,6 +14,7 @@ import DangerousRaw from '@/ds-components/DangerousRaw';
import DynamicT from '@/ds-components/DynamicT'; import DynamicT from '@/ds-components/DynamicT';
import FlipOnRtl from '@/ds-components/FlipOnRtl'; import FlipOnRtl from '@/ds-components/FlipOnRtl';
import TextLink from '@/ds-components/TextLink'; import TextLink from '@/ds-components/TextLink';
import { isProPlan } from '@/utils/subscription';
import FeaturedSkuContent from './FeaturedSkuContent'; import FeaturedSkuContent from './FeaturedSkuContent';
import styles from './index.module.scss'; import styles from './index.module.scss';
@ -92,9 +93,7 @@ function SkuCardItem({ sku, onSelect, buttonProps }: Props) {
disabled={(isFreeSku && isFreeTenantExceeded) || buttonProps?.disabled} disabled={(isFreeSku && isFreeTenantExceeded) || buttonProps?.disabled}
/> />
</div> </div>
{skuId === ReservedPlanId.Pro && ( {isProPlan(skuId) && <div className={styles.mostPopularTag}>{t('most_popular')}</div>}
<div className={styles.mostPopularTag}>{t('most_popular')}</div>
)}
</div> </div>
); );
} }

View file

@ -5,11 +5,29 @@ import { useContext } from 'react';
import { isCloud } from '@/consts/env'; import { isCloud } from '@/consts/env';
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider'; import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import { TenantsContext } from '@/contexts/TenantsProvider'; import { TenantsContext } from '@/contexts/TenantsProvider';
import { isProPlan } from '@/utils/subscription';
import styles from './index.module.scss'; import styles from './index.module.scss';
export { default as BetaTag } from './BetaTag'; export { default as BetaTag } from './BetaTag';
/**
* The display tag mapping for each ReservedPlanId.
*/
const planIdTagMap: Record<ReservedPlanId, string> = {
[ReservedPlanId.Free]: 'free',
[ReservedPlanId.Pro]: 'pro',
[ReservedPlanId.Pro202411]: 'pro',
[ReservedPlanId.Development]: 'dev',
[ReservedPlanId.Admin]: 'admin',
};
/**
* The minimum plan required to use the feature.
* Currently we only have pro plan paywall.
*/
export type PaywallPlanId = Extract<ReservedPlanId, ReservedPlanId.Pro | ReservedPlanId.Pro202411>;
export type Props = { export type Props = {
/** /**
* Whether the tag should be visible. It should be `true` if the tenant's subscription * Whether the tag should be visible. It should be `true` if the tenant's subscription
@ -17,8 +35,12 @@ export type Props = {
* tenants. * tenants.
*/ */
readonly isVisible: boolean; readonly isVisible: boolean;
/** The minimum plan required to use the feature. */ /**
readonly plan: Exclude<ReservedPlanId, ReservedPlanId.Free | ReservedPlanId.Development>; * The minimum plan required to use the feature.
* Currently we only have pro plan paywall.
* Set the default value to the latest pro plan id we are using.
*/
readonly plan: PaywallPlanId;
readonly className?: string; readonly className?: string;
}; };
@ -61,7 +83,7 @@ function FeatureTag(props: Props) {
return null; return null;
} }
return <div className={classNames(styles.tag, className)}>{plan}</div>; return <div className={classNames(styles.tag, className)}>{planIdTagMap[plan]}</div>;
} }
export default FeatureTag; export default FeatureTag;
@ -89,7 +111,7 @@ export function CombinedAddOnAndFeatureTag(props: CombinedAddOnAndFeatureTagProp
} }
// Show the "Add-on" tag for Pro plan. // Show the "Add-on" tag for Pro plan.
if (hasAddOnTag && isCloud && planId === ReservedPlanId.Pro) { if (hasAddOnTag && isCloud && isProPlan(planId)) {
return ( return (
<div className={classNames(styles.tag, styles.beta, styles.addOn, className)}>Add-on</div> <div className={classNames(styles.tag, styles.beta, styles.addOn, className)}>Add-on</div>
); );

View file

@ -1,9 +1,10 @@
import { ReservedPlanId, Theme } from '@logto/schemas'; import { Theme } from '@logto/schemas';
import classNames from 'classnames'; import classNames from 'classnames';
import { Suspense, useCallback } from 'react'; import { Suspense, useCallback } from 'react';
import { type Guide, type GuideMetadata } from '@/assets/docs/guides/types'; import { type Guide, type GuideMetadata } from '@/assets/docs/guides/types';
import FeatureTag, { BetaTag } from '@/components/FeatureTag'; import FeatureTag, { BetaTag } from '@/components/FeatureTag';
import { latestProPlanId } from '@/consts/subscriptions';
import Button from '@/ds-components/Button'; import Button from '@/ds-components/Button';
import useTheme from '@/hooks/use-theme'; import useTheme from '@/hooks/use-theme';
import { onKeyDownHandler } from '@/utils/a11y'; import { onKeyDownHandler } from '@/utils/a11y';
@ -61,7 +62,7 @@ function GuideCard({ data, onClick, hasBorder, hasButton, hasPaywall, isBeta }:
<div className={styles.name}>{name}</div> <div className={styles.name}>{name}</div>
{hasTags && ( {hasTags && (
<div className={styles.tagWrapper}> <div className={styles.tagWrapper}>
{hasPaywall && <FeatureTag isVisible plan={ReservedPlanId.Pro} />} {hasPaywall && <FeatureTag isVisible plan={latestProPlanId} />}
{isBeta && <BetaTag />} {isBeta && <BetaTag />}
</div> </div>
)} )}

View file

@ -9,6 +9,7 @@ const registeredPlanDescriptionPhrasesMap: Record<
> = { > = {
[ReservedPlanId.Free]: 'free_plan_description', [ReservedPlanId.Free]: 'free_plan_description',
[ReservedPlanId.Pro]: 'pro_plan_description', [ReservedPlanId.Pro]: 'pro_plan_description',
[ReservedPlanId.Pro202411]: 'pro_plan_description',
}; };
const getRegisteredPlanDescriptionPhrase = ( const getRegisteredPlanDescriptionPhrase = (

View file

@ -12,7 +12,7 @@ import {
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider'; import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import { TenantsContext } from '@/contexts/TenantsProvider'; import { TenantsContext } from '@/contexts/TenantsProvider';
import DynamicT from '@/ds-components/DynamicT'; import DynamicT from '@/ds-components/DynamicT';
import { formatPeriod, isPaidPlan } from '@/utils/subscription'; import { formatPeriod, isPaidPlan, isProPlan } from '@/utils/subscription';
import PlanUsageCard, { type Props as PlanUsageCardProps } from './PlanUsageCard'; import PlanUsageCard, { type Props as PlanUsageCardProps } from './PlanUsageCard';
import styles from './index.module.scss'; import styles from './index.module.scss';
@ -134,7 +134,7 @@ function PlanUsage({ periodicUsage: rawPeriodicUsage }: Props) {
// Hide the quota notice for Pro plans if the basic quota is 0. // Hide the quota notice for Pro plans if the basic quota is 0.
// Per current pricing model design, it should apply to `enterpriseSsoLimit`. // Per current pricing model design, it should apply to `enterpriseSsoLimit`.
...cond( ...cond(
planId === ReservedPlanId.Pro && isProPlan(planId) &&
currentSubscriptionBasicQuota[key] === 0 && { currentSubscriptionBasicQuota[key] === 0 && {
isQuotaNoticeHidden: true, isQuotaNoticeHidden: true,
} }

View file

@ -8,6 +8,7 @@ import { type LogtoSkuQuota } from '@/types/skus';
export const ticketSupportResponseTimeMap: Record<string, number> = { export const ticketSupportResponseTimeMap: Record<string, number> = {
[ReservedPlanId.Free]: 0, [ReservedPlanId.Free]: 0,
[ReservedPlanId.Pro]: 48, [ReservedPlanId.Pro]: 48,
[ReservedPlanId.Pro202411]: 48,
}; };
/** /**

View file

@ -1,5 +1,7 @@
import { ReservedPlanId } from '@logto/schemas'; import { ReservedPlanId } from '@logto/schemas';
import { isDevFeaturesEnabled } from './env';
/** /**
* Shared quota limits between the featured plan content in the `CreateTenantModal` and the `PlanComparisonTable`. * Shared quota limits between the featured plan content in the `CreateTenantModal` and the `PlanComparisonTable`.
*/ */
@ -34,3 +36,6 @@ export const featuredPlanIds: string[] = [ReservedPlanId.Free, ReservedPlanId.Pr
export const featuredPlanIdOrder: string[] = [ReservedPlanId.Free, ReservedPlanId.Pro]; export const featuredPlanIdOrder: string[] = [ReservedPlanId.Free, ReservedPlanId.Pro];
export const checkoutStateQueryKey = 'checkout-state'; export const checkoutStateQueryKey = 'checkout-state';
/** The latest pro plan id we are using. TODO: Remove this when we have the new Pro202411 plan released. */
export const latestProPlanId = isDevFeaturesEnabled ? ReservedPlanId.Pro202411 : ReservedPlanId.Pro;

View file

@ -1,10 +1,9 @@
import type { AdminConsoleKey } from '@logto/phrases'; import type { AdminConsoleKey } from '@logto/phrases';
import { type ReservedPlanId } from '@logto/schemas';
import classNames from 'classnames'; import classNames from 'classnames';
import type { ReactElement } from 'react'; import type { ReactElement } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { CombinedAddOnAndFeatureTag } from '@/components/FeatureTag'; import { CombinedAddOnAndFeatureTag, type PaywallPlanId } from '@/components/FeatureTag';
import type { Props as TextLinkProps } from '@/ds-components/TextLink'; import type { Props as TextLinkProps } from '@/ds-components/TextLink';
import type DangerousRaw from '../DangerousRaw'; import type DangerousRaw from '../DangerousRaw';
@ -22,10 +21,9 @@ export type Props = {
readonly className?: string; readonly className?: string;
/** /**
* If a paywall tag should be shown next to the title. The value is the plan type. * If a paywall tag should be shown next to the title. The value is the plan type.
*
* If not provided, no paywall tag will be shown. * If not provided, no paywall tag will be shown.
*/ */
readonly paywall?: Exclude<ReservedPlanId, ReservedPlanId.Free | ReservedPlanId.Development>; readonly paywall?: PaywallPlanId;
readonly hasAddOnTag?: boolean; readonly hasAddOnTag?: boolean;
}; };

View file

@ -1,5 +1,5 @@
import { isValidUrl } from '@logto/core-kit'; import { isValidUrl } from '@logto/core-kit';
import { ReservedPlanId, type Resource } from '@logto/schemas'; import { type Resource } from '@logto/schemas';
import { conditional } from '@silverhand/essentials'; import { conditional } from '@silverhand/essentials';
import { useContext } from 'react'; import { useContext } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
@ -7,6 +7,7 @@ import { toast } from 'react-hot-toast';
import { Trans, useTranslation } from 'react-i18next'; import { Trans, useTranslation } from 'react-i18next';
import Modal from 'react-modal'; import Modal from 'react-modal';
import { latestProPlanId } from '@/consts/subscriptions';
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider'; import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import FormField from '@/ds-components/FormField'; import FormField from '@/ds-components/FormField';
import ModalLayout from '@/ds-components/ModalLayout'; import ModalLayout from '@/ds-components/ModalLayout';
@ -43,6 +44,7 @@ function CreateForm({ onClose }: Props) {
const api = useApi(); const api = useApi();
const { hasReachedLimit } = useApiResourcesUsage(); const { hasReachedLimit } = useApiResourcesUsage();
const isPaidTenant = isPaidPlan(planId, isEnterprisePlan);
const onSubmit = handleSubmit( const onSubmit = handleSubmit(
trySubmitSafe(async (data) => { trySubmitSafe(async (data) => {
@ -69,8 +71,8 @@ function CreateForm({ onClose }: Props) {
<ModalLayout <ModalLayout
title="api_resources.create" title="api_resources.create"
subtitle="api_resources.subtitle" subtitle="api_resources.subtitle"
paywall={conditional(planId !== ReservedPlanId.Pro && ReservedPlanId.Pro)} paywall={conditional(!isPaidTenant && latestProPlanId)}
hasAddOnTag={isPaidPlan(planId, isEnterprisePlan) && hasReachedLimit} hasAddOnTag={isPaidTenant && hasReachedLimit}
footer={<Footer isCreationLoading={isSubmitting} onClickCreate={onSubmit} />} footer={<Footer isCreationLoading={isSubmitting} onClickCreate={onSubmit} />}
onClose={onClose} onClose={onClose}
> >

View file

@ -1,4 +1,3 @@
import { ReservedPlanId } from '@logto/schemas';
import { cond } from '@silverhand/essentials'; import { cond } from '@silverhand/essentials';
import classNames from 'classnames'; import classNames from 'classnames';
import { useCallback, useContext, useMemo, useState } from 'react'; import { useCallback, useContext, useMemo, useState } from 'react';
@ -12,6 +11,7 @@ import { type SelectedGuide } from '@/components/Guide/GuideCard';
import GuideCardGroup from '@/components/Guide/GuideCardGroup'; import GuideCardGroup from '@/components/Guide/GuideCardGroup';
import { useAppGuideMetadata } from '@/components/Guide/hooks'; import { useAppGuideMetadata } from '@/components/Guide/hooks';
import { isCloud } from '@/consts/env'; import { isCloud } from '@/consts/env';
import { latestProPlanId } from '@/consts/subscriptions';
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider'; import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import { CheckboxGroup } from '@/ds-components/Checkbox'; import { CheckboxGroup } from '@/ds-components/Checkbox';
import OverlayScrollbar from '@/ds-components/OverlayScrollbar'; import OverlayScrollbar from '@/ds-components/OverlayScrollbar';
@ -105,7 +105,7 @@ function GuideLibrary({ className, hasCardBorder, hasCardButton, onSelectGuide }
isVisible={ isVisible={
currentSubscriptionQuota.thirdPartyApplicationsLimit === 0 currentSubscriptionQuota.thirdPartyApplicationsLimit === 0
} }
plan={ReservedPlanId.Pro} plan={latestProPlanId}
/> />
), ),
} }

View file

@ -4,12 +4,13 @@ import { useCallback, useContext, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import FormCard, { FormCardSkeleton } from '@/components/FormCard'; import FormCard, { FormCardSkeleton } from '@/components/FormCard';
import { isCloud } from '@/consts/env'; import { latestProPlanId } from '@/consts/subscriptions';
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider'; import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import { TenantsContext } from '@/contexts/TenantsProvider'; import { TenantsContext } from '@/contexts/TenantsProvider';
import CardTitle from '@/ds-components/CardTitle'; import CardTitle from '@/ds-components/CardTitle';
import FormField from '@/ds-components/FormField'; import FormField from '@/ds-components/FormField';
import useDocumentationUrl from '@/hooks/use-documentation-url'; import useDocumentationUrl from '@/hooks/use-documentation-url';
import { isPaidPlan } from '@/utils/subscription';
import CreateButton from './CreateButton'; import CreateButton from './CreateButton';
import CustomizerItem from './CustomizerItem'; import CustomizerItem from './CustomizerItem';
@ -23,12 +24,11 @@ function CustomizeJwt() {
const { isDevTenant } = useContext(TenantsContext); const { isDevTenant } = useContext(TenantsContext);
const { const {
currentSubscription: { planId }, currentSubscription: { planId, isEnterprisePlan },
currentSubscriptionQuota: { customJwtEnabled }, currentSubscriptionQuota: { customJwtEnabled },
} = useContext(SubscriptionDataContext); } = useContext(SubscriptionDataContext);
const { getDocumentationUrl } = useDocumentationUrl(); const { getDocumentationUrl } = useDocumentationUrl();
const isCustomJwtEnabled = !isCloud || customJwtEnabled;
const showPaywall = planId === ReservedPlanId.Free; const showPaywall = planId === ReservedPlanId.Free;
@ -38,13 +38,15 @@ function CustomizeJwt() {
setDeleteModalTokenType(tokenType); setDeleteModalTokenType(tokenType);
}, []); }, []);
const isPaidTenant = isPaidPlan(planId, isEnterprisePlan);
const { isLoading, accessTokenJwtCustomizer, clientCredentialsJwtCustomizer } = const { isLoading, accessTokenJwtCustomizer, clientCredentialsJwtCustomizer } =
useJwtCustomizer(); useJwtCustomizer();
return ( return (
<main className={styles.mainContent}> <main className={styles.mainContent}>
<CardTitle <CardTitle
paywall={cond((!isCustomJwtEnabled || isDevTenant) && ReservedPlanId.Pro)} paywall={cond(!isPaidTenant && latestProPlanId)}
title="jwt_claims.title" title="jwt_claims.title"
subtitle="jwt_claims.description" subtitle="jwt_claims.description"
learnMoreLink={{ learnMoreLink={{

View file

@ -1,5 +1,4 @@
import { import {
ReservedPlanId,
type RequestErrorBody, type RequestErrorBody,
type SsoConnectorProvidersResponse, type SsoConnectorProvidersResponse,
type SsoConnectorWithProviderConfig, type SsoConnectorWithProviderConfig,
@ -19,7 +18,7 @@ import { getConnectorRadioGroupSize } from '@/components/CreateConnectorForm/uti
import QuotaGuardFooter from '@/components/QuotaGuardFooter'; import QuotaGuardFooter from '@/components/QuotaGuardFooter';
import { isCloud } from '@/consts/env'; import { isCloud } from '@/consts/env';
import { addOnPricingExplanationLink } from '@/consts/external-links'; import { addOnPricingExplanationLink } from '@/consts/external-links';
import { enterpriseSsoAddOnUnitPrice } from '@/consts/subscriptions'; import { enterpriseSsoAddOnUnitPrice, latestProPlanId } from '@/consts/subscriptions';
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider'; import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import Button from '@/ds-components/Button'; import Button from '@/ds-components/Button';
import DynamicT from '@/ds-components/DynamicT'; import DynamicT from '@/ds-components/DynamicT';
@ -63,8 +62,8 @@ function SsoCreationModal({ isOpen, onClose: rawOnClose }: Props) {
const isSsoEnabled = const isSsoEnabled =
!isCloud || !isCloud ||
currentSubscriptionQuota.enterpriseSsoLimit === null || currentSubscriptionQuota.enterpriseSsoLimit === null ||
currentSubscriptionQuota.enterpriseSsoLimit > 0 || currentSubscriptionQuota.enterpriseSsoLimit > 0;
planId === ReservedPlanId.Pro;
const isPaidTenant = isPaidPlan(planId, isEnterprisePlan); const isPaidTenant = isPaidPlan(planId, isEnterprisePlan);
const { data, error } = useSWR<SsoConnectorProvidersResponse, RequestError>( const { data, error } = useSWR<SsoConnectorProvidersResponse, RequestError>(
@ -155,7 +154,7 @@ function SsoCreationModal({ isOpen, onClose: rawOnClose }: Props) {
> >
<ModalLayout <ModalLayout
title="enterprise_sso.create_modal.title" title="enterprise_sso.create_modal.title"
paywall={conditional(isPaidTenant && planId !== ReservedPlanId.Pro && ReservedPlanId.Pro)} paywall={conditional(!isPaidTenant && latestProPlanId)}
hasAddOnTag={isPaidTenant} hasAddOnTag={isPaidTenant}
footer={ footer={
conditional( conditional(
@ -185,7 +184,8 @@ function SsoCreationModal({ isOpen, onClose: rawOnClose }: Props) {
</AddOnNoticeFooter> </AddOnNoticeFooter>
) )
) ?? ) ??
(isSsoEnabled ? ( // Paid tenant can create SSO connectors
(isSsoEnabled || isPaidTenant ? (
<Button <Button
title="enterprise_sso.create_modal.create_button_text" title="enterprise_sso.create_modal.create_button_text"
type="primary" type="primary"

View file

@ -1,4 +1,4 @@
import { type SsoConnectorWithProviderConfig, ReservedPlanId } from '@logto/schemas'; import { type SsoConnectorWithProviderConfig } from '@logto/schemas';
import { conditional } from '@silverhand/essentials'; import { conditional } from '@silverhand/essentials';
import { useContext } from 'react'; import { useContext } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@ -11,9 +11,8 @@ import EnterpriseSsoConnectorEmpty from '@/assets/images/sso-connector-empty.svg
import ItemPreview from '@/components/ItemPreview'; import ItemPreview from '@/components/ItemPreview';
import ListPage from '@/components/ListPage'; import ListPage from '@/components/ListPage';
import { defaultPageSize } from '@/consts'; import { defaultPageSize } from '@/consts';
import { isCloud } from '@/consts/env'; import { latestProPlanId } from '@/consts/subscriptions';
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider'; import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import { TenantsContext } from '@/contexts/TenantsProvider';
import Button from '@/ds-components/Button'; import Button from '@/ds-components/Button';
import TablePlaceholder from '@/ds-components/Table/TablePlaceholder'; import TablePlaceholder from '@/ds-components/Table/TablePlaceholder';
import Tag from '@/ds-components/Tag'; import Tag from '@/ds-components/Tag';
@ -36,19 +35,14 @@ function EnterpriseSso() {
const { pathname } = useLocation(); const { pathname } = useLocation();
const { navigate } = useTenantPathname(); const { navigate } = useTenantPathname();
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { isDevTenant } = useContext(TenantsContext);
const { const {
currentSubscription: { planId, isEnterprisePlan }, currentSubscription: { planId, isEnterprisePlan },
currentSubscriptionQuota,
} = useContext(SubscriptionDataContext); } = useContext(SubscriptionDataContext);
const [{ page }, updateSearchParameters] = useSearchParametersWatcher({ const [{ page }, updateSearchParameters] = useSearchParametersWatcher({
page: 1, page: 1,
}); });
const isSsoEnabled =
!isCloud || currentSubscriptionQuota.enterpriseSsoLimit !== 0 || planId === ReservedPlanId.Pro;
const url = buildUrl('api/sso-connectors', { const url = buildUrl('api/sso-connectors', {
page: String(page), page: String(page),
page_size: String(pageSize), page_size: String(pageSize),
@ -60,14 +54,15 @@ function EnterpriseSso() {
const isLoading = !data && !error; const isLoading = !data && !error;
const [ssoConnectors, totalCount] = data ?? []; const [ssoConnectors, totalCount] = data ?? [];
const isPaidTenant = isPaidPlan(planId, isEnterprisePlan);
return ( return (
<ListPage <ListPage
title={{ title={{
paywall: conditional((!isSsoEnabled || isDevTenant) && ReservedPlanId.Pro), paywall: conditional(!isPaidTenant && latestProPlanId),
title: 'enterprise_sso.title', title: 'enterprise_sso.title',
subtitle: 'enterprise_sso.subtitle', subtitle: 'enterprise_sso.subtitle',
hasAddOnTag: isPaidPlan(planId, isEnterprisePlan), hasAddOnTag: isPaidTenant,
}} }}
pageMeta={{ titleKey: 'enterprise_sso.page_title' }} pageMeta={{ titleKey: 'enterprise_sso.page_title' }}
createButton={conditional( createButton={conditional(

View file

@ -1,4 +1,3 @@
import { ReservedPlanId } from '@logto/schemas';
import { useContext } from 'react'; import { useContext } from 'react';
import { Trans, useTranslation } from 'react-i18next'; import { Trans, useTranslation } from 'react-i18next';
@ -8,6 +7,7 @@ import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import InlineNotification from '@/ds-components/InlineNotification'; import InlineNotification from '@/ds-components/InlineNotification';
import TextLink from '@/ds-components/TextLink'; import TextLink from '@/ds-components/TextLink';
import useUserPreferences from '@/hooks/use-user-preferences'; import useUserPreferences from '@/hooks/use-user-preferences';
import { isPaidPlan } from '@/utils/subscription';
type Props = { type Props = {
readonly className?: string; readonly className?: string;
@ -16,14 +16,16 @@ type Props = {
function UpsellNotice({ className }: Props) { function UpsellNotice({ className }: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { const {
currentSubscription: { planId }, currentSubscription: { planId, isEnterprisePlan },
} = useContext(SubscriptionDataContext); } = useContext(SubscriptionDataContext);
const { const {
data: { mfaUpsellNoticeAcknowledged }, data: { mfaUpsellNoticeAcknowledged },
update, update,
} = useUserPreferences(); } = useUserPreferences();
if (planId !== ReservedPlanId.Pro || mfaUpsellNoticeAcknowledged) { const isPaidTenant = isPaidPlan(planId, isEnterprisePlan);
if (!isPaidTenant || mfaUpsellNoticeAcknowledged) {
return null; return null;
} }

View file

@ -1,11 +1,9 @@
import { ReservedPlanId } from '@logto/schemas';
import { cond } from '@silverhand/essentials'; import { cond } from '@silverhand/essentials';
import { useContext, type ReactNode } from 'react'; import { useContext, type ReactNode } from 'react';
import PageMeta from '@/components/PageMeta'; import PageMeta from '@/components/PageMeta';
import { isCloud } from '@/consts/env'; import { latestProPlanId } from '@/consts/subscriptions';
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider'; import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import { TenantsContext } from '@/contexts/TenantsProvider';
import CardTitle from '@/ds-components/CardTitle'; import CardTitle from '@/ds-components/CardTitle';
import { isPaidPlan } from '@/utils/subscription'; import { isPaidPlan } from '@/utils/subscription';
@ -16,18 +14,18 @@ type Props = {
}; };
function PageWrapper({ children }: Props) { function PageWrapper({ children }: Props) {
const { isDevTenant } = useContext(TenantsContext);
const { const {
currentSubscription: { planId, isEnterprisePlan }, currentSubscription: { planId, isEnterprisePlan },
currentSubscriptionQuota: { mfaEnabled }, currentSubscriptionQuota: { mfaEnabled },
} = useContext(SubscriptionDataContext); } = useContext(SubscriptionDataContext);
const isMfaEnabled = !isCloud || mfaEnabled || planId === ReservedPlanId.Pro;
const isPaidTenant = isPaidPlan(planId, isEnterprisePlan);
return ( return (
<div className={styles.container}> <div className={styles.container}>
<PageMeta titleKey="mfa.title" /> <PageMeta titleKey="mfa.title" />
<CardTitle <CardTitle
paywall={cond((!isMfaEnabled || isDevTenant) && ReservedPlanId.Pro)} paywall={cond(!isPaidTenant && latestProPlanId)}
hasAddOnTag={isPaidPlan(planId, isEnterprisePlan)} hasAddOnTag={isPaidPlan(planId, isEnterprisePlan)}
title="mfa.title" title="mfa.title"
subtitle="mfa.description" subtitle="mfa.description"

View file

@ -1,4 +1,3 @@
import { ReservedPlanId } from '@logto/schemas';
import { cond } from '@silverhand/essentials'; import { cond } from '@silverhand/essentials';
import classNames from 'classnames'; import classNames from 'classnames';
import { useCallback, useContext, useState } from 'react'; import { useCallback, useContext, useState } from 'react';
@ -11,8 +10,8 @@ import PageMeta from '@/components/PageMeta';
import { OrganizationTemplateTabs, organizationTemplateLink } from '@/consts'; import { OrganizationTemplateTabs, organizationTemplateLink } from '@/consts';
import { isCloud } from '@/consts/env'; import { isCloud } from '@/consts/env';
import { subscriptionPage } from '@/consts/pages'; import { subscriptionPage } from '@/consts/pages';
import { latestProPlanId } from '@/consts/subscriptions';
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider'; import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import { TenantsContext } from '@/contexts/TenantsProvider';
import Button from '@/ds-components/Button'; import Button from '@/ds-components/Button';
import Card from '@/ds-components/Card'; import Card from '@/ds-components/Card';
import CardTitle from '@/ds-components/CardTitle'; import CardTitle from '@/ds-components/CardTitle';
@ -22,7 +21,7 @@ import TablePlaceholder from '@/ds-components/Table/TablePlaceholder';
import useDocumentationUrl from '@/hooks/use-documentation-url'; import useDocumentationUrl from '@/hooks/use-documentation-url';
import useTenantPathname from '@/hooks/use-tenant-pathname'; import useTenantPathname from '@/hooks/use-tenant-pathname';
import pageLayout from '@/scss/page-layout.module.scss'; import pageLayout from '@/scss/page-layout.module.scss';
import { isFeatureEnabled } from '@/utils/subscription'; import { isFeatureEnabled, isPaidPlan } from '@/utils/subscription';
import Introduction from '../Organizations/Introduction'; import Introduction from '../Organizations/Introduction';
@ -34,14 +33,16 @@ function OrganizationTemplate() {
const { getDocumentationUrl } = useDocumentationUrl(); const { getDocumentationUrl } = useDocumentationUrl();
const [isGuideDrawerOpen, setIsGuideDrawerOpen] = useState(false); const [isGuideDrawerOpen, setIsGuideDrawerOpen] = useState(false);
const { const {
currentSubscription: { planId }, currentSubscription: { planId, isEnterprisePlan },
currentSubscriptionQuota, currentSubscriptionQuota,
} = useContext(SubscriptionDataContext); } = useContext(SubscriptionDataContext);
const { isDevTenant } = useContext(TenantsContext); const isPaidTenant = isPaidPlan(planId, isEnterprisePlan);
const isOrganizationsDisabled = const isOrganizationsDisabled =
isCloud && // Check if the organizations feature is disabled except for paid tenants.
!isFeatureEnabled(currentSubscriptionQuota.organizationsLimit) && // Paid tenants can create organizations with organization feature add-on applied to their subscription.
planId !== ReservedPlanId.Pro; isCloud && !isFeatureEnabled(currentSubscriptionQuota.organizationsLimit) && !isPaidTenant;
const { navigate } = useTenantPathname(); const { navigate } = useTenantPathname();
const handleUpgradePlan = useCallback(() => { const handleUpgradePlan = useCallback(() => {
@ -59,7 +60,7 @@ function OrganizationTemplate() {
href: getDocumentationUrl(organizationTemplateLink), href: getDocumentationUrl(organizationTemplateLink),
targetBlank: 'noopener', targetBlank: 'noopener',
}} }}
paywall={cond((isOrganizationsDisabled || isDevTenant) && ReservedPlanId.Pro)} paywall={cond(!isPaidTenant && latestProPlanId)}
/> />
<Button <Button
title="application_details.check_guide" title="application_details.check_guide"

View file

@ -1,4 +1,4 @@
import { type Organization, type CreateOrganization, ReservedPlanId } from '@logto/schemas'; import { type Organization, type CreateOrganization } from '@logto/schemas';
import { cond, conditional } from '@silverhand/essentials'; import { cond, conditional } from '@silverhand/essentials';
import { useContext, useEffect } from 'react'; import { useContext, useEffect } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
@ -10,7 +10,7 @@ import ContactUsPhraseLink from '@/components/ContactUsPhraseLink';
import QuotaGuardFooter from '@/components/QuotaGuardFooter'; import QuotaGuardFooter from '@/components/QuotaGuardFooter';
import { isCloud } from '@/consts/env'; import { isCloud } from '@/consts/env';
import { addOnPricingExplanationLink } from '@/consts/external-links'; import { addOnPricingExplanationLink } from '@/consts/external-links';
import { organizationAddOnUnitPrice } from '@/consts/subscriptions'; import { latestProPlanId, organizationAddOnUnitPrice } from '@/consts/subscriptions';
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider'; import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import Button from '@/ds-components/Button'; import Button from '@/ds-components/Button';
import FormField from '@/ds-components/FormField'; import FormField from '@/ds-components/FormField';
@ -41,11 +41,11 @@ function CreateOrganizationModal({ isOpen, onClose }: Props) {
data: { organizationUpsellNoticeAcknowledged }, data: { organizationUpsellNoticeAcknowledged },
update, update,
} = useUserPreferences(); } = useUserPreferences();
const isOrganizationsDisabled =
isCloud &&
!isFeatureEnabled(currentSubscriptionQuota.organizationsLimit) &&
planId !== ReservedPlanId.Pro;
const isPaidTenant = isPaidPlan(planId, isEnterprisePlan); const isPaidTenant = isPaidPlan(planId, isEnterprisePlan);
const isOrganizationsDisabled =
// Check if the organizations feature is disabled except for paid tenants.
// Paid tenants can create organizations with organization feature add-on applied to their subscription.
isCloud && !isFeatureEnabled(currentSubscriptionQuota.organizationsLimit) && !isPaidTenant;
const { const {
reset, reset,
@ -82,7 +82,7 @@ function CreateOrganizationModal({ isOpen, onClose }: Props) {
> >
<ModalLayout <ModalLayout
title="organizations.create_organization" title="organizations.create_organization"
paywall={conditional(isPaidTenant && planId !== ReservedPlanId.Pro && ReservedPlanId.Pro)} paywall={conditional(!isPaidTenant && latestProPlanId)}
hasAddOnTag={isPaidTenant} hasAddOnTag={isPaidTenant}
footer={ footer={
cond( cond(

View file

@ -1,4 +1,3 @@
import { ReservedPlanId } from '@logto/schemas';
import { cond } from '@silverhand/essentials'; import { cond } from '@silverhand/essentials';
import { useCallback, useContext, useState } from 'react'; import { useCallback, useContext, useState } from 'react';
@ -7,6 +6,7 @@ import PageMeta from '@/components/PageMeta';
import { organizationsFeatureLink } from '@/consts'; import { organizationsFeatureLink } from '@/consts';
import { isCloud } from '@/consts/env'; import { isCloud } from '@/consts/env';
import { subscriptionPage } from '@/consts/pages'; import { subscriptionPage } from '@/consts/pages';
import { latestProPlanId } from '@/consts/subscriptions';
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider'; import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import { TenantsContext } from '@/contexts/TenantsProvider'; import { TenantsContext } from '@/contexts/TenantsProvider';
import Button from '@/ds-components/Button'; import Button from '@/ds-components/Button';
@ -35,10 +35,10 @@ function Organizations() {
const { navigate } = useTenantPathname(); const { navigate } = useTenantPathname();
const [isCreating, setIsCreating] = useState(false); const [isCreating, setIsCreating] = useState(false);
const isPaidTenant = isPaidPlan(planId, isEnterprisePlan);
const isOrganizationsDisabled = const isOrganizationsDisabled =
isCloud && isCloud && !isFeatureEnabled(currentSubscriptionQuota.organizationsLimit) && !isPaidTenant;
!isFeatureEnabled(currentSubscriptionQuota.organizationsLimit) &&
planId !== ReservedPlanId.Pro;
const upgradePlan = useCallback(() => { const upgradePlan = useCallback(() => {
navigate(subscriptionPage); navigate(subscriptionPage);
@ -63,7 +63,7 @@ function Organizations() {
<PageMeta titleKey="organizations.page_title" /> <PageMeta titleKey="organizations.page_title" />
<div className={pageLayout.headline}> <div className={pageLayout.headline}>
<CardTitle <CardTitle
paywall={cond((isOrganizationsDisabled || isDevTenant) && ReservedPlanId.Pro)} paywall={cond(!isPaidTenant && latestProPlanId)}
hasAddOnTag={isPaidPlan(planId, isEnterprisePlan)} hasAddOnTag={isPaidPlan(planId, isEnterprisePlan)}
title="organizations.title" title="organizations.title"
subtitle="organizations.subtitle" subtitle="organizations.subtitle"

View file

@ -1,10 +1,10 @@
import { ReservedPlanId } from '@logto/schemas';
import { useContext } from 'react'; import { useContext } from 'react';
import { Controller, useFormContext } from 'react-hook-form'; import { Controller, useFormContext } from 'react-hook-form';
import { Trans, useTranslation } from 'react-i18next'; import { Trans, useTranslation } from 'react-i18next';
import InlineUpsell from '@/components/InlineUpsell'; import InlineUpsell from '@/components/InlineUpsell';
import { isCloud } from '@/consts/env'; import { isCloud } from '@/consts/env';
import { latestProPlanId } from '@/consts/subscriptions';
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider'; import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import Card from '@/ds-components/Card'; import Card from '@/ds-components/Card';
import CodeEditor from '@/ds-components/CodeEditor'; import CodeEditor from '@/ds-components/CodeEditor';
@ -88,7 +88,7 @@ function CustomUiForm() {
descriptionPosition="top" descriptionPosition="top"
featureTag={{ featureTag={{
isVisible: !isBringYourUiEnabled, isVisible: !isBringYourUiEnabled,
plan: ReservedPlanId.Pro, plan: latestProPlanId,
}} }}
> >
<Controller <Controller

View file

@ -1,4 +1,3 @@
import { ReservedPlanId } from '@logto/schemas';
import { useContext } from 'react'; import { useContext } from 'react';
import { Trans, useTranslation } from 'react-i18next'; import { Trans, useTranslation } from 'react-i18next';
@ -8,6 +7,7 @@ import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import InlineNotification from '@/ds-components/InlineNotification'; import InlineNotification from '@/ds-components/InlineNotification';
import TextLink from '@/ds-components/TextLink'; import TextLink from '@/ds-components/TextLink';
import useUserPreferences from '@/hooks/use-user-preferences'; import useUserPreferences from '@/hooks/use-user-preferences';
import { isPaidPlan } from '@/utils/subscription';
type Props = { type Props = {
readonly className?: string; readonly className?: string;
@ -16,14 +16,16 @@ type Props = {
function AddOnUsageChangesNotification({ className }: Props) { function AddOnUsageChangesNotification({ className }: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { const {
currentSubscription: { planId }, currentSubscription: { planId, isEnterprisePlan },
} = useContext(SubscriptionDataContext); } = useContext(SubscriptionDataContext);
const { const {
data: { addOnChangesInCurrentCycleNoticeAcknowledged }, data: { addOnChangesInCurrentCycleNoticeAcknowledged },
update, update,
} = useUserPreferences(); } = useUserPreferences();
if (planId !== ReservedPlanId.Pro || addOnChangesInCurrentCycleNoticeAcknowledged) { const isPaidTenant = isPaidPlan(planId, isEnterprisePlan);
if (!isPaidTenant || addOnChangesInCurrentCycleNoticeAcknowledged) {
return null; return null;
} }

View file

@ -1,10 +1,10 @@
import { ReservedPlanId } from '@logto/schemas';
import { conditional } from '@silverhand/essentials'; import { conditional } from '@silverhand/essentials';
import { useContext, useMemo, useState } from 'react'; import { useContext, useMemo, useState } from 'react';
import { toastResponseError } from '@/cloud/hooks/use-cloud-api'; import { toastResponseError } from '@/cloud/hooks/use-cloud-api';
import { type NewSubscriptionPeriodicUsage } from '@/cloud/types/router'; import { type NewSubscriptionPeriodicUsage } from '@/cloud/types/router';
import { subscriptionPage } from '@/consts/pages'; import { subscriptionPage } from '@/consts/pages';
import { latestProPlanId } from '@/consts/subscriptions';
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider'; import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import { TenantsContext } from '@/contexts/TenantsProvider'; import { TenantsContext } from '@/contexts/TenantsProvider';
import DynamicT from '@/ds-components/DynamicT'; import DynamicT from '@/ds-components/DynamicT';
@ -27,7 +27,7 @@ function MauLimitExceededNotification({ periodicUsage: rawPeriodicUsage, classNa
const { currentTenant } = useContext(TenantsContext); const { currentTenant } = useContext(TenantsContext);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const proSku = useMemo(() => logtoSkus.find(({ id }) => id === ReservedPlanId.Pro), [logtoSkus]); const proSku = useMemo(() => logtoSkus.find(({ id }) => id === latestProPlanId), [logtoSkus]);
const periodicUsage = useMemo( const periodicUsage = useMemo(
() => () =>

View file

@ -1,4 +1,4 @@
import { ReservedPlanId, TenantRole } from '@logto/schemas'; import { TenantRole } from '@logto/schemas';
import { conditional } from '@silverhand/essentials'; import { conditional } from '@silverhand/essentials';
import { useContext, useEffect, useMemo, useState } from 'react'; import { useContext, useEffect, useMemo, useState } from 'react';
import { Controller, FormProvider, useForm } from 'react-hook-form'; import { Controller, FormProvider, useForm } from 'react-hook-form';
@ -9,7 +9,7 @@ import ReactModal from 'react-modal';
import { useAuthedCloudApi } from '@/cloud/hooks/use-cloud-api'; import { useAuthedCloudApi } from '@/cloud/hooks/use-cloud-api';
import AddOnNoticeFooter from '@/components/AddOnNoticeFooter'; import AddOnNoticeFooter from '@/components/AddOnNoticeFooter';
import { addOnPricingExplanationLink } from '@/consts/external-links'; import { addOnPricingExplanationLink } from '@/consts/external-links';
import { tenantMembersAddOnUnitPrice } from '@/consts/subscriptions'; import { latestProPlanId, tenantMembersAddOnUnitPrice } from '@/consts/subscriptions';
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider'; import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import { TenantsContext } from '@/contexts/TenantsProvider'; import { TenantsContext } from '@/contexts/TenantsProvider';
import FormField from '@/ds-components/FormField'; import FormField from '@/ds-components/FormField';
@ -127,7 +127,7 @@ function InviteMemberModal({ isOpen, onClose }: Props) {
<ModalLayout <ModalLayout
size="large" size="large"
title="tenant_members.invite_modal.title" title="tenant_members.invite_modal.title"
paywall={conditional(planId !== ReservedPlanId.Pro && ReservedPlanId.Pro)} paywall={conditional(!isPaidTenant && latestProPlanId)}
hasAddOnTag={isPaidTenant && hasTenantMembersReachedLimit} hasAddOnTag={isPaidTenant && hasTenantMembersReachedLimit}
subtitle="tenant_members.invite_modal.subtitle" subtitle="tenant_members.invite_modal.subtitle"
footer={ footer={

View file

@ -1,4 +1,3 @@
import { ReservedPlanId } from '@logto/schemas';
import { conditional } from '@silverhand/essentials'; import { conditional } from '@silverhand/essentials';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { Trans, useTranslation } from 'react-i18next'; import { Trans, useTranslation } from 'react-i18next';
@ -14,6 +13,7 @@ import {
import DynamicT from '@/ds-components/DynamicT'; import DynamicT from '@/ds-components/DynamicT';
import { type LogtoSkuQuota, type LogtoSkuQuotaEntries } from '@/types/skus'; import { type LogtoSkuQuota, type LogtoSkuQuotaEntries } from '@/types/skus';
import { sortBy } from '@/utils/sort'; import { sortBy } from '@/utils/sort';
import { isProPlan } from '@/utils/subscription';
import styles from './index.module.scss'; import styles from './index.module.scss';
@ -64,7 +64,7 @@ export function NotEligibleSwitchSkuModalContent({
> >
{t(isDowngrade ? 'downgrade_description' : 'upgrade_description')} {t(isDowngrade ? 'downgrade_description' : 'upgrade_description')}
</Trans> </Trans>
{!isDowngrade && id === ReservedPlanId.Pro && t('upgrade_pro_tip')} {!isDowngrade && isProPlan(id) && t('upgrade_pro_tip')}
</div> </div>
<ul className={styles.list}> <ul className={styles.list}>
{orderedEntries.map(([quotaKey, quotaValue]) => { {orderedEntries.map(([quotaKey, quotaValue]) => {

View file

@ -102,8 +102,17 @@ export const pickupFeaturedLogtoSkus = (logtoSkus: LogtoSkuResponse[]): LogtoSku
logtoSkus.filter(({ id }) => featuredPlanIds.includes(id)); logtoSkus.filter(({ id }) => featuredPlanIds.includes(id));
export const isPaidPlan = (planId: string, isEnterprisePlan: boolean) => export const isPaidPlan = (planId: string, isEnterprisePlan: boolean) =>
planId === ReservedPlanId.Pro || isEnterprisePlan; isProPlan(planId) || isEnterprisePlan;
export const isFeatureEnabled = (quota: Nullable<number>): boolean => { export const isFeatureEnabled = (quota: Nullable<number>): boolean => {
return quota === null || quota > 0; return quota === null || quota > 0;
}; };
/**
* We may have more than one pro planId in the future.
* E.g grandfathered {@link ReservedPlanId.Pro} and new {@link ReservedPlanId.Pro202411}.
* User this function to check if the planId can be considered as a pro plan.
*/
export const isProPlan = (planId: string) =>
// eslint-disable-next-line no-restricted-syntax
[ReservedPlanId.Pro, ReservedPlanId.Pro202411].includes(planId as ReservedPlanId);