0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-31 22:51:25 -05:00

refactor(console): update featured plan quota list (#5147)

This commit is contained in:
Xiao Yijun 2023-12-25 14:49:11 +08:00 committed by GitHub
parent 8d5ff29e27
commit f33ef7067f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 374 additions and 163 deletions

View file

@ -0,0 +1,14 @@
@use '@/scss/underscore' as _;
.icon {
width: 16px;
height: 16px;
&.notCapable {
color: var(--color-error);
}
&.capable {
color: var(--color-on-success-container);
}
}

View file

@ -0,0 +1,45 @@
import { cond } from '@silverhand/essentials';
import classNames from 'classnames';
import Failed from '@/assets/icons/failed.svg';
import Success from '@/assets/icons/success.svg';
import QuotaListItem from '@/components/PlanQuotaList/QuotaListItem';
import DynamicT from '@/ds-components/DynamicT';
import Tag from '@/ds-components/Tag';
import { type SubscriptionPlanQuota } from '@/types/subscriptions';
import * as styles from './index.module.scss';
type Props = {
quotaKey: keyof SubscriptionPlanQuota;
quotaValue: SubscriptionPlanQuota[keyof SubscriptionPlanQuota];
isAddOnQuota: boolean;
isComingSoonTagVisible: boolean;
};
function FeaturedQuotaItem({ quotaKey, quotaValue, isAddOnQuota, isComingSoonTagVisible }: Props) {
const isNotCapable = quotaValue === 0 || quotaValue === false;
const Icon = isNotCapable ? Failed : Success;
return (
<QuotaListItem
quotaKey={quotaKey}
quotaValue={quotaValue}
isAddOn={isAddOnQuota}
icon={
<Icon
className={classNames(styles.icon, isNotCapable ? styles.notCapable : styles.capable)}
/>
}
suffix={cond(
isComingSoonTagVisible && (
<Tag>
<DynamicT forKey="general.coming_soon" />
</Tag>
)
)}
/>
);
}
export default FeaturedQuotaItem;

View file

@ -0,0 +1,6 @@
@use '@/scss/underscore' as _;
.featuredQuotaList {
flex: 1;
padding-bottom: _.unit(8);
}

View file

@ -0,0 +1,69 @@
import { ReservedPlanId } from '@logto/schemas';
import { condArray } from '@silverhand/essentials';
import { useMemo } from 'react';
import PlanQuotaList from '@/components/PlanQuotaList';
import { isDevFeaturesEnabled } from '@/consts/env';
import { comingSoonQuotaKeys } from '@/consts/plan-quotas';
import { quotaItemAddOnPhrasesMap } from '@/consts/quota-item-phrases';
import {
type SubscriptionPlanQuotaEntries,
type SubscriptionPlan,
type SubscriptionPlanQuota,
} from '@/types/subscriptions';
import FeaturedQuotaItem from './FeaturedQuotaItem';
import * as styles from './index.module.scss';
const featuredQuotaKeys = new Set<keyof SubscriptionPlanQuota>([
'mauLimit',
'machineToMachineLimit',
// Todo @xiaoyijun [Pricing] Remove feature flag
...condArray(!isDevFeaturesEnabled && 'standardConnectorsLimit'),
'rolesLimit',
'scopesPerRoleLimit',
'mfaEnabled',
'ssoEnabled',
'organizationsEnabled',
'auditLogsRetentionDays',
]);
type Props = {
plan: SubscriptionPlan;
};
function FeaturedPlanQuotaList({ plan }: Props) {
const { id: planId, quota } = plan;
const featuredEntries = useMemo(
() =>
// eslint-disable-next-line no-restricted-syntax
(Object.entries(quota) as SubscriptionPlanQuotaEntries).filter(([key]) =>
featuredQuotaKeys.has(key)
),
[quota]
);
return (
<PlanQuotaList
className={styles.featuredQuotaList}
entries={featuredEntries}
itemRenderer={(quotaKey, quotaValue) => (
<FeaturedQuotaItem
key={quotaKey}
quotaKey={quotaKey}
quotaValue={quotaValue}
isAddOnQuota={
// Todo @xiaoyijun [Pricing] Remove feature flag
isDevFeaturesEnabled &&
planId !== ReservedPlanId.Free &&
Boolean(quotaItemAddOnPhrasesMap[quotaKey])
}
isComingSoonTagVisible={comingSoonQuotaKeys.includes(quotaKey)}
/>
)}
/>
);
}
export default FeaturedPlanQuotaList;

View file

@ -6,10 +6,8 @@ import { Trans, useTranslation } from 'react-i18next';
import ArrowRight from '@/assets/icons/arrow-right.svg';
import PlanDescription from '@/components/PlanDescription';
import PlanName from '@/components/PlanName';
import PlanQuotaList from '@/components/PlanQuotaList';
import { pricingLink } from '@/consts';
import { isDevFeaturesEnabled } from '@/consts/env';
import { comingSoonQuotaKeys } from '@/consts/plan-quotas';
import { TenantsContext } from '@/contexts/TenantsProvider';
import Button from '@/ds-components/Button';
import DangerousRaw from '@/ds-components/DangerousRaw';
@ -17,12 +15,12 @@ import DynamicT from '@/ds-components/DynamicT';
import TextLink from '@/ds-components/TextLink';
import { type SubscriptionPlanQuota, type SubscriptionPlan } from '@/types/subscriptions';
import FeaturedPlanQuotaList from './FeaturedPlanQuotaList';
import * as styles from './index.module.scss';
const featuredQuotaKeys: Array<keyof SubscriptionPlanQuota> = [
'mauLimit',
'machineToMachineLimit',
'standardConnectorsLimit',
'rolesLimit',
'scopesPerRoleLimit',
'mfaEnabled',
@ -90,13 +88,7 @@ function PlanCardItem({ plan, onSelect }: Props) {
</div>
</div>
<div className={styles.content}>
<PlanQuotaList
hasIcon
quota={quota}
featuredQuotaKeys={featuredQuotaKeys}
comingSoonQuotaKeys={comingSoonQuotaKeys}
className={styles.list}
/>
<FeaturedPlanQuotaList plan={plan} />
{isFreePlan && isFreeTenantExceeded && (
<div className={classNames(styles.tip, styles.exceedFreeTenantsTip)}>
{t('free_tenants_limit', { count: maxFreeTenantLimit })}

View file

@ -1,34 +0,0 @@
@use '@/scss/underscore' as _;
.item {
margin-left: _.unit(4);
font: var(--font-body-2);
&.withIcon {
list-style-type: none;
margin-left: unset;
}
.itemContent {
display: flex;
align-items: center;
gap: _.unit(2);
.icon {
width: 16px;
height: 16px;
&.notCapable {
color: var(--color-error);
}
&.capable {
color: var(--color-on-success-container);
}
}
.lineThrough {
text-decoration: line-through;
}
}
}

View file

@ -1,65 +0,0 @@
import { conditional } from '@silverhand/essentials';
import classNames from 'classnames';
import { useTranslation } from 'react-i18next';
import DescendArrow from '@/assets/icons/descend-arrow.svg';
import Failed from '@/assets/icons/failed.svg';
import Success from '@/assets/icons/success.svg';
import {
quotaItemUnlimitedPhrasesMap,
quotaItemPhrasesMap,
quotaItemLimitedPhrasesMap,
} from '@/consts/quota-item-phrases';
import DynamicT from '@/ds-components/DynamicT';
import Tag from '@/ds-components/Tag';
import { type SubscriptionPlanQuota } from '@/types/subscriptions';
import * as styles from './index.module.scss';
type Props = {
hasIcon?: boolean;
quotaKey: keyof SubscriptionPlanQuota;
quotaValue: SubscriptionPlanQuota[keyof SubscriptionPlanQuota];
isDiffItem?: boolean;
isComingSoonTagVisible?: boolean;
};
function QuotaItem({ hasIcon, quotaKey, quotaValue, isDiffItem, isComingSoonTagVisible }: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console.subscription.quota_item' });
const isUnlimited = quotaValue === null;
const isNotCapable = quotaValue === 0 || quotaValue === false;
const isLimited = Boolean(quotaValue);
const Icon = isNotCapable ? Failed : isDiffItem ? DescendArrow : Success;
return (
<li className={classNames(styles.item, hasIcon && styles.withIcon)}>
<span className={styles.itemContent}>
{hasIcon && (
<Icon
className={classNames(styles.icon, isNotCapable ? styles.notCapable : styles.capable)}
/>
)}
<span className={classNames(isDiffItem && isNotCapable && styles.lineThrough)}>
{isUnlimited && <>{t(quotaItemUnlimitedPhrasesMap[quotaKey])}</>}
{isNotCapable && <>{t(quotaItemPhrasesMap[quotaKey])}</>}
{isLimited && (
<>
{t(
quotaItemLimitedPhrasesMap[quotaKey],
conditional(typeof quotaValue === 'number' && { count: quotaValue }) ?? {}
)}
</>
)}
</span>
{isComingSoonTagVisible && (
<Tag>
<DynamicT forKey="general.coming_soon" />
</Tag>
)}
</span>
</li>
);
}
export default QuotaItem;

View file

@ -0,0 +1,46 @@
import { cond } from '@silverhand/essentials';
import {
quotaItemUnlimitedPhrasesMap,
quotaItemPhrasesMap,
quotaItemLimitedPhrasesMap,
quotaItemAddOnPhrasesMap,
} from '@/consts/quota-item-phrases';
import DynamicT from '@/ds-components/DynamicT';
import { type SubscriptionPlanQuota } from '@/types/subscriptions';
const quotaItemPhraseKeyPrefix = 'subscription.quota_item';
type Props = {
quotaKey: keyof SubscriptionPlanQuota;
quotaValue: SubscriptionPlanQuota[keyof SubscriptionPlanQuota];
isAddOn?: boolean;
};
function QuotaItemPhrase({ quotaKey, quotaValue, isAddOn = false }: Props) {
const isUnlimited = quotaValue === null;
const isNotCapable = quotaValue === 0 || quotaValue === false;
const isLimited = Boolean(quotaValue);
const limitedPhraseKey =
cond(isAddOn && quotaItemAddOnPhrasesMap[quotaKey]) ?? quotaItemLimitedPhrasesMap[quotaKey];
const phraseKey =
cond(isUnlimited && quotaItemUnlimitedPhrasesMap[quotaKey]) ??
cond(isNotCapable && quotaItemPhrasesMap[quotaKey]) ??
cond(isLimited && limitedPhraseKey);
if (!phraseKey) {
// Should not happen
return null;
}
return (
<DynamicT
forKey={`${quotaItemPhraseKeyPrefix}.${phraseKey}`}
interpolation={cond(isLimited && typeof quotaValue === 'number' && { count: quotaValue })}
/>
);
}
export default QuotaItemPhrase;

View file

@ -0,0 +1,19 @@
@use '@/scss/underscore' as _;
.quotaListItem {
font: var(--font-body-2);
// Add a margin to the left for the list item marker
margin-left: _.unit(4);
.content {
display: flex;
align-items: center;
gap: _.unit(2);
}
&.withIcon {
list-style-type: none;
// Unset a margin to the left for the list item marker
margin-left: unset;
}
}

View file

@ -0,0 +1,36 @@
import classNames from 'classnames';
import { type ReactNode } from 'react';
import { type SubscriptionPlanQuota } from '@/types/subscriptions';
import QuotaItemPhrase from './QuotaItemPhrase';
import * as styles from './index.module.scss';
type Props = {
quotaKey: keyof SubscriptionPlanQuota;
quotaValue: SubscriptionPlanQuota[keyof SubscriptionPlanQuota];
icon?: ReactNode;
suffix?: ReactNode;
isAddOn?: boolean;
phraseClassName?: string;
};
function QuotaListItem({ icon, suffix, phraseClassName, ...rest }: Props) {
return (
<li className={classNames(styles.quotaListItem, icon && styles.withIcon)}>
{/**
* Add a `span` as a wrapper to apply the flex layout to the content.
* If we apply the flex layout to the `li` directly, the `li` circle bullet will disappear.
*/}
<span className={styles.content}>
{icon}
<span className={phraseClassName}>
<QuotaItemPhrase {...rest} />
</span>
{suffix}
</span>
</li>
);
}
export default QuotaListItem;

View file

@ -1,6 +1,6 @@
@use '@/scss/underscore' as _;
.list {
.planQuotaList {
margin-block: 0;
padding-inline: 0;

View file

@ -1,5 +1,5 @@
import classNames from 'classnames';
import { useMemo } from 'react';
import { useMemo, type ReactNode } from 'react';
import { planQuotaItemOrder } from '@/consts/plan-quotas';
import {
@ -8,55 +8,31 @@ import {
} from '@/types/subscriptions';
import { sortBy } from '@/utils/sort';
import QuotaItem from './QuotaItem';
import * as styles from './index.module.scss';
type Props = {
quota: Partial<SubscriptionPlanQuota>;
featuredQuotaKeys?: Array<keyof SubscriptionPlanQuota>;
comingSoonQuotaKeys?: Array<keyof SubscriptionPlanQuota>;
entries: SubscriptionPlanQuotaEntries;
itemRenderer: (
quotaKey: keyof SubscriptionPlanQuota,
quotaValue: SubscriptionPlanQuota[keyof SubscriptionPlanQuota]
) => ReactNode;
className?: string;
isDiff?: boolean;
hasIcon?: boolean;
};
function PlanQuotaList({
quota,
featuredQuotaKeys,
comingSoonQuotaKeys,
isDiff,
hasIcon,
className,
}: Props) {
const items = useMemo(() => {
// eslint-disable-next-line no-restricted-syntax
const entries = Object.entries(quota) as SubscriptionPlanQuotaEntries;
const featuredEntries = featuredQuotaKeys
? entries.filter(([key]) => featuredQuotaKeys.includes(key))
: entries;
return featuredEntries
.slice()
.sort(([preQuotaKey], [nextQuotaKey]) =>
sortBy(planQuotaItemOrder)(preQuotaKey, nextQuotaKey)
);
}, [quota, featuredQuotaKeys]);
function PlanQuotaList({ entries, itemRenderer, className }: Props) {
const sortedEntries = useMemo(
() =>
entries
.slice()
.sort(([preQuotaKey], [nextQuotaKey]) =>
sortBy(planQuotaItemOrder)(preQuotaKey, nextQuotaKey)
),
[entries]
);
return (
<ul className={classNames(styles.list, className)}>
{items.map(([quotaKey, quotaValue]) => (
<QuotaItem
key={quotaKey}
isDiffItem={isDiff}
quotaKey={quotaKey}
quotaValue={quotaValue}
hasIcon={hasIcon}
isComingSoonTagVisible={
(quotaValue === null || Boolean(quotaValue)) && comingSoonQuotaKeys?.includes(quotaKey)
}
/>
))}
<ul className={classNames(styles.planQuotaList, className)}>
{sortedEntries.map(([quotaKey, quotaValue]) => itemRenderer(quotaKey, quotaValue))}
</ul>
);
}

View file

@ -105,3 +105,12 @@ export const quotaItemNotEligiblePhrasesMap: Record<
organizationsEnabled: 'organizations_enabled.not_eligible',
ssoEnabled: 'sso_enabled.not_eligible',
};
export const quotaItemAddOnPhrasesMap: Partial<
Record<
keyof SubscriptionPlanQuota,
TFuncKey<'translation', 'admin_console.subscription.quota_item'>
>
> = {
machineToMachineLimit: 'machine_to_machine_limit.add_on',
};

View file

@ -0,0 +1,12 @@
.icon {
width: 16px;
height: 16px;
&.notCapable {
color: var(--color-error);
}
}
.lineThrough {
text-decoration: line-through;
}

View file

@ -0,0 +1,37 @@
import { cond } from '@silverhand/essentials';
import classNames from 'classnames';
import DescendArrow from '@/assets/icons/descend-arrow.svg';
import Failed from '@/assets/icons/failed.svg';
import QuotaListItem from '@/components/PlanQuotaList/QuotaListItem';
import { type SubscriptionPlanQuota } from '@/types/subscriptions';
import * as styles from './index.module.scss';
type Props = {
quotaKey: keyof SubscriptionPlanQuota;
quotaValue: SubscriptionPlanQuota[keyof SubscriptionPlanQuota];
isForDowngradeTargetPlan?: boolean;
};
function DiffQuotaItem({ quotaKey, quotaValue, isForDowngradeTargetPlan }: Props) {
const isNotCapable = quotaValue === 0 || quotaValue === false;
const DowngradeStatusIcon = isNotCapable ? Failed : DescendArrow;
return (
<QuotaListItem
quotaKey={quotaKey}
quotaValue={quotaValue}
icon={cond(
isForDowngradeTargetPlan && (
<DowngradeStatusIcon
className={classNames(styles.icon, isNotCapable && styles.notCapable)}
/>
)
)}
phraseClassName={cond(isNotCapable && styles.lineThrough)}
/>
);
}
export default DiffQuotaItem;

View file

@ -1,22 +1,23 @@
import { Trans, useTranslation } from 'react-i18next';
import { Trans } from 'react-i18next';
import PlanName from '@/components/PlanName';
import PlanQuotaList from '@/components/PlanQuotaList';
import { type SubscriptionPlanQuota } from '@/types/subscriptions';
import DynamicT from '@/ds-components/DynamicT';
import {
type SubscriptionPlanQuotaEntries,
type SubscriptionPlanQuota,
} from '@/types/subscriptions';
import DiffQuotaItem from './DiffQuotaItem';
import * as styles from './index.module.scss';
type Props = {
planName: string;
quotaDiff: Partial<SubscriptionPlanQuota>;
isTarget?: boolean;
isDowngradeTargetPlan?: boolean;
};
function PlanQuotaDiffCard({ planName, quotaDiff, isTarget = false }: Props) {
const { t } = useTranslation(undefined, {
keyPrefix: 'admin_console.subscription.downgrade_modal',
});
function PlanQuotaDiffCard({ planName, quotaDiff, isDowngradeTargetPlan = false }: Props) {
return (
<div className={styles.container}>
<div className={styles.title}>
@ -25,10 +26,25 @@ function PlanQuotaDiffCard({ planName, quotaDiff, isTarget = false }: Props) {
name: <PlanName name={planName} />,
}}
>
{t(isTarget ? 'after' : 'before')}
<DynamicT
forKey={`subscription.downgrade_modal.${isDowngradeTargetPlan ? 'after' : 'before'}`}
/>
</Trans>
</div>
<PlanQuotaList isDiff quota={quotaDiff} hasIcon={isTarget} />
<PlanQuotaList
entries={
// eslint-disable-next-line no-restricted-syntax
Object.entries(quotaDiff) as SubscriptionPlanQuotaEntries
}
itemRenderer={(quotaKey, quotaValue) => (
<DiffQuotaItem
key={quotaKey}
quotaKey={quotaKey}
quotaValue={quotaValue}
isForDowngradeTargetPlan={isDowngradeTargetPlan}
/>
)}
/>
</div>
);
}

View file

@ -57,7 +57,11 @@ function DowngradeConfirmModalContent({ currentPlan, targetPlan }: Props) {
</div>
<div className={styles.content}>
<PlanQuotaDiffCard planName={currentPlanName} quotaDiff={currentQuotaDiff} />
<PlanQuotaDiffCard isTarget planName={targetPlanName} quotaDiff={targetQuotaDiff} />
<PlanQuotaDiffCard
isDowngradeTargetPlan
planName={targetPlanName}
quotaDiff={targetQuotaDiff}
/>
</div>
</div>
);

View file

@ -32,6 +32,8 @@ const quota_item = {
limited_other: '{{count, number}} Maschine-zu-Maschine-Apps',
unlimited: 'Unbegrenzte Maschine-zu-Maschine-Apps',
not_eligible: 'Entferne deine Maschine-zu-Maschine-Apps',
/** UNTRANSLATED */
add_on: 'Additional machine-to-machine apps',
},
resources_limit: {
name: 'API-Ressourcen',

View file

@ -32,6 +32,7 @@ const quota_item = {
limited_other: '{{count, number}} machine to machine apps',
unlimited: 'Unlimited machine to machine apps',
not_eligible: 'Remove your machine to machine apps',
add_on: 'Additional machine-to-machine apps',
},
resources_limit: {
name: 'API resources',

View file

@ -32,6 +32,8 @@ const quota_item = {
limited_other: '{{count, number}} aplicaciones de dispositivo a dispositivo',
unlimited: 'Aplicaciones de dispositivo a dispositivo ilimitadas',
not_eligible: 'Elimine sus aplicaciones de dispositivo a dispositivo',
/** UNTRANSLATED */
add_on: 'Additional machine-to-machine apps',
},
resources_limit: {
name: 'Recursos de API',

View file

@ -32,6 +32,8 @@ const quota_item = {
limited_other: '{{count, number}} applications machine à machine',
unlimited: 'Illimité applications machine à machine',
not_eligible: 'Supprimez vos applications machine à machine',
/** UNTRANSLATED */
add_on: 'Additional machine-to-machine apps',
},
resources_limit: {
name: 'Ressources API',

View file

@ -32,6 +32,8 @@ const quota_item = {
limited_other: '{{count, number}} applicazioni Machine-to-Machine',
unlimited: 'Applicazioni Machine-to-Machine illimitate',
not_eligible: 'Rimuovi le tue applicazioni Machine-to-Machine',
/** UNTRANSLATED */
add_on: 'Additional machine-to-machine apps',
},
resources_limit: {
name: 'Risorse API',

View file

@ -32,6 +32,8 @@ const quota_item = {
limited_other: '{{count, number}} マシン間アプリケーション',
unlimited: '無制限のマシン間アプリケーション',
not_eligible: 'マシン間アプリケーションを削除してください',
/** UNTRANSLATED */
add_on: 'Additional machine-to-machine apps',
},
resources_limit: {
name: 'APIリソース',

View file

@ -32,6 +32,8 @@ const quota_item = {
limited_other: '{{count, number}} 기계 간 앱',
unlimited: '제한 없는 기계 간 앱',
not_eligible: '기계 간 앱을 제거하십시오',
/** UNTRANSLATED */
add_on: 'Additional machine-to-machine apps',
},
resources_limit: {
name: 'API 리소스',

View file

@ -32,6 +32,8 @@ const quota_item = {
limited_other: '{{count, number}} aplikacje machine to machine',
unlimited: 'Nieograniczona liczba aplikacji machine to machine',
not_eligible: 'Usuń swoje aplikacje machine to machine',
/** UNTRANSLATED */
add_on: 'Additional machine-to-machine apps',
},
resources_limit: {
name: 'Zasoby API',

View file

@ -32,6 +32,8 @@ const quota_item = {
limited_other: '{{count, number}} aplicações de máquina a máquina',
unlimited: 'Aplicações de máquina a máquina ilimitadas',
not_eligible: 'Remova suas aplicações de máquina a máquina',
/** UNTRANSLATED */
add_on: 'Additional machine-to-machine apps',
},
resources_limit: {
name: 'Recursos da API',

View file

@ -32,6 +32,8 @@ const quota_item = {
limited_other: '{{count, number}} aplicações de máquina para máquina',
unlimited: 'Aplicações de máquina para máquina ilimitadas',
not_eligible: 'Remover as tuas aplicações de máquina para máquina',
/** UNTRANSLATED */
add_on: 'Additional machine-to-machine apps',
},
resources_limit: {
name: 'Recursos de API',

View file

@ -32,6 +32,8 @@ const quota_item = {
limited_other: '{{count, number}} приложения для машин ко машине',
unlimited: 'Неограниченное количество приложений для машин ко машине',
not_eligible: 'Удалите свои приложения для машин ко машине',
/** UNTRANSLATED */
add_on: 'Additional machine-to-machine apps',
},
resources_limit: {
name: 'API ресурсы',

View file

@ -32,6 +32,8 @@ const quota_item = {
limited_other: '{{count, number}} makineye makine uygulamalar',
unlimited: 'Sınırsız makineye makine uygulamalar',
not_eligible: 'Makineye makine uygulamalarınızı kaldırın',
/** UNTRANSLATED */
add_on: 'Additional machine-to-machine apps',
},
resources_limit: {
name: 'API kaynakları',

View file

@ -32,6 +32,8 @@ const quota_item = {
limited_other: '{{count, number}} 个机器到机器应用',
unlimited: '无限制机器到机器应用',
not_eligible: '移除你的机器到机器应用',
/** UNTRANSLATED */
add_on: 'Additional machine-to-machine apps',
},
resources_limit: {
name: 'API 资源',

View file

@ -32,6 +32,8 @@ const quota_item = {
limited_other: '{{count, number}} 個機器對機器應用程式',
unlimited: '無限機器對機器應用程式',
not_eligible: '刪除您的機器對機器應用程式',
/** UNTRANSLATED */
add_on: 'Additional machine-to-machine apps',
},
resources_limit: {
name: 'API 資源',

View file

@ -32,6 +32,8 @@ const quota_item = {
limited_other: '{{count, number}} 機器對機器應用程式',
unlimited: '不限機器對機器應用程式數',
not_eligible: '移除你的機器對機器應用程式',
/** UNTRANSLATED */
add_on: 'Additional machine-to-machine apps',
},
resources_limit: {
name: 'API 資源',