0
Fork 0
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:
Darcy Ye 2024-08-08 11:51:55 +08:00 committed by GitHub
parent 1e4aa5ad9a
commit 2f40aaa773
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 306 additions and 2 deletions

View file

@ -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;
}

View file

@ -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;

View file

@ -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);

View file

@ -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} / `}

View 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, ',');
};

View file

@ -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);

View file

@ -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);