0
Fork 0
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:
simeng-li 2024-12-12 17:23:41 +08:00 committed by GitHub
parent 69986bc179
commit 96fd7ba49f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 167 additions and 96 deletions

View file

@ -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',

View file

@ -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() {
</Trans>
</InlineNotification>
<FormField title="subscription.plan_usage">
<PlanUsage />
<PlanUsage periodicUsage={periodicUsage} />
</FormField>
</ModalLayout>
</ReactModal>

View file

@ -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],
}

View file

@ -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<string, number> = Object.freeze({
[ReservedPlanId.Free]: 0,
[ReservedPlanId.Pro]: 1,
[ReservedPlanId.Pro202411]: 1,
});
export const checkoutStateQueryKey = 'checkout-state';

View file

@ -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) {
</div>
</div>
<FormField title="subscription.plan_usage">
<PlanUsage periodicUsage={rawPeriodicUsage} />
<PlanUsage periodicUsage={periodicUsage} />
</FormField>
<FormField title="subscription.next_bill">
<BillInfo cost={upcomingCost} isManagePaymentVisible={Boolean(upcomingCost)} />
@ -72,10 +57,7 @@ function CurrentPlan({ periodicUsage: rawPeriodicUsage }: Props) {
{isPaidPlan(planId, isEnterprisePlan) && !isEnterprisePlan && (
<AddOnUsageChangesNotification className={styles.notification} />
)}
<MauLimitExceedNotification
periodicUsage={rawPeriodicUsage}
className={styles.notification}
/>
<MauLimitExceedNotification periodicUsage={periodicUsage} className={styles.notification} />
<PaymentOverdueNotification className={styles.notification} />
</FormCard>
);

View file

@ -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,
],
},
],
},

View file

@ -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<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 = {
readonly currentSkuId: string;
readonly logtoSkus: LogtoSkuResponse[];
readonly onSubscriptionUpdated: () => Promise<void>;
};
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 (
<div className={styles.container}>
<Spacer />
{/** 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 ? (
<div key={skuId}>
<a href={contactEmailLink} className={styles.buttonLink} rel="noopener">
<Button title="general.contact_us_action" />
</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>
return (
<SkuButton
key={skuId}
targetSkuId={skuId}
currentSkuId={currentSkuId}
isCurrentEnterprisePlan={isEnterprisePlan}
loadingSkuId={currentLoadingSkuId}
onClick={handleSubscribe}
/>
);
})}
{/** Enterprise plan button */}
<div>
<a href={contactEmailLink} className={styles.buttonLink} rel="noopener">
<Button

View file

@ -6,7 +6,7 @@ import dayjs from 'dayjs';
import { tryReadResponseErrorBody } from '@/cloud/hooks/use-cloud-api';
import { type LogtoSkuResponse } from '@/cloud/types/router';
import { ticketSupportResponseTimeMap } from '@/consts/plan-quotas';
import { featuredPlanIdOrder, featuredPlanIds } from '@/consts/subscriptions';
import { featuredPlanIds, planIdOrder } from '@/consts/subscriptions';
import { type LogtoSkuQuota } from '@/types/skus';
const addSupportQuota = (logtoSkuResponse: LogtoSkuResponse) => {
@ -35,25 +35,28 @@ export const formatLogtoSkusResponses = (logtoSkus: LogtoSkuResponse[] | undefin
return [];
}
return logtoSkus
.map((logtoSku) => addSupportQuota(logtoSku))
.slice()
.sort(
({ id: previousId }, { id: nextId }) =>
featuredPlanIdOrder.indexOf(previousId) - featuredPlanIdOrder.indexOf(nextId)
);
return logtoSkus.map((logtoSku) => addSupportQuota(logtoSku));
};
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
return index === -1 ? Number.POSITIVE_INFINITY : index;
// E.g. enterprise plan.
return index ?? Number.POSITIVE_INFINITY;
};
export const isDowngradePlan = (fromPlanId: string, toPlanId: string) =>
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 = {
periodStart: Date;
periodEnd: Date;
@ -98,8 +101,18 @@ export const parseExceededSkuQuotaLimitError = async (
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[] =>
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) =>
isProPlan(planId) || isEnterprisePlan;

View file

@ -14,6 +14,7 @@ import { type CloudConnectionLibrary } from './cloud-connection.js';
export type QuotaLibrary = ReturnType<typeof createQuotaLibrary>;
const paidReservedPlans = new Set<string>([ReservedPlanId.Pro, ReservedPlanId.Pro202411]);
/**
* @remarks
* Should report usage changes to the Cloud only when the following conditions are met:
@ -25,7 +26,7 @@ const shouldReportSubscriptionUpdates = (
isEnterprisePlan: boolean,
key: keyof SubscriptionQuota
) =>
(planId === ReservedPlanId.Pro || isEnterprisePlan) && isReportSubscriptionUpdatesUsageKey(key);
(paidReservedPlans.has(planId) || isEnterprisePlan) && isReportSubscriptionUpdatesUsageKey(key);
export const createQuotaLibrary = (cloudConnection: CloudConnectionLibrary) => {
const guardTenantUsageByKey = async (key: keyof SubscriptionUsage) => {

View file

@ -101,6 +101,7 @@ const quota_table = {
included: '{{value, number}} مضمن',
included_mao: '{{value, number}} MAO مضمنة',
extra_quota_price: 'ثم ${{value, number}} شهريًا / لكل واحد بعد ذلك',
extra_token_price: 'ثم ${{value, number}} شهريًا / {{amount, number}} بعد ذلك',
per_month_each: '${{value, number}} شهريًا / لكل واحد',
extra_mao_price: 'ثم ${{value, number}} شهريًا لكل MAO',
per_month: '${{value, number}} شهريًا',

View file

@ -102,6 +102,7 @@ const quota_table = {
included: '{{value, number}} inklusive',
included_mao: '{{value, number}} MAO enthalten',
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',
extra_mao_price: 'Dann ${{value, number}} pro MAO',
per_month: '${{value, number}} pro Monat',

View file

@ -104,6 +104,7 @@ const quota_table = {
included: '{{value, number}} included',
included_mao: '{{value, number}} MAO included',
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',
extra_mao_price: 'Then ${{value, number}} per MAO',
per_month: '${{value, number}} per mo',

View file

@ -102,6 +102,7 @@ const quota_table = {
included: 'incluido{{value, number}}',
included_mao: '{{value, number}} MAO incluido',
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',
extra_mao_price: 'Luego ${{value, number}} por MAO',
per_month: '${{value, number}} por mes',

View file

@ -102,6 +102,7 @@ const quota_table = {
included: '{{value, number}} inclus',
included_mao: '{{value, number}} MAO inclus',
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',
extra_mao_price: 'Ensuite ${{value, number}} par MAO',
per_month: '${{value, number}} par mois',

View file

@ -102,6 +102,7 @@ const quota_table = {
included: '{{value, number}} incluso',
included_mao: '{{value, number}} MAO inclusi',
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',
extra_mao_price: 'Quindi ${{value, number}} per MAO',
per_month: '${{value, number}} al mese',

View file

@ -102,6 +102,7 @@ const quota_table = {
included: '{{value, number}} 込み',
included_mao: '{{value, number}} MAO込み',
extra_quota_price: 'その後、各${{value, number}} / 月ごと',
extra_token_price: 'その後、${{value, number}} / 月ごと {{amount, number}} ごと',
per_month_each: '各${{value, number}} / 月ごと',
extra_mao_price: 'その後、MAOごとに${{value, number}}',
per_month: '${{value, number}} / 月ごと',

View file

@ -100,6 +100,7 @@ const quota_table = {
included: '{{value, number}} 포함',
included_mao: '{{value, number}} MAO 포함',
extra_quota_price: '이후 월당 ${{value, number}} / 각각',
extra_token_price: '이후 월당 ${{value, number}} / 각각 {{amount, number}} 당',
per_month_each: '월당 ${{value, number}} / 각각',
extra_mao_price: '이후 MAO 당 ${{value, number}}',
per_month: '월당 ${{value, number}}',

View file

@ -102,6 +102,7 @@ const quota_table = {
included: '{{value, number}} zawarte',
included_mao: '{{value, number}} MAO wliczone',
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',
extra_mao_price: 'Następnie ${{value, number}} za MAO',
per_month: '${{value, number}} za miesiąc',

View file

@ -102,6 +102,7 @@ const quota_table = {
included: 'incluído{{value, number}}',
included_mao: '{{value, number}} MAO incluído',
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',
extra_mao_price: 'Então ${{value, number}} por MAO',
per_month: '${{value, number}} por mês',

View file

@ -102,6 +102,7 @@ const quota_table = {
included: 'incluído{{value, number}}',
included_mao: '{{value, number}} MAO incluída',
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',
extra_mao_price: 'Depois ${{value, number}} por MAO',
per_month: '${{value, number}} por mês',

View file

@ -102,6 +102,7 @@ const quota_table = {
included: 'включено {{value, number}}',
included_mao: '{{value, number}} MAO включено',
extra_quota_price: 'Затем $ {{value, number}} в месяц / за каждый после',
extra_token_price: 'Затем $ {{value, number}} в месяц / {{amount, number}} за каждый после',
per_month_each: '$ {{value, number}} в месяц / за каждый',
extra_mao_price: 'Затем $ {{value, number}} за MAO',
per_month: '$ {{value, number}} в месяц',

View file

@ -102,6 +102,7 @@ const quota_table = {
included: '{{value, number}} dahil',
included_mao: '{{value, number}} MAO dahil',
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',
extra_mao_price: 'Sonra MAO başına ${{value, number}}',
per_month: 'Aylık ${{value, number}}',

View file

@ -98,6 +98,7 @@ const quota_table = {
included: '已包含{{value, number}}',
included_mao: '已包含 {{value, number}} MAO',
extra_quota_price: '然后每月 ${{value, number}} / 每个之后',
extra_token_price: '然后每月 ${{value, number}} / 每 {{amount, number}} 之后',
per_month_each: '每月 ${{value, number}} / 每个',
extra_mao_price: '然后每 MAO ${{value, number}}',
per_month: '每月 ${{value, number}}',

View file

@ -98,6 +98,7 @@ const quota_table = {
included: '已包含 {{value, number}}',
included_mao: '已包含 {{value, number}} MAO',
extra_quota_price: '然後每月 ${{value, number}} / 每個之後',
extra_token_price: '然後每月 ${{value, number}} / 每 {{amount, number}} 之後',
per_month_each: '每月 ${{value, number}} / 每個',
extra_mao_price: '然後每 MAO ${{value, number}}',
per_month: '每月 ${{value, number}}',

View file

@ -98,6 +98,7 @@ const quota_table = {
included: '已包含 {{value, number}}',
included_mao: '已包含 {{value, number}} MAO',
extra_quota_price: '然後每月 ${{value, number}} / 每個之後',
extra_token_price: '然後每月 ${{value, number}} / 每 {{amount, number}} 之後',
per_month_each: '每月 ${{value, number}} / 每個',
extra_mao_price: '然後每 MAO ${{value, number}}',
per_month: '每月 ${{value, number}}',