0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-04-07 23:01:25 -05:00

refactor(console): plan comparison table (#5417)

This commit is contained in:
Xiao Yijun 2024-02-21 11:03:28 +08:00 committed by GitHub
parent 3034e899b9
commit 168ddc5927
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 307 additions and 788 deletions

View file

@ -1,71 +1,13 @@
import { ReservedPlanId } from '@logto/schemas';
import { type Nullable } from '@silverhand/essentials';
import {
type SubscriptionPlanTable,
type SubscriptionPlanTableData,
type SubscriptionPlanTableGroupKeyMap,
SubscriptionPlanTableGroupKey,
ReservedPlanName,
type SubscriptionPlanQuota,
} from '@/types/subscriptions';
type EnabledFeatureMap = Record<string, boolean | undefined>;
export const customCssEnabledMap: EnabledFeatureMap = {
[ReservedPlanId.Free]: true,
[ReservedPlanId.Hobby]: true,
[ReservedPlanId.Pro]: true,
};
export const appLogoAndFaviconEnabledMap: EnabledFeatureMap = {
[ReservedPlanId.Free]: true,
[ReservedPlanId.Hobby]: true,
[ReservedPlanId.Pro]: true,
};
export const darkModeEnabledMap: EnabledFeatureMap = {
[ReservedPlanId.Free]: true,
[ReservedPlanId.Hobby]: true,
[ReservedPlanId.Pro]: true,
};
export const i18nEnabledMap: EnabledFeatureMap = {
[ReservedPlanId.Free]: true,
[ReservedPlanId.Hobby]: true,
[ReservedPlanId.Pro]: true,
};
export const passwordSignInEnabledMap: EnabledFeatureMap = {
[ReservedPlanId.Free]: true,
[ReservedPlanId.Hobby]: true,
[ReservedPlanId.Pro]: true,
};
export const passwordlessSignInEnabledMap: EnabledFeatureMap = {
[ReservedPlanId.Free]: true,
[ReservedPlanId.Hobby]: true,
[ReservedPlanId.Pro]: true,
};
export const emailConnectorsEnabledMap: EnabledFeatureMap = {
[ReservedPlanId.Free]: true,
[ReservedPlanId.Hobby]: true,
[ReservedPlanId.Pro]: true,
};
export const smsConnectorsEnabledMap: EnabledFeatureMap = {
[ReservedPlanId.Free]: true,
[ReservedPlanId.Hobby]: true,
[ReservedPlanId.Pro]: true,
};
export const userManagementEnabledMap: EnabledFeatureMap = {
[ReservedPlanId.Free]: true,
[ReservedPlanId.Hobby]: true,
[ReservedPlanId.Pro]: true,
};
export const communitySupportEnabledMap: EnabledFeatureMap = {
[ReservedPlanId.Free]: true,
[ReservedPlanId.Hobby]: true,
@ -78,100 +20,7 @@ export const ticketSupportResponseTimeMap: Record<string, number | undefined> =
[ReservedPlanId.Pro]: 48,
};
export const allowedUsersPerOrganizationMap: Record<string, Nullable<number> | undefined> = {
[ReservedPlanId.Free]: 0,
[ReservedPlanId.Hobby]: null,
[ReservedPlanId.Pro]: undefined,
};
export const invitationEnabledMap: Record<string, boolean | undefined> = {
[ReservedPlanId.Free]: false,
[ReservedPlanId.Hobby]: true,
[ReservedPlanId.Pro]: true,
};
export const orgRolesLimitMap: Record<string, Nullable<number> | undefined> = {
[ReservedPlanId.Free]: 0,
[ReservedPlanId.Hobby]: null,
[ReservedPlanId.Pro]: undefined,
};
export const orgPermissionsLimitMap: Record<string, Nullable<number> | undefined> = {
[ReservedPlanId.Free]: 0,
[ReservedPlanId.Hobby]: null,
[ReservedPlanId.Pro]: undefined,
};
export const justInTimeProvisioningEnabledMap: Record<string, boolean | undefined> = {
[ReservedPlanId.Free]: false,
[ReservedPlanId.Hobby]: true,
[ReservedPlanId.Pro]: true,
};
export const soc2ReportEnabledMap: Record<string, boolean | undefined> = {
[ReservedPlanId.Free]: false,
[ReservedPlanId.Hobby]: true,
[ReservedPlanId.Pro]: true,
};
export const hipaaOrBaaReportEnabledMap: Record<string, boolean | undefined> = {
[ReservedPlanId.Free]: false,
[ReservedPlanId.Hobby]: false,
[ReservedPlanId.Pro]: false,
};
/**
* Note: this is only for display purpose.
*
* `null` => Unlimited
* `undefined` => Contact
*/
const enterprisePlanTable: SubscriptionPlanTable = {
basePrice: undefined,
mauLimit: undefined,
applicationsLimit: undefined,
machineToMachineLimit: undefined,
resourcesLimit: undefined,
scopesPerResourceLimit: undefined,
customDomainEnabled: true,
customCssEnabled: true,
appLogoAndFaviconEnabled: true,
darkModeEnabled: true,
i18nEnabled: true,
mfaEnabled: undefined,
omniSignInEnabled: true,
passwordSignInEnabled: true,
passwordlessSignInEnabled: true,
emailConnectorsEnabled: true,
smsConnectorsEnabled: true,
socialConnectorsLimit: undefined,
standardConnectorsLimit: undefined,
userManagementEnabled: true,
rolesLimit: undefined,
machineToMachineRolesLimit: undefined,
scopesPerRoleLimit: undefined,
auditLogsRetentionDays: undefined,
hooksLimit: undefined,
communitySupportEnabled: true,
ticketSupportResponseTime: undefined,
organizationsEnabled: undefined,
invitationEnabled: true,
justInTimeProvisioningEnabled: true,
ssoEnabled: undefined,
soc2ReportEnabled: true,
hipaaOrBaaReportEnabled: true,
};
/**
* Note: this is only for display purpose.
*/
export const enterprisePlanTableData: SubscriptionPlanTableData = {
id: 'enterprise', // Dummy
name: ReservedPlanName.Enterprise,
table: enterprisePlanTable,
};
export const planTableGroupKeyMap: SubscriptionPlanTableGroupKeyMap = Object.freeze({
const planTableGroupKeyMap: SubscriptionPlanTableGroupKeyMap = Object.freeze({
[SubscriptionPlanTableGroupKey.base]: ['basePrice', 'mauLimit', 'tokenLimit'],
[SubscriptionPlanTableGroupKey.applications]: [
'applicationsLimit',

View file

@ -1,32 +0,0 @@
import { type TFuncKey } from 'i18next';
import DynamicT from '@/ds-components/DynamicT';
import { SubscriptionPlanTableGroupKey } from '@/types/subscriptions';
const planQuotaGroupKeyPhraseMap: {
[key in SubscriptionPlanTableGroupKey]: TFuncKey<
'translation',
'admin_console.subscription.quota_table'
>;
} = {
[SubscriptionPlanTableGroupKey.base]: 'quota.title',
[SubscriptionPlanTableGroupKey.applications]: 'application.title',
[SubscriptionPlanTableGroupKey.resources]: 'resource.title',
[SubscriptionPlanTableGroupKey.branding]: 'branding.title',
[SubscriptionPlanTableGroupKey.userAuthentication]: 'user_authn.title',
[SubscriptionPlanTableGroupKey.roles]: 'user_management.title',
[SubscriptionPlanTableGroupKey.hooks]: 'hooks.title',
[SubscriptionPlanTableGroupKey.organizations]: 'organizations.title',
[SubscriptionPlanTableGroupKey.auditLogs]: 'audit_logs.title',
[SubscriptionPlanTableGroupKey.support]: 'support.title',
};
type Props = {
groupKey: SubscriptionPlanTableGroupKey;
};
function PlanQuotaGroupKeyLabel({ groupKey }: Props) {
return <DynamicT forKey={`subscription.quota_table.${planQuotaGroupKeyPhraseMap[groupKey]}`} />;
}
export default PlanQuotaGroupKeyLabel;

View file

@ -1,84 +0,0 @@
import { cond } from '@silverhand/essentials';
import { type TFuncKey } from 'i18next';
import DynamicT from '@/ds-components/DynamicT';
import { type SubscriptionPlanTable } from '@/types/subscriptions';
import TableDataWrapper from '../components/TableDataWrapper';
const planQuotaKeyPhraseMap: {
[key in keyof Required<SubscriptionPlanTable>]: TFuncKey<
'translation',
'admin_console.subscription.quota_table'
>;
} = {
basePrice: 'quota.base_price',
mauLimit: 'quota.mau_limit',
tokenLimit: 'quota.included_tokens',
applicationsLimit: 'application.total',
machineToMachineLimit: 'application.m2m',
thirdPartyApplicationsLimit: 'application.third_party',
resourcesLimit: 'resource.resource_count',
scopesPerResourceLimit: 'resource.scopes_per_resource',
customDomainEnabled: 'branding.custom_domain',
customCssEnabled: 'branding.custom_css',
appLogoAndFaviconEnabled: 'branding.app_logo_and_favicon',
darkModeEnabled: 'branding.dark_mode',
i18nEnabled: 'branding.i18n',
omniSignInEnabled: 'user_authn.omni_sign_in',
passwordSignInEnabled: 'user_authn.password',
passwordlessSignInEnabled: 'user_authn.passwordless',
emailConnectorsEnabled: 'user_authn.email_connector',
smsConnectorsEnabled: 'user_authn.sms_connector',
socialConnectorsLimit: 'user_authn.social_connectors',
standardConnectorsLimit: 'user_authn.standard_connectors',
mfaEnabled: 'user_authn.mfa',
ssoEnabled: 'user_authn.sso',
userManagementEnabled: 'user_management.user_management',
rolesLimit: 'user_management.roles',
machineToMachineRolesLimit: 'user_management.machine_to_machine_roles',
scopesPerRoleLimit: 'user_management.scopes_per_role',
hooksLimit: 'hooks.hooks',
auditLogsRetentionDays: 'audit_logs.retention',
communitySupportEnabled: 'support.community',
ticketSupportResponseTime: 'support.email_ticket_support',
organizationsEnabled: 'organizations.monthly_active_organization',
allowedUsersPerOrganization: 'organizations.allowed_users_per_org',
invitationEnabled: 'organizations.invitation',
orgRolesLimit: 'organizations.org_roles',
orgPermissionsLimit: 'organizations.org_permissions',
justInTimeProvisioningEnabled: 'organizations.just_in_time_provisioning',
soc2ReportEnabled: 'support.soc2_report',
hipaaOrBaaReportEnabled: 'support.hipaa_or_baa_report',
};
const planQuotaTipPhraseMap: Partial<
Record<
keyof Required<SubscriptionPlanTable>,
TFuncKey<'translation', 'admin_console.subscription.quota_table'>
>
> = {
mauLimit: 'mau_tip',
tokenLimit: 'tokens_tip',
organizationsEnabled: 'mao_tip',
thirdPartyApplicationsLimit: 'third_party_tip',
};
type Props = {
quotaKey: keyof SubscriptionPlanTable;
};
function PlanQuotaKeyLabel({ quotaKey }: Props) {
const quotaTip = planQuotaTipPhraseMap[quotaKey];
return (
<TableDataWrapper
isLeftAligned
tip={cond(quotaTip && <DynamicT forKey={`subscription.quota_table.${quotaTip}`} />)}
>
<DynamicT forKey={`subscription.quota_table.${planQuotaKeyPhraseMap[quotaKey]}`} />
</TableDataWrapper>
);
}
export default PlanQuotaKeyLabel;

View file

@ -0,0 +1,12 @@
@use '@/scss/underscore' as _;
.check {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
svg + span {
margin-left: _.unit(1);
}
}

View file

@ -0,0 +1,26 @@
import Success from '@/assets/icons/success.svg';
import * as styles from './index.module.scss';
type Props = {
content: string;
};
function TableDataContent({ content }: Props) {
const hasCheckmark = typeof content === 'string' && content.includes('✓');
if (hasCheckmark) {
const [before, after] = content.split('✓');
return (
<div className={styles.check}>
{before && <span>{before}</span>}
<Success />
{after && <span>{after}</span>}
</div>
);
}
return <span>{content}</span>;
}
export default TableDataContent;

View file

@ -1,24 +1,24 @@
import classNames from 'classnames';
import { type ReactNode } from 'react';
import Tip from '@/assets/icons/tip.svg';
import IconButton from '@/ds-components/IconButton';
import { ToggleTip } from '@/ds-components/Tip';
import TableDataContent from './TableDataContent';
import * as styles from './index.module.scss';
type Props = {
children: ReactNode;
tip?: ReactNode;
value: string;
isLeftAligned?: boolean;
extraInfo?: ReactNode;
};
function TableDataWrapper({ children, tip, isLeftAligned, extraInfo }: Props) {
function TableDataWrapper({ value, isLeftAligned = false }: Props) {
const [content = '', tip, extraInfo] = value.split('|');
return (
<div>
<div className={classNames(styles.quotaValue, isLeftAligned && styles.leftAligned)}>
{children}
<TableDataContent content={content} />
{tip && (
<ToggleTip content={tip}>
<IconButton size="small">

View file

@ -1,28 +1,242 @@
import { type AdminConsoleKey } from '@logto/phrases';
import { type TFuncKey } from 'i18next';
import { Fragment, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import PlanName from '@/components/PlanName';
import { enterprisePlanTableData, planTableGroupKeyMap } from '@/consts/plan-quotas';
import { type SubscriptionPlanTableGroupKey, type SubscriptionPlan } from '@/types/subscriptions';
import DynamicT from '@/ds-components/DynamicT';
import PlanQuotaGroupKeyLabel from './PlanQuotaGroupKeyLabel';
import PlanQuotaKeyLabel from './PlanQuotaKeyLabel';
import TableDataWrapper from './TableDataWrapper';
import * as styles from './index.module.scss';
import { quotaValueRenderer } from './renderers';
import { constructPlanTableDataArray } from './utils';
type Props = {
subscriptionPlans: SubscriptionPlan[];
const featuredPlanPhraseKey: AdminConsoleKey[] = [
'subscription.free_plan',
'subscription.pro_plan',
'subscription.enterprise',
];
type TableRow = {
/**
* The plan quota item name for the row.
*/
name: string;
/**
* The data for each plan. The order of the data is [free, pro, enterprise].
* Each data is a string that uses `|` to separate the content, the tooltip and extra information.
* For example:
* - ``
* - `✓|This is a tooltip`
* - `✓|This is a tooltip|This is extra information`
* - ``||This is extra information
*
* The `` in the content section will be replaced with a checkmark icon.
*/
data: string[];
};
function PlanComparisonTable({ subscriptionPlans }: Props) {
const planTableDataArray = useMemo(
() => [
...constructPlanTableDataArray(subscriptionPlans),
// Note: enterprise plan table data is not included in the subscription plans, and it's only for display
enterprisePlanTableData,
],
[subscriptionPlans]
);
type Table = {
title: TFuncKey<'translation', 'admin_console.subscription.quota_table'>;
rows: TableRow[];
};
function PlanComparisonTable() {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console.subscription.quota_table' });
const { t: adminConsoleTranslation } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const tables: Table[] = useMemo(() => {
const contact = t('contact');
const unlimited = t('unlimited');
const paidQuotaLimitTip = t('paid_quota_limit_tip');
const paidAddOnFeatureTip = t('paid_add_on_feature_tip');
const addOn = t('add_on');
const comingSoon = adminConsoleTranslation('general.coming_soon');
// Basics
const basePrice = t('quota.base_price');
const proPlanBasePrice = t('monthly_price', { value: 16 });
const mauLimit = t('quota.mau_limit');
const mauLimitTip = t('mau_tip');
const includedTokens = t('quota.included_tokens');
const includedTokensTip = t('tokens_tip');
const proPlanIncludedTokens = t('million', { value: 1 });
const proPlanIncludedTokensTip = t('paid_token_limit_tip');
// Applications
const totalApplications = t('application.total');
const m2mApps = t('application.m2m');
const proPlanM2mAppLimit = t('included', { value: 1 });
const proPlanM2mAppPrice = t('extra_quota_price', { value: 8 });
const thirdPartyApps = t('application.third_party');
const thirdPartyAppsTip = t('third_party_tip');
// API resources
const resourceCount = t('resource.resource_count');
const proPlanResourceLimit = t('included', { value: 3 });
const proPlanResourcePrice = t('extra_quota_price', { value: 4 });
const permissionsPerResource = t('resource.scopes_per_resource');
// UI and branding
const customDomain = t('branding.custom_domain');
const customCss = t('branding.custom_css');
const appLogoAndFavicon = t('branding.app_logo_and_favicon');
const darkMode = t('branding.dark_mode');
const i18n = t('branding.i18n');
// User authentication
const omniSignIn = t('user_authn.omni_sign_in');
const password = t('user_authn.password');
const passwordless = t('user_authn.passwordless');
const emailConnector = t('user_authn.email_connector');
const smsConnector = t('user_authn.sms_connector');
const socialConnectors = t('user_authn.social_connectors');
const ssoPrice = t('per_month_each', { value: 48 });
const sso = t('user_authn.sso');
const mfa = t('user_authn.mfa');
const mfaPrice = t('monthly_price', { value: 48 });
const adaptiveMfa = t('user_authn.adaptive_mfa');
// User management
const userManagement = t('user_management.user_management');
const userRoles = t('user_management.roles');
const m2mRoles = t('user_management.machine_to_machine_roles');
const permissionsPerRole = t('user_management.scopes_per_role');
// Organizations
const mao = t('organizations.monthly_active_organization');
const maoTip = t('mao_tip');
const maoLimit = t('included_mao', { value: 100 });
const maoPrice = t('extra_mao_price', { value: 0.64 });
const allowedUsersPerOrg = t('organizations.allowed_users_per_org');
const invitation = t('organizations.invitation');
const orgRoles = t('organizations.org_roles');
const orgPermissions = t('organizations.org_permissions');
const jitProvisioning = t('organizations.just_in_time_provisioning');
// Audit logs
const auditLogRetention = t('audit_logs.retention');
const freePlanLogRetention = t('days', { count: 3 });
const paidPlanLogRetention = t('days', { count: 14 });
// Webhooks
const webhooks = t('hooks.hooks');
// Compliance and support
const community = t('support.community');
const emailTicketSupport = t('support.email_ticket_support');
const soc2Report = t('support.soc2_report');
const hipaaOrBaaReport = t('support.hipaa_or_baa_report');
return [
{
title: 'quota.title',
rows: [
{ name: basePrice, data: ['0', proPlanBasePrice, contact] },
{ name: `${mauLimit}|${mauLimitTip}`, data: ['50,000', unlimited, contact] },
{
name: `${includedTokens}|${includedTokensTip}`,
data: ['500,000', `${proPlanIncludedTokens}|${proPlanIncludedTokensTip}`, contact],
},
],
},
{
title: 'application.title',
rows: [
{ name: totalApplications, data: ['3', unlimited, contact] },
{
name: m2mApps,
data: [
'1',
`${proPlanM2mAppLimit}|${paidQuotaLimitTip}|${proPlanM2mAppPrice}`,
contact,
],
},
{ name: `${thirdPartyApps}|${thirdPartyAppsTip}`, data: ['-', unlimited, contact] },
],
},
{
title: 'resource.title',
rows: [
{
name: resourceCount,
data: [
'1',
`${proPlanResourceLimit}|${paidQuotaLimitTip}|${proPlanResourcePrice}`,
contact,
],
},
{ name: permissionsPerResource, data: ['1', unlimited, contact] },
],
},
{
title: 'branding.title',
rows: [
{ name: customDomain, data: ['✓', '✓', '✓'] },
{ name: customCss, data: ['✓', '✓', '✓'] },
{ name: appLogoAndFavicon, data: ['✓', '✓', '✓'] },
{ name: darkMode, data: ['✓', '✓', '✓'] },
{ name: i18n, data: ['✓', '✓', '✓'] },
],
},
{
title: 'user_authn.title',
rows: [
{ name: omniSignIn, data: ['✓', '✓', '✓'] },
{ name: password, data: ['✓', '✓', '✓'] },
{ name: passwordless, data: ['✓', '✓', '✓'] },
{ name: emailConnector, data: ['✓', '✓', '✓'] },
{ name: smsConnector, data: ['✓', '✓', '✓'] },
{ name: socialConnectors, data: ['3', unlimited, contact] },
{ name: sso, data: ['-', `${ssoPrice}|${paidAddOnFeatureTip}|${addOn}`, contact] },
{ name: mfa, data: ['-', `${mfaPrice}|${paidAddOnFeatureTip}|${addOn}`, contact] },
{
name: adaptiveMfa,
data: ['-', comingSoon, contact],
},
],
},
{
title: 'user_management.title',
rows: [
{ name: userManagement, data: ['✓', '✓', '✓'] },
{ name: userRoles, data: ['1', unlimited, contact] },
{ name: m2mRoles, data: ['1', unlimited, contact] },
{ name: permissionsPerRole, data: ['1', unlimited, contact] },
],
},
{
title: 'organizations.title',
rows: [
{
name: `${mao}|${maoTip}`,
data: ['-', `${maoLimit}|${paidQuotaLimitTip}|${maoPrice}`, contact],
},
{ name: allowedUsersPerOrg, data: ['-', unlimited, contact] },
{ name: invitation, data: ['-', comingSoon, contact] },
{ name: orgRoles, data: ['-', unlimited, contact] },
{ name: orgPermissions, data: ['-', unlimited, contact] },
{ name: jitProvisioning, data: ['-', comingSoon, contact] },
],
},
{
title: 'audit_logs.title',
rows: [
{ name: auditLogRetention, data: [freePlanLogRetention, paidPlanLogRetention, contact] },
],
},
{
title: 'hooks.title',
rows: [{ name: webhooks, data: ['1', '10', contact] }],
},
{
title: 'support.title',
rows: [
{ name: community, data: ['✓', '✓', '✓'] },
{ name: emailTicketSupport, data: ['-', '✓ (48h)', contact] },
{ name: soc2Report, data: ['-', comingSoon, comingSoon] },
{ name: hipaaOrBaaReport, data: ['-', '-', comingSoon] },
],
},
];
}, [adminConsoleTranslation, t]);
return (
<div className={styles.container}>
@ -30,30 +244,29 @@ function PlanComparisonTable({ subscriptionPlans }: Props) {
<thead>
<tr>
<th />
{planTableDataArray.map(({ name }) => (
<th key={name}>
<PlanName name={name} />
{featuredPlanPhraseKey.map((key) => (
<th key={key}>
<DynamicT forKey={key} />
</th>
))}
</tr>
</thead>
<tbody>
{Object.entries(planTableGroupKeyMap).map(([groupKey, quotaKeys]) => (
<Fragment key={groupKey}>
{tables.map(({ title, rows }) => (
<Fragment key={title}>
<tr>
<td colSpan={planTableDataArray.length + 1} className={styles.groupLabel}>
{/* eslint-disable-next-line no-restricted-syntax */}
<PlanQuotaGroupKeyLabel groupKey={groupKey as SubscriptionPlanTableGroupKey} />
<td colSpan={4} className={styles.groupLabel}>
<DynamicT forKey={`subscription.quota_table.${title}`} />
</td>
</tr>
{quotaKeys.map((quotaKey) => (
<tr key={`${groupKey}-${quotaKey}`}>
{rows.map(({ name, data }) => (
<tr key={`${title}-${name}`}>
<td className={styles.quotaKeyColumn}>
<PlanQuotaKeyLabel quotaKey={quotaKey} />
<TableDataWrapper isLeftAligned value={name} />
</td>
{planTableDataArray.map((tableData) => (
<td key={`${tableData.id}-${quotaKey}`}>
{quotaValueRenderer[quotaKey](tableData)}
{data.map((value) => (
<td key={value}>
<TableDataWrapper value={value} />
</td>
))}
</tr>

View file

@ -1,27 +0,0 @@
import DynamicT from '@/ds-components/DynamicT';
import TableDataWrapper from '../components/TableDataWrapper';
type Props = {
value?: string;
};
function BasePrice({ value }: Props) {
if (value === undefined) {
return <DynamicT forKey="subscription.quota_table.contact" />;
}
/**
* `basePrice` is a string value representing the price in cents, we need to convert the value from cents to dollars.
*/
return (
<TableDataWrapper>
<DynamicT
forKey="subscription.quota_table.monthly_price"
interpolation={{ value: Number(value) / 100 }}
/>
</TableDataWrapper>
);
}
export default BasePrice;

View file

@ -1,56 +0,0 @@
import { cond } from '@silverhand/essentials';
import { type TFuncKey } from 'i18next';
import Success from '@/assets/icons/success.svg';
import DynamicT from '@/ds-components/DynamicT';
import TableDataWrapper from '../components/TableDataWrapper';
type Props = {
/**
* Whether the feature is enabled.
* If `undefined`, it means that the feature quota is not determined yet, will display 'Contact us' and other props will be ignored.
*/
isEnabled?: boolean;
/**
* The payment type of the feature.
*/
isAddOnForPlan?: boolean;
/**
* The tip phrase key to show
*/
tipPhraseKey?: TFuncKey<'translation', 'admin_console.subscription.quota_table'>;
customContent?: React.ReactNode;
};
/**
* Render a feature flag to indicate whether a feature is enabled or not in the plan comparison table.
* - If `isEnabled` is `undefined`, it will display 'Contact us'.
* - If `isEnabled` is `true`, it will display a checkmark.
* - If `isEnabled` is `false`, it will display a dash.
* @example
* ```tsx
* <GenericFeatureFlag isEnabled={true} />
* ```
*/
function GenericFeatureFlag({
isEnabled,
tipPhraseKey,
isAddOnForPlan = false,
customContent,
}: Props) {
if (isEnabled === undefined) {
return <DynamicT forKey="subscription.quota_table.contact" />;
}
return (
<TableDataWrapper
tip={cond(tipPhraseKey && <DynamicT forKey={`subscription.quota_table.${tipPhraseKey}`} />)}
extraInfo={cond(isAddOnForPlan && <DynamicT forKey="subscription.quota_table.add_on" />)}
>
{customContent ?? (isEnabled ? <Success /> : '-')}
</TableDataWrapper>
);
}
export default GenericFeatureFlag;

View file

@ -1,63 +0,0 @@
import { cond, type Nullable } from '@silverhand/essentials';
import { type TFuncKey } from 'i18next';
import { type ReactNode } from 'react';
import Success from '@/assets/icons/success.svg';
import DynamicT from '@/ds-components/DynamicT';
import TableDataWrapper from '../components/TableDataWrapper';
type Props = {
quota?: Nullable<number>;
tipPhraseKey?: TFuncKey<'translation', 'admin_console.subscription.quota_table'>;
tipInterpolation?: Record<string, unknown>;
hasCheckmark?: boolean;
extraInfo?: ReactNode;
formatter?: (quota: number) => string | ReactNode;
};
function GenericQuotaLimit({
quota,
tipPhraseKey,
tipInterpolation,
hasCheckmark,
extraInfo,
formatter,
}: Props) {
if (quota === undefined) {
return <DynamicT forKey="subscription.quota_table.contact" />;
}
const tipContent = cond(
tipPhraseKey && (
<DynamicT
forKey={`subscription.quota_table.${tipPhraseKey}`}
interpolation={tipInterpolation}
/>
)
);
if (quota === null) {
return (
<TableDataWrapper tip={tipContent} extraInfo={extraInfo}>
{hasCheckmark && <Success />}
<DynamicT forKey="subscription.quota_table.unlimited" />
</TableDataWrapper>
);
}
return (
<TableDataWrapper tip={tipContent} extraInfo={extraInfo}>
{quota === 0 ? (
'-'
) : (
<>
{hasCheckmark && <Success />}
{formatter?.(quota) ?? quota.toLocaleString()}
</>
)}
</TableDataWrapper>
);
}
export default GenericQuotaLimit;

View file

@ -1,274 +0,0 @@
import { ReservedPlanId } from '@logto/schemas';
import { cond } from '@silverhand/essentials';
import { t } from 'i18next';
import { type ReactNode } from 'react';
import DynamicT from '@/ds-components/DynamicT';
import { type SubscriptionPlanTable, type SubscriptionPlanTableData } from '@/types/subscriptions';
import BasePrice from './BasePrice';
import GenericFeatureFlag from './GenericFeatureFlag';
import GenericQuotaLimit from './GenericQuotaLimit';
const m2mAppUnitPrice = 8;
const resourceUnitPrice = 3;
const ssoUnitPrice = 48;
const mfaPrice = 48;
const maoDisplayLimit = 100;
const maoUnitPrice = 0.64;
export const quotaValueRenderer: Record<
keyof SubscriptionPlanTable,
(planTableData: SubscriptionPlanTableData) => ReactNode
> = {
// Base
basePrice: ({ table: { basePrice } }) => <BasePrice value={basePrice} />,
mauLimit: ({ table: { mauLimit } }) => <GenericQuotaLimit quota={mauLimit} />,
tokenLimit: ({ id, table: { tokenLimit } }) => (
<GenericQuotaLimit
quota={tokenLimit}
tipPhraseKey={cond(tokenLimit && id !== ReservedPlanId.Free && 'paid_token_limit_tip')}
formatter={(quota) => {
return quota >= 1_000_000 ? (
<DynamicT
forKey="subscription.quota_table.million"
interpolation={{ value: quota / 1_000_000 }}
/>
) : (
quota.toLocaleString()
);
}}
/>
),
// Applications
applicationsLimit: ({ table: { applicationsLimit } }) => (
<GenericQuotaLimit quota={applicationsLimit} />
),
machineToMachineLimit: ({ id, table: { machineToMachineLimit } }) => {
const isPaidPlan = id === ReservedPlanId.Hobby;
return (
<GenericQuotaLimit
quota={machineToMachineLimit}
tipPhraseKey={cond(isPaidPlan && 'paid_quota_limit_tip')}
extraInfo={cond(
isPaidPlan && (
<DynamicT
forKey="subscription.quota_table.extra_quota_price"
interpolation={{ value: m2mAppUnitPrice }}
/>
)
)}
formatter={cond(
isPaidPlan &&
((quota) => (
<DynamicT
forKey="subscription.quota_table.included"
interpolation={{ value: quota }}
/>
))
)}
/>
);
},
thirdPartyApplicationsLimit: ({ id, table: { thirdPartyApplicationsLimit } }) => {
return <GenericQuotaLimit quota={thirdPartyApplicationsLimit} />;
},
// Resources
resourcesLimit: ({ id, table: { resourcesLimit } }) => {
const isPaidPlan = id === ReservedPlanId.Hobby;
return (
<GenericQuotaLimit
quota={resourcesLimit}
tipPhraseKey={cond(isPaidPlan && 'paid_quota_limit_tip')}
extraInfo={cond(
isPaidPlan && (
<DynamicT
forKey="subscription.quota_table.extra_quota_price"
interpolation={{ value: resourceUnitPrice }}
/>
)
)}
formatter={cond(
isPaidPlan &&
((quota) => (
<DynamicT
forKey="subscription.quota_table.included"
interpolation={{ value: quota }}
/>
))
)}
/>
);
},
scopesPerResourceLimit: ({ table: { scopesPerResourceLimit } }) => (
<GenericQuotaLimit quota={scopesPerResourceLimit} />
),
// Branding
customDomainEnabled: ({ table: { customDomainEnabled } }) => (
<GenericFeatureFlag isEnabled={customDomainEnabled} />
),
customCssEnabled: ({ table: { customCssEnabled } }) => (
<GenericFeatureFlag isEnabled={customCssEnabled} />
),
appLogoAndFaviconEnabled: ({ table: { appLogoAndFaviconEnabled } }) => (
<GenericFeatureFlag isEnabled={appLogoAndFaviconEnabled} />
),
darkModeEnabled: ({ table: { darkModeEnabled } }) => (
<GenericFeatureFlag isEnabled={darkModeEnabled} />
),
i18nEnabled: ({ table: { i18nEnabled } }) => <GenericFeatureFlag isEnabled={i18nEnabled} />,
// UserAuthentication
mfaEnabled: ({ id, table: { mfaEnabled } }) => {
const isPaidPlan = id === ReservedPlanId.Hobby;
return (
<GenericFeatureFlag
isEnabled={mfaEnabled}
isAddOnForPlan={isPaidPlan}
tipPhraseKey={cond(isPaidPlan && 'paid_add_on_feature_tip')}
customContent={cond(
isPaidPlan && (
<DynamicT
forKey="subscription.quota_table.per_month"
interpolation={{ value: mfaPrice }}
/>
)
)}
/>
);
},
omniSignInEnabled: ({ table: { omniSignInEnabled } }) => (
<GenericFeatureFlag isEnabled={omniSignInEnabled} />
),
passwordSignInEnabled: ({ table: { passwordSignInEnabled } }) => (
<GenericFeatureFlag isEnabled={passwordSignInEnabled} />
),
passwordlessSignInEnabled: ({ table: { passwordlessSignInEnabled } }) => (
<GenericFeatureFlag isEnabled={passwordlessSignInEnabled} />
),
emailConnectorsEnabled: ({ table: { emailConnectorsEnabled } }) => (
<GenericFeatureFlag isEnabled={emailConnectorsEnabled} />
),
smsConnectorsEnabled: ({ table: { smsConnectorsEnabled } }) => (
<GenericFeatureFlag isEnabled={smsConnectorsEnabled} />
),
socialConnectorsLimit: ({ table: { socialConnectorsLimit } }) => (
<GenericQuotaLimit quota={socialConnectorsLimit} />
),
standardConnectorsLimit: ({ table: { standardConnectorsLimit } }) => (
<GenericQuotaLimit quota={standardConnectorsLimit} />
),
ssoEnabled: ({ id, table: { ssoEnabled } }) => {
const isPaidPlan = id === ReservedPlanId.Hobby;
return (
<GenericFeatureFlag
isEnabled={ssoEnabled}
isAddOnForPlan={isPaidPlan}
tipPhraseKey={cond(isPaidPlan && 'paid_add_on_feature_tip')}
customContent={cond(
isPaidPlan && (
<DynamicT
forKey="subscription.quota_table.per_month_each"
interpolation={{ value: ssoUnitPrice }}
/>
)
)}
/>
);
},
// Roles
userManagementEnabled: ({ table: { userManagementEnabled } }) => (
<GenericFeatureFlag isEnabled={userManagementEnabled} />
),
rolesLimit: ({ table: { rolesLimit } }) => <GenericQuotaLimit quota={rolesLimit} />,
machineToMachineRolesLimit: ({ table: { machineToMachineRolesLimit } }) => (
<GenericQuotaLimit quota={machineToMachineRolesLimit} />
),
scopesPerRoleLimit: ({ table: { scopesPerRoleLimit } }) => (
<GenericQuotaLimit quota={scopesPerRoleLimit} />
),
// Organizations
organizationsEnabled: ({ id, table: { organizationsEnabled } }) => {
const isPaidPlan = id === ReservedPlanId.Hobby;
return (
<GenericQuotaLimit
quota={
organizationsEnabled === undefined
? organizationsEnabled
: organizationsEnabled
? maoDisplayLimit
: 0
}
tipPhraseKey={cond(isPaidPlan && 'paid_quota_limit_tip')}
extraInfo={cond(
isPaidPlan && (
<DynamicT
forKey="subscription.quota_table.extra_mao_price"
interpolation={{ value: maoUnitPrice }}
/>
)
)}
formatter={cond(
isPaidPlan &&
((quota) => (
<DynamicT
forKey="subscription.quota_table.included_mao"
interpolation={{ value: quota }}
/>
))
)}
/>
);
},
allowedUsersPerOrganization: ({ table: { allowedUsersPerOrganization } }) => (
<GenericQuotaLimit quota={allowedUsersPerOrganization} />
),
invitationEnabled: ({ table: { invitationEnabled } }) =>
invitationEnabled ? (
<DynamicT forKey="general.coming_soon" />
) : (
<GenericFeatureFlag isEnabled={invitationEnabled} />
),
orgRolesLimit: ({ table: { orgRolesLimit } }) => <GenericQuotaLimit quota={orgRolesLimit} />,
orgPermissionsLimit: ({ table: { orgPermissionsLimit } }) => (
<GenericQuotaLimit quota={orgPermissionsLimit} />
),
justInTimeProvisioningEnabled: ({ table: { justInTimeProvisioningEnabled } }) =>
justInTimeProvisioningEnabled ? (
<DynamicT forKey="general.coming_soon" />
) : (
<GenericFeatureFlag isEnabled={justInTimeProvisioningEnabled} />
),
// Audit logs
auditLogsRetentionDays: ({ table: { auditLogsRetentionDays } }) => (
<GenericQuotaLimit
quota={auditLogsRetentionDays}
formatter={(quota) => t('admin_console.subscription.quota_table.days', { count: quota })}
/>
),
// Hooks
hooksLimit: ({ table: { hooksLimit } }) => <GenericQuotaLimit quota={hooksLimit} />,
// Support
communitySupportEnabled: ({ table: { communitySupportEnabled } }) => (
<GenericFeatureFlag isEnabled={communitySupportEnabled} />
),
ticketSupportResponseTime: ({ table: { ticketSupportResponseTime } }) => (
<GenericQuotaLimit
hasCheckmark
quota={ticketSupportResponseTime}
formatter={(quota) => `(${quota}h)`}
/>
),
soc2ReportEnabled: ({ table: { soc2ReportEnabled } }) =>
soc2ReportEnabled ? (
<DynamicT forKey="general.coming_soon" />
) : (
<GenericFeatureFlag isEnabled={soc2ReportEnabled} />
),
hipaaOrBaaReportEnabled: ({ table: { hipaaOrBaaReportEnabled } }) =>
hipaaOrBaaReportEnabled ? (
<DynamicT forKey="general.coming_soon" />
) : (
<GenericFeatureFlag isEnabled={hipaaOrBaaReportEnabled} />
),
};

View file

@ -1,56 +0,0 @@
import { conditional } from '@silverhand/essentials';
import {
allowedUsersPerOrganizationMap,
appLogoAndFaviconEnabledMap,
customCssEnabledMap,
darkModeEnabledMap,
emailConnectorsEnabledMap,
hipaaOrBaaReportEnabledMap,
i18nEnabledMap,
invitationEnabledMap,
justInTimeProvisioningEnabledMap,
orgPermissionsLimitMap,
orgRolesLimitMap,
passwordSignInEnabledMap,
passwordlessSignInEnabledMap,
smsConnectorsEnabledMap,
soc2ReportEnabledMap,
userManagementEnabledMap,
} from '@/consts/plan-quotas';
import { type SubscriptionPlanTableData, type SubscriptionPlan } from '@/types/subscriptions';
export const constructPlanTableDataArray = (
plans: SubscriptionPlan[]
): SubscriptionPlanTableData[] =>
plans.map((plan) => {
const { id, name, stripeProducts, quota } = plan;
return {
id,
name,
table: {
...quota,
basePrice:
conditional(
stripeProducts.find((product) => product.type === 'flat')?.price.unitAmountDecimal
) ?? '0',
customCssEnabled: customCssEnabledMap[id],
appLogoAndFaviconEnabled: appLogoAndFaviconEnabledMap[id],
darkModeEnabled: darkModeEnabledMap[id],
i18nEnabled: i18nEnabledMap[id],
passwordSignInEnabled: passwordSignInEnabledMap[id],
passwordlessSignInEnabled: passwordlessSignInEnabledMap[id],
emailConnectorsEnabled: emailConnectorsEnabledMap[id],
smsConnectorsEnabled: smsConnectorsEnabledMap[id],
userManagementEnabled: userManagementEnabledMap[id],
allowedUsersPerOrganization: allowedUsersPerOrganizationMap[id],
invitationEnabled: invitationEnabledMap[id],
orgRolesLimit: orgRolesLimitMap[id],
orgPermissionsLimit: orgPermissionsLimitMap[id],
justInTimeProvisioningEnabled: justInTimeProvisioningEnabledMap[id],
soc2ReportEnabled: soc2ReportEnabledMap[id],
hipaaOrBaaReportEnabled: hipaaOrBaaReportEnabledMap[id],
},
};
});

View file

@ -43,7 +43,7 @@ function Subscription() {
subscriptionPlan={currentPlan}
subscriptionUsage={subscriptionUsage}
/>
<PlanComparisonTable subscriptionPlans={reservedPlans} />
<PlanComparisonTable />
<SwitchPlanActionBar
currentSubscriptionPlanId={currentSubscription.planId}
subscriptionPlans={reservedPlans}

View file

@ -27,7 +27,7 @@ export type SubscriptionPlan = Omit<SubscriptionPlanResponse, 'quota'> & {
quota: SubscriptionPlanQuota;
};
export type SubscriptionPlanTable = Partial<
type SubscriptionPlanTable = Partial<
SubscriptionPlanQuota & {
// Base quota
basePrice: string;
@ -55,10 +55,6 @@ export type SubscriptionPlanTable = Partial<
}
>;
export type SubscriptionPlanTableData = Pick<SubscriptionPlanResponse, 'id' | 'name'> & {
table: SubscriptionPlanTable;
};
export enum SubscriptionPlanTableGroupKey {
base = 'base',
applications = 'applications',

View file

@ -37,6 +37,7 @@ const quota_table = {
built_in_email_connector: 'Integrierter E-Mail-Connector',
mfa: 'Multi-Faktor-Authentifizierung',
sso: 'Unternehmens-SSO',
adaptive_mfa: 'Adaptive MFA',
},
user_management: {
title: 'Benutzerverwaltung',

View file

@ -36,6 +36,7 @@ const quota_table = {
built_in_email_connector: 'Built-in email connector',
mfa: 'Multi-factor authentication',
sso: 'Enterprise SSO',
adaptive_mfa: 'Adaptive MFA',
},
user_management: {
title: 'User management',

View file

@ -37,6 +37,7 @@ const quota_table = {
built_in_email_connector: 'Conector de correo electrónico incorporado',
mfa: 'Autenticación multifactor',
sso: 'SSO empresarial',
adaptive_mfa: 'MFA adaptativo',
},
user_management: {
title: 'Gestión de usuarios',

View file

@ -37,6 +37,7 @@ const quota_table = {
built_in_email_connector: 'Connecteur email intégré',
mfa: 'Authentification multi-facteurs',
sso: 'SSO entreprise',
adaptive_mfa: 'MFA adaptative',
},
user_management: {
title: 'Gestion des utilisateurs',

View file

@ -37,6 +37,7 @@ const quota_table = {
built_in_email_connector: 'Connettore e-mail integrato',
mfa: 'Autenticazione a più fattori',
sso: 'SSO aziendale',
adaptive_mfa: 'MFA adattativa',
},
user_management: {
title: 'Gestione utenti',

View file

@ -37,6 +37,7 @@ const quota_table = {
built_in_email_connector: '組み込みE-mailコネクタ',
mfa: '多要素認証',
sso: 'エンタープライズ SSO',
adaptive_mfa: '適応型MFA',
},
user_management: {
title: 'ユーザー管理',

View file

@ -37,6 +37,7 @@ const quota_table = {
built_in_email_connector: '내장 이메일 커넥터',
mfa: '다중 인증',
sso: '기업 SSO',
adaptive_mfa: '적응형 MFA',
},
user_management: {
title: '사용자 관리',

View file

@ -37,6 +37,7 @@ const quota_table = {
built_in_email_connector: 'Wbudowane podłączenie e-mail',
mfa: 'Wielopoziomowa autentykacja',
sso: 'SSO przedsiębiorstwowe',
adaptive_mfa: 'MFA adaptacyjne',
},
user_management: {
title: 'Zarządzanie użytkownikami',

View file

@ -37,6 +37,7 @@ const quota_table = {
built_in_email_connector: 'Conector de e-mail integrado',
mfa: 'Autenticação multifator',
sso: 'SSO Empresarial',
adaptive_mfa: 'MFA adaptativo',
},
user_management: {
title: 'Gerenciamento de usuários',

View file

@ -37,6 +37,7 @@ const quota_table = {
built_in_email_connector: 'Conector de e-mail incorporado',
mfa: 'Autenticação de vários fatores',
sso: 'SSO Empresarial',
adaptive_mfa: 'MFA adaptativo',
},
user_management: {
title: 'Gestão de utilizadores',

View file

@ -37,6 +37,7 @@ const quota_table = {
built_in_email_connector: 'Встроенное подключение электронной почты',
mfa: 'Многофакторная аутентификация',
sso: 'Единый вход в корпоративные системы',
adaptive_mfa: 'Адаптивный MFA',
},
user_management: {
title: 'Управление пользователями',

View file

@ -37,6 +37,7 @@ const quota_table = {
built_in_email_connector: 'Dahili e-posta bağlayıcısı',
mfa: 'Çoklu faktörlü kimlik doğrulama',
sso: 'Kurumsal SSO',
adaptive_mfa: 'Uyarlamalı MFA',
},
user_management: {
title: 'Kullanıcı Yönetimi',

View file

@ -37,6 +37,7 @@ const quota_table = {
built_in_email_connector: '内置电子邮件连接器',
mfa: '多因素认证',
sso: '企业 SSO',
adaptive_mfa: '自适应MFA',
},
user_management: {
title: '用户管理',

View file

@ -37,6 +37,7 @@ const quota_table = {
built_in_email_connector: '內置電子郵件連接器',
mfa: '多因素認證',
sso: '企業 SSO',
adaptive_mfa: '自適應MFA',
},
user_management: {
title: '用戶管理',

View file

@ -37,6 +37,7 @@ const quota_table = {
built_in_email_connector: '內建電子郵件連接器',
mfa: '多因素認證',
sso: '企業 SSO',
adaptive_mfa: '自適應MFA',
},
user_management: {
title: '使用者管理',