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,
|
||||
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',
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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],
|
||||
}
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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,
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
|
@ -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);
|
||||
}}
|
||||
return (
|
||||
<SkuButton
|
||||
key={skuId}
|
||||
targetSkuId={skuId}
|
||||
currentSkuId={currentSkuId}
|
||||
isCurrentEnterprisePlan={isEnterprisePlan}
|
||||
loadingSkuId={currentLoadingSkuId}
|
||||
onClick={handleSubscribe}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{/** Enterprise plan button */}
|
||||
<div>
|
||||
<a href={contactEmailLink} className={styles.buttonLink} rel="noopener">
|
||||
<Button
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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}} شهريًا',
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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}} / 月ごと',
|
||||
|
|
|
@ -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}}',
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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}} в месяц',
|
||||
|
|
|
@ -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}}',
|
||||
|
|
|
@ -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}}',
|
||||
|
|
|
@ -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}}',
|
||||
|
|
|
@ -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}}',
|
||||
|
|
Loading…
Reference in a new issue