mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -05:00
feat(console): add new usage display for pro subscription plan (#6413)
This commit is contained in:
parent
1e4aa5ad9a
commit
2f40aaa773
7 changed files with 306 additions and 2 deletions
|
@ -0,0 +1,35 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
align-items: left;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--color-divider);
|
||||
background: var(--color-layer-1);
|
||||
padding: _.unit(5.5) _.unit(6);
|
||||
gap: _.unit(6);
|
||||
}
|
||||
|
||||
.title {
|
||||
font: var(--font-title-3);
|
||||
color: var(--color-text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.description {
|
||||
font: var(--font-title-3);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.usageTip {
|
||||
font: var(--font-body-2);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.tag {
|
||||
padding-top: 1px;
|
||||
padding-bottom: 1px;
|
||||
}
|
|
@ -0,0 +1,78 @@
|
|||
import { type AdminConsoleKey } from '@logto/phrases';
|
||||
import classNames from 'classnames';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
import Tip from '@/assets/icons/tip.svg?react';
|
||||
import DynamicT from '@/ds-components/DynamicT';
|
||||
import IconButton from '@/ds-components/IconButton';
|
||||
import Tag from '@/ds-components/Tag';
|
||||
import TextLink from '@/ds-components/TextLink';
|
||||
import { ToggleTip } from '@/ds-components/Tip';
|
||||
|
||||
import { formatNumber } from '../utils';
|
||||
|
||||
import styles from './index.module.scss';
|
||||
|
||||
export type Props = {
|
||||
readonly usage: number | boolean;
|
||||
readonly quota?: number;
|
||||
readonly usageKey: AdminConsoleKey;
|
||||
readonly titleKey: AdminConsoleKey;
|
||||
readonly tooltipKey: AdminConsoleKey;
|
||||
readonly className?: string;
|
||||
};
|
||||
|
||||
function ProPlanUsageCard({ usage, quota, usageKey, titleKey, tooltipKey, className }: Props) {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
|
||||
return (
|
||||
<div className={classNames(styles.card, className)}>
|
||||
<div className={styles.title}>
|
||||
<span>
|
||||
<DynamicT forKey={titleKey} />
|
||||
</span>
|
||||
<ToggleTip
|
||||
content={
|
||||
<Trans
|
||||
components={{
|
||||
a: <TextLink to="https://blog.logto.io/pricing-add-ons/" />,
|
||||
}}
|
||||
>
|
||||
{t(tooltipKey)}
|
||||
</Trans>
|
||||
}
|
||||
>
|
||||
<IconButton size="small">
|
||||
<Tip />
|
||||
</IconButton>
|
||||
</ToggleTip>
|
||||
</div>
|
||||
{typeof usage === 'number' ? (
|
||||
<div className={styles.description}>
|
||||
<Trans
|
||||
components={{
|
||||
span: <span className={styles.usageTip} />,
|
||||
}}
|
||||
>
|
||||
{t(usageKey, {
|
||||
usage:
|
||||
quota && typeof quota === 'number'
|
||||
? `${formatNumber(usage)} / ${formatNumber(quota)}`
|
||||
: formatNumber(usage),
|
||||
})}
|
||||
</Trans>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<Tag className={styles.tag} type="state" status={usage ? 'success' : 'info'}>
|
||||
<DynamicT
|
||||
forKey={`subscription.usage.${usage ? 'status_active' : 'status_inactive'}`}
|
||||
/>
|
||||
</Tag>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ProPlanUsageCard;
|
|
@ -18,10 +18,27 @@
|
|||
align-items: center;
|
||||
}
|
||||
|
||||
.newPricingModelUsage {
|
||||
margin-top: _.unit(1);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: _.unit(2);
|
||||
}
|
||||
|
||||
.cardItem {
|
||||
flex: 0 0 calc(33.333% - _.unit(2) * 2);
|
||||
max-width: calc(33.333% - _.unit(2) * 2);
|
||||
max-height: 112px;
|
||||
}
|
||||
|
||||
.planCycle {
|
||||
font: var(--font-body-2);
|
||||
}
|
||||
|
||||
.planCycleNewPricingModel {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.usageBar {
|
||||
border-radius: 4px;
|
||||
background-color: var(--color-layer-2);
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { conditional } from '@silverhand/essentials';
|
||||
import { ReservedPlanId } from '@logto/schemas';
|
||||
import { cond, conditional } from '@silverhand/essentials';
|
||||
import classNames from 'classnames';
|
||||
import dayjs from 'dayjs';
|
||||
import { useContext } from 'react';
|
||||
|
@ -10,7 +11,9 @@ import DynamicT from '@/ds-components/DynamicT';
|
|||
import { type SubscriptionPlan } from '@/types/subscriptions';
|
||||
import { formatPeriod } from '@/utils/subscription';
|
||||
|
||||
import ProPlanUsageCard, { type Props as ProPlanUsageCardProps } from './ProPlanUsageCard';
|
||||
import styles from './index.module.scss';
|
||||
import { usageKeys, usageKeyMap, titleKeyMap, tooltipKeyMap } from './utils';
|
||||
|
||||
type Props = {
|
||||
/** @deprecated */
|
||||
|
@ -38,7 +41,40 @@ function PlanUsage({ subscriptionUsage, currentSubscription, currentPlan }: Prop
|
|||
|
||||
const usagePercent = conditional(mauLimit && activeUsers / mauLimit);
|
||||
|
||||
return (
|
||||
const usages: ProPlanUsageCardProps[] = usageKeys.map((key) => ({
|
||||
usage: currentSubscriptionUsage[key],
|
||||
usageKey: `subscription.usage.${usageKeyMap[key]}`,
|
||||
titleKey: `subscription.usage.${titleKeyMap[key]}`,
|
||||
tooltipKey: `subscription.usage.${tooltipKeyMap[key]}`,
|
||||
...cond(
|
||||
key === 'tokenLimit' &&
|
||||
currentSubscriptionQuota.tokenLimit && { quota: currentSubscriptionQuota.tokenLimit }
|
||||
),
|
||||
}));
|
||||
|
||||
return isDevFeaturesEnabled &&
|
||||
currentSubscriptionFromNewPricingModel.planId === ReservedPlanId.Pro ? (
|
||||
<div>
|
||||
<div className={classNames(styles.planCycle, styles.planCycleNewPricingModel)}>
|
||||
<DynamicT
|
||||
forKey="subscription.plan_cycle"
|
||||
interpolation={{
|
||||
period: formatPeriod({
|
||||
periodStart: currentPeriodStart,
|
||||
periodEnd: currentPeriodEnd,
|
||||
}),
|
||||
renewDate: dayjs(currentPeriodEnd).add(1, 'day').format('MMM D, YYYY'),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.newPricingModelUsage}>
|
||||
{usages.map((props, index) => (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<ProPlanUsageCard key={index} className={styles.cardItem} {...props} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.usage}>
|
||||
{`${activeUsers} / `}
|
||||
|
|
77
packages/console/src/components/PlanUsage/utils.ts
Normal file
77
packages/console/src/components/PlanUsage/utils.ts
Normal file
|
@ -0,0 +1,77 @@
|
|||
import { type TFuncKey } from 'i18next';
|
||||
|
||||
import { type NewSubscriptionQuota } from '@/cloud/types/router';
|
||||
|
||||
type UsageKey = Pick<
|
||||
NewSubscriptionQuota,
|
||||
| 'mauLimit'
|
||||
| 'organizationsEnabled'
|
||||
| 'mfaEnabled'
|
||||
| 'enterpriseSsoLimit'
|
||||
| 'resourcesLimit'
|
||||
| 'machineToMachineLimit'
|
||||
| 'tenantMembersLimit'
|
||||
| 'tokenLimit'
|
||||
| 'hooksLimit'
|
||||
>;
|
||||
|
||||
export const usageKeys: Array<keyof UsageKey> = [
|
||||
'mauLimit',
|
||||
'organizationsEnabled',
|
||||
'mfaEnabled',
|
||||
'enterpriseSsoLimit',
|
||||
'resourcesLimit',
|
||||
'machineToMachineLimit',
|
||||
'tenantMembersLimit',
|
||||
'tokenLimit',
|
||||
'hooksLimit',
|
||||
];
|
||||
|
||||
export const usageKeyMap: Record<
|
||||
keyof UsageKey,
|
||||
TFuncKey<'translation', 'admin_console.subscription.usage'>
|
||||
> = {
|
||||
mauLimit: 'mau.description',
|
||||
organizationsEnabled: 'organizations.description',
|
||||
mfaEnabled: 'mfa.description',
|
||||
enterpriseSsoLimit: 'enterprise_sso.description',
|
||||
resourcesLimit: 'api_resources.description',
|
||||
machineToMachineLimit: 'machine_to_machine.description',
|
||||
tenantMembersLimit: 'tenant_members.description',
|
||||
tokenLimit: 'tokens.description',
|
||||
hooksLimit: 'hooks.description',
|
||||
};
|
||||
|
||||
export const titleKeyMap: Record<
|
||||
keyof UsageKey,
|
||||
TFuncKey<'translation', 'admin_console.subscription.usage'>
|
||||
> = {
|
||||
mauLimit: 'mau.title',
|
||||
organizationsEnabled: 'organizations.title',
|
||||
mfaEnabled: 'mfa.title',
|
||||
enterpriseSsoLimit: 'enterprise_sso.title',
|
||||
resourcesLimit: 'api_resources.title',
|
||||
machineToMachineLimit: 'machine_to_machine.title',
|
||||
tenantMembersLimit: 'tenant_members.title',
|
||||
tokenLimit: 'tokens.title',
|
||||
hooksLimit: 'hooks.title',
|
||||
};
|
||||
|
||||
export const tooltipKeyMap: Record<
|
||||
keyof UsageKey,
|
||||
TFuncKey<'translation', 'admin_console.subscription.usage'>
|
||||
> = {
|
||||
mauLimit: 'mau.tooltip',
|
||||
organizationsEnabled: 'organizations.tooltip',
|
||||
mfaEnabled: 'mfa.tooltip',
|
||||
enterpriseSsoLimit: 'enterprise_sso.tooltip',
|
||||
resourcesLimit: 'api_resources.tooltip',
|
||||
machineToMachineLimit: 'machine_to_machine.tooltip',
|
||||
tenantMembersLimit: 'tenant_members.tooltip',
|
||||
tokenLimit: 'tokens.tooltip',
|
||||
hooksLimit: 'hooks.tooltip',
|
||||
};
|
||||
|
||||
export const formatNumber = (number: number): string => {
|
||||
return number.toString().replaceAll(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||
};
|
|
@ -1,5 +1,6 @@
|
|||
import quota_item from './quota-item.js';
|
||||
import quota_table from './quota-table.js';
|
||||
import usage from './usage.js';
|
||||
|
||||
const subscription = {
|
||||
free_plan: 'Free plan',
|
||||
|
@ -64,6 +65,7 @@ const subscription = {
|
|||
downgrade_success: 'Successfully downgraded to <name/>',
|
||||
subscription_check_timeout: 'Subscription check timed out. Please refresh later.',
|
||||
no_subscription: 'No subscription',
|
||||
usage,
|
||||
};
|
||||
|
||||
export default Object.freeze(subscription);
|
||||
|
|
|
@ -0,0 +1,59 @@
|
|||
const usage = {
|
||||
status_active: 'On',
|
||||
status_inactive: 'Off',
|
||||
mau: {
|
||||
title: 'MAU',
|
||||
description: '{{usage}}',
|
||||
tooltip:
|
||||
'A MAU is a unique user who has exchanged at least one token with Logto within a billing cycle. Unlimited for the Pro Plan. <a>Learn more</a>',
|
||||
},
|
||||
organizations: {
|
||||
title: 'Organizations',
|
||||
description: '{{usage}}',
|
||||
tooltip:
|
||||
'Add-on feature with a flat rate of ${{price, number}} per month. Price is not affected by the number of organizations or their activity level.',
|
||||
},
|
||||
mfa: {
|
||||
title: 'MFA',
|
||||
description: '{{usage}}',
|
||||
tooltip:
|
||||
'Add-on feature with a flat rate of ${{price, number}} per month. Price is not affected by the number of authentication factors used.',
|
||||
},
|
||||
enterprise_sso: {
|
||||
title: 'Enterprise SSO',
|
||||
description: '{{usage}}',
|
||||
tooltip: 'Add-on feature with a price of ${{price, number}} per SSO connection per month.',
|
||||
},
|
||||
api_resources: {
|
||||
title: 'API resources',
|
||||
description: '{{usage}} <span>(Free for the first 3)</span>',
|
||||
tooltip:
|
||||
'Add-on feature priced at ${{price, number}} per resource per month. The first 3 API resources are free.',
|
||||
},
|
||||
machine_to_machine: {
|
||||
title: 'Machine-to-machine',
|
||||
description: '{{usage}} <span>(Free for the first 1)</span>',
|
||||
tooltip:
|
||||
'Add-on feature priced at ${{price, number}} per app per month. The first machine-to-machine app is free.',
|
||||
},
|
||||
tenant_members: {
|
||||
title: 'Tenant members',
|
||||
description: '{{usage}} <span>(Free for the first 3)</span>',
|
||||
tooltip:
|
||||
'Add-on feature priced at ${{price, number}} per member per month. The first 3 tenant members are free.',
|
||||
},
|
||||
tokens: {
|
||||
title: 'Tokens',
|
||||
description: '{{usage}}',
|
||||
tooltip:
|
||||
'Add-on feature priced at ${{price, number}} per million tokens. The first 1 million tokens is included.',
|
||||
},
|
||||
hooks: {
|
||||
title: 'Hooks',
|
||||
description: '{{usage}} <span>(Free for the first 10)</span>',
|
||||
tooltip:
|
||||
'Add-on feature priced at ${{price, number}} per hook. The first 10 hooks are included.',
|
||||
},
|
||||
};
|
||||
|
||||
export default Object.freeze(usage);
|
Loading…
Reference in a new issue