diff --git a/packages/console/src/components/CreateTenantModal/SelectTenantPlanModal/SkuCardItem/FeaturedSkuContent/use-featured-sku-content.ts b/packages/console/src/components/CreateTenantModal/SelectTenantPlanModal/SkuCardItem/FeaturedSkuContent/use-featured-sku-content.ts index 70662e19b..81f735695 100644 --- a/packages/console/src/components/CreateTenantModal/SelectTenantPlanModal/SkuCardItem/FeaturedSkuContent/use-featured-sku-content.ts +++ b/packages/console/src/components/CreateTenantModal/SelectTenantPlanModal/SkuCardItem/FeaturedSkuContent/use-featured-sku-content.ts @@ -10,6 +10,8 @@ import { freePlanPermissionsLimit, freePlanRoleLimit, proPlanAuditLogsRetentionDays, + // eslint-disable-next-line unused-imports/no-unused-imports -- for jsdoc usage + featuredPlanIds, } from '@/consts/subscriptions'; type ContentData = { @@ -17,6 +19,15 @@ type ContentData = { readonly isAvailable: boolean; }; +/** + * This hook is used to build the plan content on the SelectTenantPlanModal. + * It is used to display the features of the selected plan. + * Currently, all the feature content is hardcoded. + * For the grandfathered Pro plan and new created Pro202411 plan, the content is the same. + * So we don't need to differentiate them. here. + * + * @param skuId The selected sku id. Can only be one of {@link featuredPlanIds} + */ const useFeaturedSkuContent = (skuId: string) => { const { t } = useTranslation(undefined, { keyPrefix: 'admin_console.upsell.featured_plan_content', diff --git a/packages/console/src/components/MauExceededModal/index.tsx b/packages/console/src/components/MauExceededModal/index.tsx index 7480e7142..692289ed2 100644 --- a/packages/console/src/components/MauExceededModal/index.tsx +++ b/packages/console/src/components/MauExceededModal/index.tsx @@ -1,5 +1,5 @@ -import { cond } from '@silverhand/essentials'; -import { useContext, useState } from 'react'; +import { conditional } from '@silverhand/essentials'; +import { useContext, useMemo, useState } from 'react'; import { Trans, useTranslation } from 'react-i18next'; import ReactModal from 'react-modal'; @@ -33,18 +33,25 @@ function MauExceededModal() { setHasClosed(true); }; - if (hasClosed) { - return null; - } + const periodicUsage = useMemo( + () => + conditional( + currentTenant && { + mauLimit: currentTenant.usage.activeUsers, + tokenLimit: currentTenant.usage.tokenUsage, + } + ), + [currentTenant] + ); - const isMauExceeded = cond( + const isMauExceeded = conditional( // eslint-disable-next-line @typescript-eslint/prefer-optional-chain currentTenant && currentTenant.quota.mauLimit !== null && currentTenant.usage.activeUsers >= currentTenant.quota.mauLimit ); - if (!isMauExceeded) { + if (hasClosed || !isMauExceeded) { return null; } @@ -85,7 +92,7 @@ function MauExceededModal() { - + diff --git a/packages/console/src/components/PlanUsage/index.tsx b/packages/console/src/components/PlanUsage/index.tsx index 5014a0783..166099361 100644 --- a/packages/console/src/components/PlanUsage/index.tsx +++ b/packages/console/src/components/PlanUsage/index.tsx @@ -1,8 +1,8 @@ import { ReservedPlanId } from '@logto/schemas'; -import { cond, conditional } from '@silverhand/essentials'; +import { cond } from '@silverhand/essentials'; import classNames from 'classnames'; import dayjs from 'dayjs'; -import { useContext, useMemo } from 'react'; +import { useContext } from 'react'; import { type NewSubscriptionPeriodicUsage, @@ -10,7 +10,6 @@ import { type NewSubscriptionQuota, } from '@/cloud/types/router'; import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider'; -import { TenantsContext } from '@/contexts/TenantsProvider'; import DynamicT from '@/ds-components/DynamicT'; import { formatPeriod, isPaidPlan, isProPlan } from '@/utils/subscription'; @@ -26,7 +25,7 @@ import { } from './utils'; type Props = { - readonly periodicUsage?: NewSubscriptionPeriodicUsage; + readonly periodicUsage: NewSubscriptionPeriodicUsage | undefined; }; const getUsageByKey = ( @@ -58,26 +57,13 @@ const getUsageByKey = ( return countBasedUsage[key]; }; -function PlanUsage({ periodicUsage: rawPeriodicUsage }: Props) { +function PlanUsage({ periodicUsage }: Props) { const { currentSubscriptionQuota, currentSubscriptionBasicQuota, currentSubscriptionUsage, currentSubscription: { currentPeriodStart, currentPeriodEnd, planId, isEnterprisePlan }, } = useContext(SubscriptionDataContext); - const { currentTenant } = useContext(TenantsContext); - - const periodicUsage = useMemo( - () => - rawPeriodicUsage ?? - conditional( - currentTenant && { - mauLimit: currentTenant.usage.activeUsers, - tokenLimit: currentTenant.usage.tokenUsage, - } - ), - [currentTenant, rawPeriodicUsage] - ); if (!periodicUsage) { return null; @@ -102,6 +88,7 @@ function PlanUsage({ periodicUsage: rawPeriodicUsage }: Props) { titleKey: `subscription.usage.${titleKeyMap[key]}`, unitPrice: usageKeyPriceMap[key], ...cond( + // We only show the usage card for MAU and token for Free plan (key === 'tokenLimit' || key === 'mauLimit' || isPaidTenant) && { quota: currentSubscriptionQuota[key], } diff --git a/packages/console/src/consts/subscriptions.ts b/packages/console/src/consts/subscriptions.ts index c92acac8c..dde19ecbb 100644 --- a/packages/console/src/consts/subscriptions.ts +++ b/packages/console/src/consts/subscriptions.ts @@ -25,15 +25,25 @@ export const tokenAddOnUnitPrice = 80; export const hooksAddOnUnitPrice = 2; /* === Add-on unit price (in USD) === */ +// TODO: Remove this dev feature flag when we have the new Pro202411 plan released. /** * In console, only featured plans are shown in the plan selection component. + * we will this to filter out the public visible featured plans. */ -export const featuredPlanIds: string[] = [ReservedPlanId.Free, ReservedPlanId.Pro]; +export const featuredPlanIds: readonly string[] = isDevFeaturesEnabled + ? Object.freeze([ReservedPlanId.Free, ReservedPlanId.Pro202411]) + : Object.freeze([ReservedPlanId.Free, ReservedPlanId.Pro]); /** - * The order of featured plans in the plan selection content component. + * The order of plans in the plan selection content component. + * Unlike the `featuredPlanIds`, include both grandfathered plans and public visible featured plans. + * We need to properly identify the order of the grandfathered plans compared to the new public visible featured plans. */ -export const featuredPlanIdOrder: string[] = [ReservedPlanId.Free, ReservedPlanId.Pro]; +export const planIdOrder: Record = Object.freeze({ + [ReservedPlanId.Free]: 0, + [ReservedPlanId.Pro]: 1, + [ReservedPlanId.Pro202411]: 1, +}); export const checkoutStateQueryKey = 'checkout-state'; diff --git a/packages/console/src/pages/TenantSettings/Subscription/CurrentPlan/index.tsx b/packages/console/src/pages/TenantSettings/Subscription/CurrentPlan/index.tsx index 6c991bae6..7078b5022 100644 --- a/packages/console/src/pages/TenantSettings/Subscription/CurrentPlan/index.tsx +++ b/packages/console/src/pages/TenantSettings/Subscription/CurrentPlan/index.tsx @@ -1,4 +1,3 @@ -import { cond } from '@silverhand/essentials'; import { useContext, useMemo } from 'react'; import { type NewSubscriptionPeriodicUsage } from '@/cloud/types/router'; @@ -8,7 +7,6 @@ import PlanDescription from '@/components/PlanDescription'; import PlanUsage from '@/components/PlanUsage'; import SkuName from '@/components/SkuName'; import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider'; -import { TenantsContext } from '@/contexts/TenantsProvider'; import FormField from '@/ds-components/FormField'; import { isPaidPlan } from '@/utils/subscription'; @@ -21,24 +19,11 @@ type Props = { readonly periodicUsage?: NewSubscriptionPeriodicUsage; }; -function CurrentPlan({ periodicUsage: rawPeriodicUsage }: Props) { +function CurrentPlan({ periodicUsage }: Props) { const { currentSku: { unitPrice }, currentSubscription: { upcomingInvoice, isEnterprisePlan, planId }, } = useContext(SubscriptionDataContext); - const { currentTenant } = useContext(TenantsContext); - - const periodicUsage = useMemo( - () => - rawPeriodicUsage ?? - cond( - currentTenant && { - mauLimit: currentTenant.usage.activeUsers, - tokenLimit: currentTenant.usage.tokenUsage, - } - ), - [currentTenant, rawPeriodicUsage] - ); /** * After the new pricing model goes live, `upcomingInvoice` will always exist. `upcomingInvoice` is updated more frequently than `currentSubscription.upcomingInvoice`. @@ -64,7 +49,7 @@ function CurrentPlan({ periodicUsage: rawPeriodicUsage }: Props) { - + @@ -72,10 +57,7 @@ function CurrentPlan({ periodicUsage: rawPeriodicUsage }: Props) { {isPaidPlan(planId, isEnterprisePlan) && !isEnterprisePlan && ( )} - + ); diff --git a/packages/console/src/pages/TenantSettings/Subscription/PlanComparisonTable/index.tsx b/packages/console/src/pages/TenantSettings/Subscription/PlanComparisonTable/index.tsx index bf033bfa5..490da80a0 100644 --- a/packages/console/src/pages/TenantSettings/Subscription/PlanComparisonTable/index.tsx +++ b/packages/console/src/pages/TenantSettings/Subscription/PlanComparisonTable/index.tsx @@ -3,6 +3,7 @@ import { type TFuncKey } from 'i18next'; import { Fragment, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; +import { isDevFeaturesEnabled } from '@/consts/env'; import { freePlanAuditLogsRetentionDays, freePlanM2mLimit, @@ -63,7 +64,11 @@ function PlanComparisonTable() { const mauLimitTip = t('mau_tip'); const includedTokens = t('quota.included_tokens'); const includedTokensTip = t('tokens_tip'); - const proPlanIncludedTokens = t('million', { value: 1 }); + const proPlanIncludedTokens = isDevFeaturesEnabled ? '100,000' : t('million', { value: 1 }); + const freePlanIncludedTokens = isDevFeaturesEnabled ? '100,000' : '500,000'; + const proPlanTokenPrice = isDevFeaturesEnabled + ? t('extra_token_price', { value: 0.08, amount: 100 }) + : t('extra_token_price', { value: 80, amount: 1_000_000 }); // Applications const totalApplications = t('application.total'); @@ -152,7 +157,11 @@ function PlanComparisonTable() { }, { name: `${includedTokens}|${includedTokensTip}`, - data: ['500,000', `${proPlanIncludedTokens}`, contact], + data: [ + `${freePlanIncludedTokens}`, + `${proPlanIncludedTokens}||${proPlanTokenPrice}`, + contact, + ], }, ], }, diff --git a/packages/console/src/pages/TenantSettings/Subscription/SwitchPlanActionBar/index.tsx b/packages/console/src/pages/TenantSettings/Subscription/SwitchPlanActionBar/index.tsx index 374b9171f..aed886a5c 100644 --- a/packages/console/src/pages/TenantSettings/Subscription/SwitchPlanActionBar/index.tsx +++ b/packages/console/src/pages/TenantSettings/Subscription/SwitchPlanActionBar/index.tsx @@ -15,22 +15,75 @@ import Spacer from '@/ds-components/Spacer'; import { useConfirmModal } from '@/hooks/use-confirm-modal'; import useSubscribe from '@/hooks/use-subscribe'; import { NotEligibleSwitchSkuModalContent } from '@/pages/TenantSettings/components/NotEligibleSwitchPlanModalContent'; -import { isDowngradePlan, parseExceededSkuQuotaLimitError } from '@/utils/subscription'; +import { + isDowngradePlan, + isEquivalentPlan, + parseExceededSkuQuotaLimitError, +} from '@/utils/subscription'; import DowngradeConfirmModalContent from '../DowngradeConfirmModalContent'; import styles from './index.module.scss'; +type SkuButtonProps = { + readonly targetSkuId: string; + readonly currentSkuId: string; + readonly isCurrentEnterprisePlan: boolean; + readonly loadingSkuId?: string; + readonly onClick: (targetSkuId: string, isDowngrade: boolean) => Promise; +}; +function SkuButton({ + targetSkuId, + currentSkuId, + isCurrentEnterprisePlan, + loadingSkuId, + onClick, +}: SkuButtonProps) { + const isCurrentSku = currentSkuId === targetSkuId; + const isDowngrade = isDowngradePlan(currentSkuId, targetSkuId); + const isEquivalent = isEquivalentPlan(currentSkuId, targetSkuId); + + if (isCurrentEnterprisePlan || isEquivalent) { + return ( + + ); + } + + return ( +
+
+ ); +} + type Props = { readonly currentSkuId: string; readonly logtoSkus: LogtoSkuResponse[]; readonly onSubscriptionUpdated: () => Promise; }; - function SwitchPlanActionBar({ onSubscriptionUpdated, currentSkuId, logtoSkus }: Props) { const { t } = useTranslation(undefined, { keyPrefix: 'admin_console.subscription' }); const { currentTenantId } = useContext(TenantsContext); const { + currentSku, currentSubscription: { isEnterprisePlan }, } = useContext(SubscriptionDataContext); const { subscribe, cancelSubscription } = useSubscribe(); @@ -42,10 +95,9 @@ function SwitchPlanActionBar({ onSubscriptionUpdated, currentSkuId, logtoSkus }: return; } - const currentSku = logtoSkus.find(({ id }) => id === currentSkuId); const targetSku = logtoSkus.find(({ id }) => id === targetSkuId); - if (!currentSku || !targetSku) { + if (!targetSku) { return; } @@ -118,37 +170,20 @@ function SwitchPlanActionBar({ onSubscriptionUpdated, currentSkuId, logtoSkus }: return (
+ {/** Public reserved plan buttons */} {logtoSkus.map(({ id: skuId }) => { - const isCurrentSku = currentSkuId === skuId; - const isDowngrade = isDowngradePlan(currentSkuId, skuId); - - // Let user contact us when they are currently on Enterprise plan. Do not allow users to self-serve downgrade. - return isEnterprisePlan ? ( - - ) : ( -
-
+ return ( + ); })} + {/** Enterprise plan button */}