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:
parent
3034e899b9
commit
168ddc5927
30 changed files with 307 additions and 788 deletions
|
@ -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',
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
|
@ -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">
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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} />
|
||||
),
|
||||
};
|
|
@ -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],
|
||||
},
|
||||
};
|
||||
});
|
|
@ -43,7 +43,7 @@ function Subscription() {
|
|||
subscriptionPlan={currentPlan}
|
||||
subscriptionUsage={subscriptionUsage}
|
||||
/>
|
||||
<PlanComparisonTable subscriptionPlans={reservedPlans} />
|
||||
<PlanComparisonTable />
|
||||
<SwitchPlanActionBar
|
||||
currentSubscriptionPlanId={currentSubscription.planId}
|
||||
subscriptionPlans={reservedPlans}
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -37,6 +37,7 @@ const quota_table = {
|
|||
built_in_email_connector: '組み込みE-mailコネクタ',
|
||||
mfa: '多要素認証',
|
||||
sso: 'エンタープライズ SSO',
|
||||
adaptive_mfa: '適応型MFA',
|
||||
},
|
||||
user_management: {
|
||||
title: 'ユーザー管理',
|
||||
|
|
|
@ -37,6 +37,7 @@ const quota_table = {
|
|||
built_in_email_connector: '내장 이메일 커넥터',
|
||||
mfa: '다중 인증',
|
||||
sso: '기업 SSO',
|
||||
adaptive_mfa: '적응형 MFA',
|
||||
},
|
||||
user_management: {
|
||||
title: '사용자 관리',
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -37,6 +37,7 @@ const quota_table = {
|
|||
built_in_email_connector: 'Встроенное подключение электронной почты',
|
||||
mfa: 'Многофакторная аутентификация',
|
||||
sso: 'Единый вход в корпоративные системы',
|
||||
adaptive_mfa: 'Адаптивный MFA',
|
||||
},
|
||||
user_management: {
|
||||
title: 'Управление пользователями',
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -37,6 +37,7 @@ const quota_table = {
|
|||
built_in_email_connector: '内置电子邮件连接器',
|
||||
mfa: '多因素认证',
|
||||
sso: '企业 SSO',
|
||||
adaptive_mfa: '自适应MFA',
|
||||
},
|
||||
user_management: {
|
||||
title: '用户管理',
|
||||
|
|
|
@ -37,6 +37,7 @@ const quota_table = {
|
|||
built_in_email_connector: '內置電子郵件連接器',
|
||||
mfa: '多因素認證',
|
||||
sso: '企業 SSO',
|
||||
adaptive_mfa: '自適應MFA',
|
||||
},
|
||||
user_management: {
|
||||
title: '用戶管理',
|
||||
|
|
|
@ -37,6 +37,7 @@ const quota_table = {
|
|||
built_in_email_connector: '內建電子郵件連接器',
|
||||
mfa: '多因素認證',
|
||||
sso: '企業 SSO',
|
||||
adaptive_mfa: '自適應MFA',
|
||||
},
|
||||
user_management: {
|
||||
title: '使用者管理',
|
||||
|
|
Loading…
Add table
Reference in a new issue