mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -05:00
refactor(console, core): refactor console to support new pro plan (#6874)
* refactor(console, core): refactor console to support new pro plan refactor console to support new pro plan * fix(console): fix the wrong quota number fix the wrong quota number * fix(console): align the util method usage align the util method usage
This commit is contained in:
parent
69986bc179
commit
96fd7ba49f
25 changed files with 167 additions and 96 deletions
|
@ -10,6 +10,8 @@ import {
|
||||||
freePlanPermissionsLimit,
|
freePlanPermissionsLimit,
|
||||||
freePlanRoleLimit,
|
freePlanRoleLimit,
|
||||||
proPlanAuditLogsRetentionDays,
|
proPlanAuditLogsRetentionDays,
|
||||||
|
// eslint-disable-next-line unused-imports/no-unused-imports -- for jsdoc usage
|
||||||
|
featuredPlanIds,
|
||||||
} from '@/consts/subscriptions';
|
} from '@/consts/subscriptions';
|
||||||
|
|
||||||
type ContentData = {
|
type ContentData = {
|
||||||
|
@ -17,6 +19,15 @@ type ContentData = {
|
||||||
readonly isAvailable: boolean;
|
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 useFeaturedSkuContent = (skuId: string) => {
|
||||||
const { t } = useTranslation(undefined, {
|
const { t } = useTranslation(undefined, {
|
||||||
keyPrefix: 'admin_console.upsell.featured_plan_content',
|
keyPrefix: 'admin_console.upsell.featured_plan_content',
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { cond } from '@silverhand/essentials';
|
import { conditional } from '@silverhand/essentials';
|
||||||
import { useContext, useState } from 'react';
|
import { useContext, useMemo, useState } from 'react';
|
||||||
import { Trans, useTranslation } from 'react-i18next';
|
import { Trans, useTranslation } from 'react-i18next';
|
||||||
import ReactModal from 'react-modal';
|
import ReactModal from 'react-modal';
|
||||||
|
|
||||||
|
@ -33,18 +33,25 @@ function MauExceededModal() {
|
||||||
setHasClosed(true);
|
setHasClosed(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (hasClosed) {
|
const periodicUsage = useMemo(
|
||||||
return null;
|
() =>
|
||||||
}
|
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
|
// eslint-disable-next-line @typescript-eslint/prefer-optional-chain
|
||||||
currentTenant &&
|
currentTenant &&
|
||||||
currentTenant.quota.mauLimit !== null &&
|
currentTenant.quota.mauLimit !== null &&
|
||||||
currentTenant.usage.activeUsers >= currentTenant.quota.mauLimit
|
currentTenant.usage.activeUsers >= currentTenant.quota.mauLimit
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!isMauExceeded) {
|
if (hasClosed || !isMauExceeded) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -85,7 +92,7 @@ function MauExceededModal() {
|
||||||
</Trans>
|
</Trans>
|
||||||
</InlineNotification>
|
</InlineNotification>
|
||||||
<FormField title="subscription.plan_usage">
|
<FormField title="subscription.plan_usage">
|
||||||
<PlanUsage />
|
<PlanUsage periodicUsage={periodicUsage} />
|
||||||
</FormField>
|
</FormField>
|
||||||
</ModalLayout>
|
</ModalLayout>
|
||||||
</ReactModal>
|
</ReactModal>
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import { ReservedPlanId } from '@logto/schemas';
|
import { ReservedPlanId } from '@logto/schemas';
|
||||||
import { cond, conditional } from '@silverhand/essentials';
|
import { cond } from '@silverhand/essentials';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { useContext, useMemo } from 'react';
|
import { useContext } from 'react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type NewSubscriptionPeriodicUsage,
|
type NewSubscriptionPeriodicUsage,
|
||||||
|
@ -10,7 +10,6 @@ import {
|
||||||
type NewSubscriptionQuota,
|
type NewSubscriptionQuota,
|
||||||
} from '@/cloud/types/router';
|
} from '@/cloud/types/router';
|
||||||
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
|
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
|
||||||
import { TenantsContext } from '@/contexts/TenantsProvider';
|
|
||||||
import DynamicT from '@/ds-components/DynamicT';
|
import DynamicT from '@/ds-components/DynamicT';
|
||||||
import { formatPeriod, isPaidPlan, isProPlan } from '@/utils/subscription';
|
import { formatPeriod, isPaidPlan, isProPlan } from '@/utils/subscription';
|
||||||
|
|
||||||
|
@ -26,7 +25,7 @@ import {
|
||||||
} from './utils';
|
} from './utils';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
readonly periodicUsage?: NewSubscriptionPeriodicUsage;
|
readonly periodicUsage: NewSubscriptionPeriodicUsage | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getUsageByKey = (
|
const getUsageByKey = (
|
||||||
|
@ -58,26 +57,13 @@ const getUsageByKey = (
|
||||||
return countBasedUsage[key];
|
return countBasedUsage[key];
|
||||||
};
|
};
|
||||||
|
|
||||||
function PlanUsage({ periodicUsage: rawPeriodicUsage }: Props) {
|
function PlanUsage({ periodicUsage }: Props) {
|
||||||
const {
|
const {
|
||||||
currentSubscriptionQuota,
|
currentSubscriptionQuota,
|
||||||
currentSubscriptionBasicQuota,
|
currentSubscriptionBasicQuota,
|
||||||
currentSubscriptionUsage,
|
currentSubscriptionUsage,
|
||||||
currentSubscription: { currentPeriodStart, currentPeriodEnd, planId, isEnterprisePlan },
|
currentSubscription: { currentPeriodStart, currentPeriodEnd, planId, isEnterprisePlan },
|
||||||
} = useContext(SubscriptionDataContext);
|
} = useContext(SubscriptionDataContext);
|
||||||
const { currentTenant } = useContext(TenantsContext);
|
|
||||||
|
|
||||||
const periodicUsage = useMemo(
|
|
||||||
() =>
|
|
||||||
rawPeriodicUsage ??
|
|
||||||
conditional(
|
|
||||||
currentTenant && {
|
|
||||||
mauLimit: currentTenant.usage.activeUsers,
|
|
||||||
tokenLimit: currentTenant.usage.tokenUsage,
|
|
||||||
}
|
|
||||||
),
|
|
||||||
[currentTenant, rawPeriodicUsage]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!periodicUsage) {
|
if (!periodicUsage) {
|
||||||
return null;
|
return null;
|
||||||
|
@ -102,6 +88,7 @@ function PlanUsage({ periodicUsage: rawPeriodicUsage }: Props) {
|
||||||
titleKey: `subscription.usage.${titleKeyMap[key]}`,
|
titleKey: `subscription.usage.${titleKeyMap[key]}`,
|
||||||
unitPrice: usageKeyPriceMap[key],
|
unitPrice: usageKeyPriceMap[key],
|
||||||
...cond(
|
...cond(
|
||||||
|
// We only show the usage card for MAU and token for Free plan
|
||||||
(key === 'tokenLimit' || key === 'mauLimit' || isPaidTenant) && {
|
(key === 'tokenLimit' || key === 'mauLimit' || isPaidTenant) && {
|
||||||
quota: currentSubscriptionQuota[key],
|
quota: currentSubscriptionQuota[key],
|
||||||
}
|
}
|
||||||
|
|
|
@ -25,15 +25,25 @@ export const tokenAddOnUnitPrice = 80;
|
||||||
export const hooksAddOnUnitPrice = 2;
|
export const hooksAddOnUnitPrice = 2;
|
||||||
/* === Add-on unit price (in USD) === */
|
/* === 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.
|
* 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<string, number> = Object.freeze({
|
||||||
|
[ReservedPlanId.Free]: 0,
|
||||||
|
[ReservedPlanId.Pro]: 1,
|
||||||
|
[ReservedPlanId.Pro202411]: 1,
|
||||||
|
});
|
||||||
|
|
||||||
export const checkoutStateQueryKey = 'checkout-state';
|
export const checkoutStateQueryKey = 'checkout-state';
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import { cond } from '@silverhand/essentials';
|
|
||||||
import { useContext, useMemo } from 'react';
|
import { useContext, useMemo } from 'react';
|
||||||
|
|
||||||
import { type NewSubscriptionPeriodicUsage } from '@/cloud/types/router';
|
import { type NewSubscriptionPeriodicUsage } from '@/cloud/types/router';
|
||||||
|
@ -8,7 +7,6 @@ import PlanDescription from '@/components/PlanDescription';
|
||||||
import PlanUsage from '@/components/PlanUsage';
|
import PlanUsage from '@/components/PlanUsage';
|
||||||
import SkuName from '@/components/SkuName';
|
import SkuName from '@/components/SkuName';
|
||||||
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
|
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
|
||||||
import { TenantsContext } from '@/contexts/TenantsProvider';
|
|
||||||
import FormField from '@/ds-components/FormField';
|
import FormField from '@/ds-components/FormField';
|
||||||
import { isPaidPlan } from '@/utils/subscription';
|
import { isPaidPlan } from '@/utils/subscription';
|
||||||
|
|
||||||
|
@ -21,24 +19,11 @@ type Props = {
|
||||||
readonly periodicUsage?: NewSubscriptionPeriodicUsage;
|
readonly periodicUsage?: NewSubscriptionPeriodicUsage;
|
||||||
};
|
};
|
||||||
|
|
||||||
function CurrentPlan({ periodicUsage: rawPeriodicUsage }: Props) {
|
function CurrentPlan({ periodicUsage }: Props) {
|
||||||
const {
|
const {
|
||||||
currentSku: { unitPrice },
|
currentSku: { unitPrice },
|
||||||
currentSubscription: { upcomingInvoice, isEnterprisePlan, planId },
|
currentSubscription: { upcomingInvoice, isEnterprisePlan, planId },
|
||||||
} = useContext(SubscriptionDataContext);
|
} = 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`.
|
* 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) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<FormField title="subscription.plan_usage">
|
<FormField title="subscription.plan_usage">
|
||||||
<PlanUsage periodicUsage={rawPeriodicUsage} />
|
<PlanUsage periodicUsage={periodicUsage} />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField title="subscription.next_bill">
|
<FormField title="subscription.next_bill">
|
||||||
<BillInfo cost={upcomingCost} isManagePaymentVisible={Boolean(upcomingCost)} />
|
<BillInfo cost={upcomingCost} isManagePaymentVisible={Boolean(upcomingCost)} />
|
||||||
|
@ -72,10 +57,7 @@ function CurrentPlan({ periodicUsage: rawPeriodicUsage }: Props) {
|
||||||
{isPaidPlan(planId, isEnterprisePlan) && !isEnterprisePlan && (
|
{isPaidPlan(planId, isEnterprisePlan) && !isEnterprisePlan && (
|
||||||
<AddOnUsageChangesNotification className={styles.notification} />
|
<AddOnUsageChangesNotification className={styles.notification} />
|
||||||
)}
|
)}
|
||||||
<MauLimitExceedNotification
|
<MauLimitExceedNotification periodicUsage={periodicUsage} className={styles.notification} />
|
||||||
periodicUsage={rawPeriodicUsage}
|
|
||||||
className={styles.notification}
|
|
||||||
/>
|
|
||||||
<PaymentOverdueNotification className={styles.notification} />
|
<PaymentOverdueNotification className={styles.notification} />
|
||||||
</FormCard>
|
</FormCard>
|
||||||
);
|
);
|
||||||
|
|
|
@ -3,6 +3,7 @@ import { type TFuncKey } from 'i18next';
|
||||||
import { Fragment, useMemo } from 'react';
|
import { Fragment, useMemo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { isDevFeaturesEnabled } from '@/consts/env';
|
||||||
import {
|
import {
|
||||||
freePlanAuditLogsRetentionDays,
|
freePlanAuditLogsRetentionDays,
|
||||||
freePlanM2mLimit,
|
freePlanM2mLimit,
|
||||||
|
@ -63,7 +64,11 @@ function PlanComparisonTable() {
|
||||||
const mauLimitTip = t('mau_tip');
|
const mauLimitTip = t('mau_tip');
|
||||||
const includedTokens = t('quota.included_tokens');
|
const includedTokens = t('quota.included_tokens');
|
||||||
const includedTokensTip = t('tokens_tip');
|
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
|
// Applications
|
||||||
const totalApplications = t('application.total');
|
const totalApplications = t('application.total');
|
||||||
|
@ -152,7 +157,11 @@ function PlanComparisonTable() {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: `${includedTokens}|${includedTokensTip}`,
|
name: `${includedTokens}|${includedTokensTip}`,
|
||||||
data: ['500,000', `${proPlanIncludedTokens}`, contact],
|
data: [
|
||||||
|
`${freePlanIncludedTokens}`,
|
||||||
|
`${proPlanIncludedTokens}||${proPlanTokenPrice}`,
|
||||||
|
contact,
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
|
@ -15,22 +15,75 @@ import Spacer from '@/ds-components/Spacer';
|
||||||
import { useConfirmModal } from '@/hooks/use-confirm-modal';
|
import { useConfirmModal } from '@/hooks/use-confirm-modal';
|
||||||
import useSubscribe from '@/hooks/use-subscribe';
|
import useSubscribe from '@/hooks/use-subscribe';
|
||||||
import { NotEligibleSwitchSkuModalContent } from '@/pages/TenantSettings/components/NotEligibleSwitchPlanModalContent';
|
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 DowngradeConfirmModalContent from '../DowngradeConfirmModalContent';
|
||||||
|
|
||||||
import styles from './index.module.scss';
|
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<void>;
|
||||||
|
};
|
||||||
|
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 (
|
||||||
|
<div>
|
||||||
|
<a href={contactEmailLink} className={styles.buttonLink} rel="noopener">
|
||||||
|
<Button title="general.contact_us_action" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
title={
|
||||||
|
isCurrentSku
|
||||||
|
? 'subscription.current'
|
||||||
|
: isDowngrade
|
||||||
|
? 'subscription.downgrade'
|
||||||
|
: 'subscription.upgrade'
|
||||||
|
}
|
||||||
|
type={isDowngrade ? 'default' : 'primary'}
|
||||||
|
disabled={isCurrentSku}
|
||||||
|
isLoading={!isCurrentSku && loadingSkuId === targetSkuId}
|
||||||
|
onClick={() => {
|
||||||
|
void onClick(targetSkuId, isDowngrade);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
readonly currentSkuId: string;
|
readonly currentSkuId: string;
|
||||||
readonly logtoSkus: LogtoSkuResponse[];
|
readonly logtoSkus: LogtoSkuResponse[];
|
||||||
readonly onSubscriptionUpdated: () => Promise<void>;
|
readonly onSubscriptionUpdated: () => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function SwitchPlanActionBar({ onSubscriptionUpdated, currentSkuId, logtoSkus }: Props) {
|
function SwitchPlanActionBar({ onSubscriptionUpdated, currentSkuId, logtoSkus }: Props) {
|
||||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console.subscription' });
|
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console.subscription' });
|
||||||
const { currentTenantId } = useContext(TenantsContext);
|
const { currentTenantId } = useContext(TenantsContext);
|
||||||
const {
|
const {
|
||||||
|
currentSku,
|
||||||
currentSubscription: { isEnterprisePlan },
|
currentSubscription: { isEnterprisePlan },
|
||||||
} = useContext(SubscriptionDataContext);
|
} = useContext(SubscriptionDataContext);
|
||||||
const { subscribe, cancelSubscription } = useSubscribe();
|
const { subscribe, cancelSubscription } = useSubscribe();
|
||||||
|
@ -42,10 +95,9 @@ function SwitchPlanActionBar({ onSubscriptionUpdated, currentSkuId, logtoSkus }:
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentSku = logtoSkus.find(({ id }) => id === currentSkuId);
|
|
||||||
const targetSku = logtoSkus.find(({ id }) => id === targetSkuId);
|
const targetSku = logtoSkus.find(({ id }) => id === targetSkuId);
|
||||||
|
|
||||||
if (!currentSku || !targetSku) {
|
if (!targetSku) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -118,37 +170,20 @@ function SwitchPlanActionBar({ onSubscriptionUpdated, currentSkuId, logtoSkus }:
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<Spacer />
|
<Spacer />
|
||||||
|
{/** Public reserved plan buttons */}
|
||||||
{logtoSkus.map(({ id: skuId }) => {
|
{logtoSkus.map(({ id: skuId }) => {
|
||||||
const isCurrentSku = currentSkuId === skuId;
|
return (
|
||||||
const isDowngrade = isDowngradePlan(currentSkuId, skuId);
|
<SkuButton
|
||||||
|
key={skuId}
|
||||||
// Let user contact us when they are currently on Enterprise plan. Do not allow users to self-serve downgrade.
|
targetSkuId={skuId}
|
||||||
return isEnterprisePlan ? (
|
currentSkuId={currentSkuId}
|
||||||
<div key={skuId}>
|
isCurrentEnterprisePlan={isEnterprisePlan}
|
||||||
<a href={contactEmailLink} className={styles.buttonLink} rel="noopener">
|
loadingSkuId={currentLoadingSkuId}
|
||||||
<Button title="general.contact_us_action" />
|
onClick={handleSubscribe}
|
||||||
</a>
|
/>
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div key={skuId}>
|
|
||||||
<Button
|
|
||||||
title={
|
|
||||||
isCurrentSku
|
|
||||||
? 'subscription.current'
|
|
||||||
: isDowngrade
|
|
||||||
? 'subscription.downgrade'
|
|
||||||
: 'subscription.upgrade'
|
|
||||||
}
|
|
||||||
type={isDowngrade ? 'default' : 'primary'}
|
|
||||||
disabled={isCurrentSku}
|
|
||||||
isLoading={!isCurrentSku && currentLoadingSkuId === skuId}
|
|
||||||
onClick={() => {
|
|
||||||
void handleSubscribe(skuId, isDowngrade);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{/** Enterprise plan button */}
|
||||||
<div>
|
<div>
|
||||||
<a href={contactEmailLink} className={styles.buttonLink} rel="noopener">
|
<a href={contactEmailLink} className={styles.buttonLink} rel="noopener">
|
||||||
<Button
|
<Button
|
||||||
|
|
|
@ -6,7 +6,7 @@ import dayjs from 'dayjs';
|
||||||
import { tryReadResponseErrorBody } from '@/cloud/hooks/use-cloud-api';
|
import { tryReadResponseErrorBody } from '@/cloud/hooks/use-cloud-api';
|
||||||
import { type LogtoSkuResponse } from '@/cloud/types/router';
|
import { type LogtoSkuResponse } from '@/cloud/types/router';
|
||||||
import { ticketSupportResponseTimeMap } from '@/consts/plan-quotas';
|
import { ticketSupportResponseTimeMap } from '@/consts/plan-quotas';
|
||||||
import { featuredPlanIdOrder, featuredPlanIds } from '@/consts/subscriptions';
|
import { featuredPlanIds, planIdOrder } from '@/consts/subscriptions';
|
||||||
import { type LogtoSkuQuota } from '@/types/skus';
|
import { type LogtoSkuQuota } from '@/types/skus';
|
||||||
|
|
||||||
const addSupportQuota = (logtoSkuResponse: LogtoSkuResponse) => {
|
const addSupportQuota = (logtoSkuResponse: LogtoSkuResponse) => {
|
||||||
|
@ -35,25 +35,28 @@ export const formatLogtoSkusResponses = (logtoSkus: LogtoSkuResponse[] | undefin
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return logtoSkus
|
return logtoSkus.map((logtoSku) => addSupportQuota(logtoSku));
|
||||||
.map((logtoSku) => addSupportQuota(logtoSku))
|
|
||||||
.slice()
|
|
||||||
.sort(
|
|
||||||
({ id: previousId }, { id: nextId }) =>
|
|
||||||
featuredPlanIdOrder.indexOf(previousId) - featuredPlanIdOrder.indexOf(nextId)
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getSubscriptionPlanOrderById = (id: string) => {
|
const getSubscriptionPlanOrderById = (id: string) => {
|
||||||
const index = featuredPlanIdOrder.indexOf(id);
|
const index = planIdOrder[id];
|
||||||
|
|
||||||
// Note: if the plan id is not in the featuredPlanIdOrder, it will be treated as the highest priority
|
// Note: if the plan id is not in the featuredPlanIdOrder, it will be treated as the highest priority
|
||||||
return index === -1 ? Number.POSITIVE_INFINITY : index;
|
// E.g. enterprise plan.
|
||||||
|
return index ?? Number.POSITIVE_INFINITY;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isDowngradePlan = (fromPlanId: string, toPlanId: string) =>
|
export const isDowngradePlan = (fromPlanId: string, toPlanId: string) =>
|
||||||
getSubscriptionPlanOrderById(fromPlanId) > getSubscriptionPlanOrderById(toPlanId);
|
getSubscriptionPlanOrderById(fromPlanId) > getSubscriptionPlanOrderById(toPlanId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the two plan ids are equivalent,
|
||||||
|
* one is grandfathered and the other is public visible featured plan.
|
||||||
|
*/
|
||||||
|
export const isEquivalentPlan = (fromPlanId: string, toPlanId: string) =>
|
||||||
|
fromPlanId !== toPlanId &&
|
||||||
|
getSubscriptionPlanOrderById(fromPlanId) === getSubscriptionPlanOrderById(toPlanId);
|
||||||
|
|
||||||
type FormatPeriodOptions = {
|
type FormatPeriodOptions = {
|
||||||
periodStart: Date;
|
periodStart: Date;
|
||||||
periodEnd: Date;
|
periodEnd: Date;
|
||||||
|
@ -98,8 +101,18 @@ export const parseExceededSkuQuotaLimitError = async (
|
||||||
return [true, Object.keys(exceededQuota) as Array<keyof LogtoSkuQuota>];
|
return [true, Object.keys(exceededQuota) as Array<keyof LogtoSkuQuota>];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter the featured plans (public visible) from the Logto SKUs API response.
|
||||||
|
* and sorted by the order of {@link planIdOrder}.
|
||||||
|
*/
|
||||||
export const pickupFeaturedLogtoSkus = (logtoSkus: LogtoSkuResponse[]): LogtoSkuResponse[] =>
|
export const pickupFeaturedLogtoSkus = (logtoSkus: LogtoSkuResponse[]): LogtoSkuResponse[] =>
|
||||||
logtoSkus.filter(({ id }) => featuredPlanIds.includes(id));
|
logtoSkus
|
||||||
|
.filter(({ id }) => featuredPlanIds.includes(id))
|
||||||
|
.slice()
|
||||||
|
.sort(
|
||||||
|
({ id: previousId }, { id: nextId }) =>
|
||||||
|
getSubscriptionPlanOrderById(previousId) - getSubscriptionPlanOrderById(nextId)
|
||||||
|
);
|
||||||
|
|
||||||
export const isPaidPlan = (planId: string, isEnterprisePlan: boolean) =>
|
export const isPaidPlan = (planId: string, isEnterprisePlan: boolean) =>
|
||||||
isProPlan(planId) || isEnterprisePlan;
|
isProPlan(planId) || isEnterprisePlan;
|
||||||
|
|
|
@ -14,6 +14,7 @@ import { type CloudConnectionLibrary } from './cloud-connection.js';
|
||||||
|
|
||||||
export type QuotaLibrary = ReturnType<typeof createQuotaLibrary>;
|
export type QuotaLibrary = ReturnType<typeof createQuotaLibrary>;
|
||||||
|
|
||||||
|
const paidReservedPlans = new Set<string>([ReservedPlanId.Pro, ReservedPlanId.Pro202411]);
|
||||||
/**
|
/**
|
||||||
* @remarks
|
* @remarks
|
||||||
* Should report usage changes to the Cloud only when the following conditions are met:
|
* Should report usage changes to the Cloud only when the following conditions are met:
|
||||||
|
@ -25,7 +26,7 @@ const shouldReportSubscriptionUpdates = (
|
||||||
isEnterprisePlan: boolean,
|
isEnterprisePlan: boolean,
|
||||||
key: keyof SubscriptionQuota
|
key: keyof SubscriptionQuota
|
||||||
) =>
|
) =>
|
||||||
(planId === ReservedPlanId.Pro || isEnterprisePlan) && isReportSubscriptionUpdatesUsageKey(key);
|
(paidReservedPlans.has(planId) || isEnterprisePlan) && isReportSubscriptionUpdatesUsageKey(key);
|
||||||
|
|
||||||
export const createQuotaLibrary = (cloudConnection: CloudConnectionLibrary) => {
|
export const createQuotaLibrary = (cloudConnection: CloudConnectionLibrary) => {
|
||||||
const guardTenantUsageByKey = async (key: keyof SubscriptionUsage) => {
|
const guardTenantUsageByKey = async (key: keyof SubscriptionUsage) => {
|
||||||
|
|
|
@ -101,6 +101,7 @@ const quota_table = {
|
||||||
included: '{{value, number}} مضمن',
|
included: '{{value, number}} مضمن',
|
||||||
included_mao: '{{value, number}} MAO مضمنة',
|
included_mao: '{{value, number}} MAO مضمنة',
|
||||||
extra_quota_price: 'ثم ${{value, number}} شهريًا / لكل واحد بعد ذلك',
|
extra_quota_price: 'ثم ${{value, number}} شهريًا / لكل واحد بعد ذلك',
|
||||||
|
extra_token_price: 'ثم ${{value, number}} شهريًا / {{amount, number}} بعد ذلك',
|
||||||
per_month_each: '${{value, number}} شهريًا / لكل واحد',
|
per_month_each: '${{value, number}} شهريًا / لكل واحد',
|
||||||
extra_mao_price: 'ثم ${{value, number}} شهريًا لكل MAO',
|
extra_mao_price: 'ثم ${{value, number}} شهريًا لكل MAO',
|
||||||
per_month: '${{value, number}} شهريًا',
|
per_month: '${{value, number}} شهريًا',
|
||||||
|
|
|
@ -102,6 +102,7 @@ const quota_table = {
|
||||||
included: '{{value, number}} inklusive',
|
included: '{{value, number}} inklusive',
|
||||||
included_mao: '{{value, number}} MAO enthalten',
|
included_mao: '{{value, number}} MAO enthalten',
|
||||||
extra_quota_price: 'Dann ${{value, number}} pro Monat / je danach',
|
extra_quota_price: 'Dann ${{value, number}} pro Monat / je danach',
|
||||||
|
extra_token_price: 'Dann ${{value, number}} pro Monat / {{amount, number}} danach',
|
||||||
per_month_each: '${{value, number}} pro Monat / je',
|
per_month_each: '${{value, number}} pro Monat / je',
|
||||||
extra_mao_price: 'Dann ${{value, number}} pro MAO',
|
extra_mao_price: 'Dann ${{value, number}} pro MAO',
|
||||||
per_month: '${{value, number}} pro Monat',
|
per_month: '${{value, number}} pro Monat',
|
||||||
|
|
|
@ -104,6 +104,7 @@ const quota_table = {
|
||||||
included: '{{value, number}} included',
|
included: '{{value, number}} included',
|
||||||
included_mao: '{{value, number}} MAO included',
|
included_mao: '{{value, number}} MAO included',
|
||||||
extra_quota_price: 'Then ${{value, number}} per mo / ea after',
|
extra_quota_price: 'Then ${{value, number}} per mo / ea after',
|
||||||
|
extra_token_price: 'Then ${{value, number}} per mo / {{amount, number}} after',
|
||||||
per_month_each: '${{value, number}} per mo / ea',
|
per_month_each: '${{value, number}} per mo / ea',
|
||||||
extra_mao_price: 'Then ${{value, number}} per MAO',
|
extra_mao_price: 'Then ${{value, number}} per MAO',
|
||||||
per_month: '${{value, number}} per mo',
|
per_month: '${{value, number}} per mo',
|
||||||
|
|
|
@ -102,6 +102,7 @@ const quota_table = {
|
||||||
included: 'incluido{{value, number}}',
|
included: 'incluido{{value, number}}',
|
||||||
included_mao: '{{value, number}} MAO incluido',
|
included_mao: '{{value, number}} MAO incluido',
|
||||||
extra_quota_price: 'Luego ${{value, number}} por mes / cada uno después',
|
extra_quota_price: 'Luego ${{value, number}} por mes / cada uno después',
|
||||||
|
extra_token_price: 'Luego ${{value, number}} por mes / {{amount, number}} después',
|
||||||
per_month_each: '${{value, number}} por mes / cada uno',
|
per_month_each: '${{value, number}} por mes / cada uno',
|
||||||
extra_mao_price: 'Luego ${{value, number}} por MAO',
|
extra_mao_price: 'Luego ${{value, number}} por MAO',
|
||||||
per_month: '${{value, number}} por mes',
|
per_month: '${{value, number}} por mes',
|
||||||
|
|
|
@ -102,6 +102,7 @@ const quota_table = {
|
||||||
included: '{{value, number}} inclus',
|
included: '{{value, number}} inclus',
|
||||||
included_mao: '{{value, number}} MAO inclus',
|
included_mao: '{{value, number}} MAO inclus',
|
||||||
extra_quota_price: 'Ensuite ${{value, number}} par mois / chacun après',
|
extra_quota_price: 'Ensuite ${{value, number}} par mois / chacun après',
|
||||||
|
extra_token_price: 'Ensuite ${{value, number}} par mois / {{amount, number}} après',
|
||||||
per_month_each: '${{value, number}} par mois / chacun',
|
per_month_each: '${{value, number}} par mois / chacun',
|
||||||
extra_mao_price: 'Ensuite ${{value, number}} par MAO',
|
extra_mao_price: 'Ensuite ${{value, number}} par MAO',
|
||||||
per_month: '${{value, number}} par mois',
|
per_month: '${{value, number}} par mois',
|
||||||
|
|
|
@ -102,6 +102,7 @@ const quota_table = {
|
||||||
included: '{{value, number}} incluso',
|
included: '{{value, number}} incluso',
|
||||||
included_mao: '{{value, number}} MAO inclusi',
|
included_mao: '{{value, number}} MAO inclusi',
|
||||||
extra_quota_price: 'Quindi ${{value, number}} al mese / ognuno dopo',
|
extra_quota_price: 'Quindi ${{value, number}} al mese / ognuno dopo',
|
||||||
|
extra_token_price: 'Quindi ${{value, number}} al mese / {{amount, number}} dopo',
|
||||||
per_month_each: '${{value, number}} al mese / ognuno',
|
per_month_each: '${{value, number}} al mese / ognuno',
|
||||||
extra_mao_price: 'Quindi ${{value, number}} per MAO',
|
extra_mao_price: 'Quindi ${{value, number}} per MAO',
|
||||||
per_month: '${{value, number}} al mese',
|
per_month: '${{value, number}} al mese',
|
||||||
|
|
|
@ -102,6 +102,7 @@ const quota_table = {
|
||||||
included: '{{value, number}} 込み',
|
included: '{{value, number}} 込み',
|
||||||
included_mao: '{{value, number}} MAO込み',
|
included_mao: '{{value, number}} MAO込み',
|
||||||
extra_quota_price: 'その後、各${{value, number}} / 月ごと',
|
extra_quota_price: 'その後、各${{value, number}} / 月ごと',
|
||||||
|
extra_token_price: 'その後、${{value, number}} / 月ごと {{amount, number}} ごと',
|
||||||
per_month_each: '各${{value, number}} / 月ごと',
|
per_month_each: '各${{value, number}} / 月ごと',
|
||||||
extra_mao_price: 'その後、MAOごとに${{value, number}}',
|
extra_mao_price: 'その後、MAOごとに${{value, number}}',
|
||||||
per_month: '${{value, number}} / 月ごと',
|
per_month: '${{value, number}} / 月ごと',
|
||||||
|
|
|
@ -100,6 +100,7 @@ const quota_table = {
|
||||||
included: '{{value, number}} 포함',
|
included: '{{value, number}} 포함',
|
||||||
included_mao: '{{value, number}} MAO 포함',
|
included_mao: '{{value, number}} MAO 포함',
|
||||||
extra_quota_price: '이후 월당 ${{value, number}} / 각각',
|
extra_quota_price: '이후 월당 ${{value, number}} / 각각',
|
||||||
|
extra_token_price: '이후 월당 ${{value, number}} / 각각 {{amount, number}} 당',
|
||||||
per_month_each: '월당 ${{value, number}} / 각각',
|
per_month_each: '월당 ${{value, number}} / 각각',
|
||||||
extra_mao_price: '이후 MAO 당 ${{value, number}}',
|
extra_mao_price: '이후 MAO 당 ${{value, number}}',
|
||||||
per_month: '월당 ${{value, number}}',
|
per_month: '월당 ${{value, number}}',
|
||||||
|
|
|
@ -102,6 +102,7 @@ const quota_table = {
|
||||||
included: '{{value, number}} zawarte',
|
included: '{{value, number}} zawarte',
|
||||||
included_mao: '{{value, number}} MAO wliczone',
|
included_mao: '{{value, number}} MAO wliczone',
|
||||||
extra_quota_price: 'Następnie ${{value, number}} za miesiąc / każdy po',
|
extra_quota_price: 'Następnie ${{value, number}} za miesiąc / każdy po',
|
||||||
|
extra_token_price: 'Następnie ${{value, number}} za miesiąc / {{amount, number}} po',
|
||||||
per_month_each: '${{value, number}} za miesiąc / każdy',
|
per_month_each: '${{value, number}} za miesiąc / każdy',
|
||||||
extra_mao_price: 'Następnie ${{value, number}} za MAO',
|
extra_mao_price: 'Następnie ${{value, number}} za MAO',
|
||||||
per_month: '${{value, number}} za miesiąc',
|
per_month: '${{value, number}} za miesiąc',
|
||||||
|
|
|
@ -102,6 +102,7 @@ const quota_table = {
|
||||||
included: 'incluído{{value, number}}',
|
included: 'incluído{{value, number}}',
|
||||||
included_mao: '{{value, number}} MAO incluído',
|
included_mao: '{{value, number}} MAO incluído',
|
||||||
extra_quota_price: 'Então ${{value, number}} por mês / cada depois',
|
extra_quota_price: 'Então ${{value, number}} por mês / cada depois',
|
||||||
|
extra_token_price: 'Então ${{value, number}} por mês / {{amount, number}} depois',
|
||||||
per_month_each: '${{value, number}} por mês / cada',
|
per_month_each: '${{value, number}} por mês / cada',
|
||||||
extra_mao_price: 'Então ${{value, number}} por MAO',
|
extra_mao_price: 'Então ${{value, number}} por MAO',
|
||||||
per_month: '${{value, number}} por mês',
|
per_month: '${{value, number}} por mês',
|
||||||
|
|
|
@ -102,6 +102,7 @@ const quota_table = {
|
||||||
included: 'incluído{{value, number}}',
|
included: 'incluído{{value, number}}',
|
||||||
included_mao: '{{value, number}} MAO incluída',
|
included_mao: '{{value, number}} MAO incluída',
|
||||||
extra_quota_price: 'Depois ${{value, number}} por mês / cada um depois',
|
extra_quota_price: 'Depois ${{value, number}} por mês / cada um depois',
|
||||||
|
extra_token_price: 'Depois ${{value, number}} por mês / {{amount, number}} depois',
|
||||||
per_month_each: '${{value, number}} por mês / cada um',
|
per_month_each: '${{value, number}} por mês / cada um',
|
||||||
extra_mao_price: 'Depois ${{value, number}} por MAO',
|
extra_mao_price: 'Depois ${{value, number}} por MAO',
|
||||||
per_month: '${{value, number}} por mês',
|
per_month: '${{value, number}} por mês',
|
||||||
|
|
|
@ -102,6 +102,7 @@ const quota_table = {
|
||||||
included: 'включено {{value, number}}',
|
included: 'включено {{value, number}}',
|
||||||
included_mao: '{{value, number}} MAO включено',
|
included_mao: '{{value, number}} MAO включено',
|
||||||
extra_quota_price: 'Затем $ {{value, number}} в месяц / за каждый после',
|
extra_quota_price: 'Затем $ {{value, number}} в месяц / за каждый после',
|
||||||
|
extra_token_price: 'Затем $ {{value, number}} в месяц / {{amount, number}} за каждый после',
|
||||||
per_month_each: '$ {{value, number}} в месяц / за каждый',
|
per_month_each: '$ {{value, number}} в месяц / за каждый',
|
||||||
extra_mao_price: 'Затем $ {{value, number}} за MAO',
|
extra_mao_price: 'Затем $ {{value, number}} за MAO',
|
||||||
per_month: '$ {{value, number}} в месяц',
|
per_month: '$ {{value, number}} в месяц',
|
||||||
|
|
|
@ -102,6 +102,7 @@ const quota_table = {
|
||||||
included: '{{value, number}} dahil',
|
included: '{{value, number}} dahil',
|
||||||
included_mao: '{{value, number}} MAO dahil',
|
included_mao: '{{value, number}} MAO dahil',
|
||||||
extra_quota_price: 'Sonra aylık ${{value, number}} / sonrasında her biri',
|
extra_quota_price: 'Sonra aylık ${{value, number}} / sonrasında her biri',
|
||||||
|
extra_token_price: 'Sonra aylık ${{value, number}} / {{amount, number}} her biri',
|
||||||
per_month_each: 'Aylık ${{value, number}} / her biri',
|
per_month_each: 'Aylık ${{value, number}} / her biri',
|
||||||
extra_mao_price: 'Sonra MAO başına ${{value, number}}',
|
extra_mao_price: 'Sonra MAO başına ${{value, number}}',
|
||||||
per_month: 'Aylık ${{value, number}}',
|
per_month: 'Aylık ${{value, number}}',
|
||||||
|
|
|
@ -98,6 +98,7 @@ const quota_table = {
|
||||||
included: '已包含{{value, number}}',
|
included: '已包含{{value, number}}',
|
||||||
included_mao: '已包含 {{value, number}} MAO',
|
included_mao: '已包含 {{value, number}} MAO',
|
||||||
extra_quota_price: '然后每月 ${{value, number}} / 每个之后',
|
extra_quota_price: '然后每月 ${{value, number}} / 每个之后',
|
||||||
|
extra_token_price: '然后每月 ${{value, number}} / 每 {{amount, number}} 之后',
|
||||||
per_month_each: '每月 ${{value, number}} / 每个',
|
per_month_each: '每月 ${{value, number}} / 每个',
|
||||||
extra_mao_price: '然后每 MAO ${{value, number}}',
|
extra_mao_price: '然后每 MAO ${{value, number}}',
|
||||||
per_month: '每月 ${{value, number}}',
|
per_month: '每月 ${{value, number}}',
|
||||||
|
|
|
@ -98,6 +98,7 @@ const quota_table = {
|
||||||
included: '已包含 {{value, number}}',
|
included: '已包含 {{value, number}}',
|
||||||
included_mao: '已包含 {{value, number}} MAO',
|
included_mao: '已包含 {{value, number}} MAO',
|
||||||
extra_quota_price: '然後每月 ${{value, number}} / 每個之後',
|
extra_quota_price: '然後每月 ${{value, number}} / 每個之後',
|
||||||
|
extra_token_price: '然後每月 ${{value, number}} / 每 {{amount, number}} 之後',
|
||||||
per_month_each: '每月 ${{value, number}} / 每個',
|
per_month_each: '每月 ${{value, number}} / 每個',
|
||||||
extra_mao_price: '然後每 MAO ${{value, number}}',
|
extra_mao_price: '然後每 MAO ${{value, number}}',
|
||||||
per_month: '每月 ${{value, number}}',
|
per_month: '每月 ${{value, number}}',
|
||||||
|
|
|
@ -98,6 +98,7 @@ const quota_table = {
|
||||||
included: '已包含 {{value, number}}',
|
included: '已包含 {{value, number}}',
|
||||||
included_mao: '已包含 {{value, number}} MAO',
|
included_mao: '已包含 {{value, number}} MAO',
|
||||||
extra_quota_price: '然後每月 ${{value, number}} / 每個之後',
|
extra_quota_price: '然後每月 ${{value, number}} / 每個之後',
|
||||||
|
extra_token_price: '然後每月 ${{value, number}} / 每 {{amount, number}} 之後',
|
||||||
per_month_each: '每月 ${{value, number}} / 每個',
|
per_month_each: '每月 ${{value, number}} / 每個',
|
||||||
extra_mao_price: '然後每 MAO ${{value, number}}',
|
extra_mao_price: '然後每 MAO ${{value, number}}',
|
||||||
per_month: '每月 ${{value, number}}',
|
per_month: '每月 ${{value, number}}',
|
||||||
|
|
Loading…
Reference in a new issue