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

refactor(console): update plan comparison table ()

This commit is contained in:
Xiao Yijun 2023-12-19 10:10:32 +08:00 committed by GitHub
parent 72fa53f1f3
commit a5ef0d014e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 518 additions and 221 deletions
packages/console/src

View file

@ -4,6 +4,6 @@ import { yes } from '@silverhand/essentials';
export const isProduction = process.env.NODE_ENV === 'production';
export const isCloud = yes(process.env.IS_CLOUD);
export const adminEndpoint = process.env.ADMIN_ENDPOINT;
// eslint-disable-next-line unicorn/prevent-abbreviations, import/no-unused-modules -- we love dev
// eslint-disable-next-line unicorn/prevent-abbreviations -- we love dev
export const isDevFeaturesEnabled =
!isProduction || yes(process.env.DEV_FEATURES_ENABLED) || yes(process.env.INTEGRATION_TEST);

View file

@ -1,4 +1,5 @@
import { ReservedPlanId } from '@logto/schemas';
import { type Nullable, condArray } from '@silverhand/essentials';
import {
type SubscriptionPlanTable,
@ -9,6 +10,8 @@ import {
type SubscriptionPlanQuota,
} from '@/types/subscriptions';
import { isDevFeaturesEnabled as isDevelopmentFeaturesEnabled } from './env';
type EnabledFeatureMap = Record<string, boolean | undefined>;
export const customCssEnabledMap: EnabledFeatureMap = {
@ -77,6 +80,13 @@ export const ticketSupportResponseTimeMap: Record<string, number | undefined> =
[ReservedPlanId.Pro]: 48,
};
// @Todo @xiaoyijun [Pricing] read token limit from backend when it's ready
export const tokenLimitMap: Record<string, Nullable<number>> = {
[ReservedPlanId.Free]: 500_000,
[ReservedPlanId.Hobby]: 1_000_000,
[ReservedPlanId.Pro]: 1_000_000,
};
/**
* Note: this is only for display purpose.
*
@ -96,7 +106,8 @@ const enterprisePlanTable: SubscriptionPlanTable = {
appLogoAndFaviconEnabled: true,
darkModeEnabled: true,
i18nEnabled: true,
mfaEnabled: true,
// Todo @xiaoyijun [Pricing] Remove feature flag
mfaEnabled: isDevelopmentFeaturesEnabled ? undefined : true,
omniSignInEnabled: true,
passwordSignInEnabled: true,
passwordlessSignInEnabled: true,
@ -111,8 +122,10 @@ const enterprisePlanTable: SubscriptionPlanTable = {
hooksLimit: undefined,
communitySupportEnabled: true,
ticketSupportResponseTime: undefined,
organizationsEnabled: true,
ssoEnabled: true,
// Todo @xiaoyijun [Pricing] Remove feature flag
organizationsEnabled: isDevelopmentFeaturesEnabled ? undefined : true,
// Todo @xiaoyijun [Pricing] Remove feature flag
ssoEnabled: isDevelopmentFeaturesEnabled ? undefined : true,
};
/**
@ -125,7 +138,12 @@ export const enterprisePlanTableData: SubscriptionPlanTableData = {
};
export const planTableGroupKeyMap: SubscriptionPlanTableGroupKeyMap = Object.freeze({
[SubscriptionPlanTableGroupKey.base]: ['basePrice', 'mauUnitPrice', 'mauLimit'],
[SubscriptionPlanTableGroupKey.base]: [
'basePrice',
// TODO @xiaoyijun [Pricing] remove feature flag
...condArray(!isDevelopmentFeaturesEnabled && 'mauUnitPrice'),
'mauLimit',
],
[SubscriptionPlanTableGroupKey.applications]: ['applicationsLimit', 'machineToMachineLimit'],
[SubscriptionPlanTableGroupKey.resources]: ['resourcesLimit', 'scopesPerResourceLimit'],
[SubscriptionPlanTableGroupKey.branding]: [

View file

@ -1,17 +1,22 @@
import { cond, type Nullable } from '@silverhand/essentials';
import { type TFuncKey } from 'i18next';
import { isDevFeaturesEnabled } from '@/consts/env';
import DynamicT from '@/ds-components/DynamicT';
import { type SubscriptionPlanTable } from '@/types/subscriptions';
const planQuotaKeyPhraseMap: {
[key in keyof Required<SubscriptionPlanTable>]: TFuncKey<
'translation',
'admin_console.subscription.quota_table'
[key in keyof Required<SubscriptionPlanTable>]: Nullable<
TFuncKey<'translation', 'admin_console.subscription.quota_table'>
>;
} = {
basePrice: 'quota.base_price',
mauUnitPrice: 'quota.mau_unit_price',
mauLimit: 'quota.mau_limit',
/**
* Token limit is required in the plan quota table but we don't display it as a row data.
*/
tokenLimit: null,
applicationsLimit: 'application.total',
machineToMachineLimit: 'application.m2m',
resourcesLimit: 'resource.resource_count',
@ -37,7 +42,12 @@ const planQuotaKeyPhraseMap: {
auditLogsRetentionDays: 'audit_logs.retention',
communitySupportEnabled: 'support.community',
ticketSupportResponseTime: 'support.customer_ticket',
organizationsEnabled: 'organizations.organizations',
/**
* Todo @xiaoyijun [Pricing] Remove feature flag
*/
organizationsEnabled: isDevFeaturesEnabled
? 'organizations.monthly_active_organization'
: 'organizations.organizations',
};
type Props = {
@ -45,7 +55,8 @@ type Props = {
};
function PlanQuotaKeyLabel({ quotaKey }: Props) {
return <DynamicT forKey={`subscription.quota_table.${planQuotaKeyPhraseMap[quotaKey]}`} />;
const phraseKey = planQuotaKeyPhraseMap[quotaKey];
return cond(phraseKey && <DynamicT forKey={`subscription.quota_table.${phraseKey}`} />) ?? <>-</>;
}
export default PlanQuotaKeyLabel;

View file

@ -1,39 +1,50 @@
@use '@/scss/underscore' as _;
.container {
padding: _.unit(3);
border-radius: 16px;
background-color: var(--color-layer-1);
margin-bottom: _.unit(3);
.table {
tbody tr td {
border: none;
text-align: center;
table {
border: none;
border-spacing: 0;
table-layout: fixed;
width: 100%;
&:first-child {
border-radius: 6px 0 0 6px;
}
&:last-child {
border-radius: 0 6px 6px 0;
}
}
.headerTable {
thead {
background-color: var(--color-layer-light);
border-radius: 6px;
padding: 0 _.unit(3);
thead tr th {
tr th {
font: var(--font-title-2);
text-align: center;
padding: _.unit(3);
&:first-child {
border-radius: 6px 0 0 6px;
}
&:last-child {
border-radius: 0 6px 6px 0;
}
}
}
.bodyTableWrapper {
border-radius: unset;
padding: 0;
tbody {
tr td {
font: var(--font-body-2);
text-align: center;
padding: _.unit(3);
&:first-child {
border-radius: 6px 0 0 6px;
}
&:last-child {
border-radius: 0 6px 6px 0;
}
}
.groupLabel {
font: var(--font-title-2);
@ -48,12 +59,6 @@
.colorRow {
background-color: var(--color-layer-light);
}
.ticketSupport {
display: flex;
align-items: center;
justify-content: center;
}
}
}
@ -64,4 +69,3 @@
font-style: italic;
}
}

View file

@ -0,0 +1,83 @@
import { cond } from '@silverhand/essentials';
import { Fragment, useMemo } from 'react';
import PlanName from '@/components/PlanName';
import { isDevFeaturesEnabled } from '@/consts/env';
import { enterprisePlanTableData, planTableGroupKeyMap } from '@/consts/plan-quotas';
import DynamicT from '@/ds-components/DynamicT';
import { type SubscriptionPlanTableGroupKey, type SubscriptionPlan } from '@/types/subscriptions';
import PlanQuotaGroupKeyLabel from './PlanQuotaGroupKeyLabel';
import PlanQuotaKeyLabel from './PlanQuotaKeyLabel';
import * as styles from './index.module.scss';
import { quotaValueRenderer } from './renderers';
import { constructPlanTableDataArray } from './utils';
type Props = {
subscriptionPlans: SubscriptionPlan[];
};
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]
);
return (
<div className={styles.container}>
<table>
<thead>
<tr>
<th />
{planTableDataArray.map(({ name }) => (
<th key={name}>
<PlanName name={name} />
</th>
))}
</tr>
</thead>
<tbody>
{Object.entries(planTableGroupKeyMap).map(([groupKey, quotaKeys]) => (
<Fragment key={groupKey}>
<tr>
<td className={styles.groupLabel}>
{/* eslint-disable-next-line no-restricted-syntax */}
<PlanQuotaGroupKeyLabel groupKey={groupKey as SubscriptionPlanTableGroupKey} />
</td>
</tr>
{quotaKeys.map((quotaKey, index) => (
<tr
key={`${groupKey}-${quotaKey}`}
className={cond(index % 2 === 0 && styles.colorRow)}
>
<td className={styles.quotaKeyColumn}>
<PlanQuotaKeyLabel quotaKey={quotaKey} />
</td>
{planTableDataArray.map((tableData) => (
<td key={`${tableData.id}-${quotaKey}`}>
{quotaValueRenderer[quotaKey](tableData)}
</td>
))}
</tr>
))}
</Fragment>
))}
</tbody>
</table>
{cond(
// Todo @xiaoyijun [Pricing] Remove feature flag
!isDevFeaturesEnabled && (
<div className={styles.footnote}>
<DynamicT forKey="subscription.quota_table.mau_unit_price_footnote" />
</div>
)
)}
</div>
);
}
export default PlanComparisonTable;

View file

@ -0,0 +1,27 @@
import DynamicT from '@/ds-components/DynamicT';
import QuotaValueWrapper from './QuotaValueWrapper';
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 (
<QuotaValueWrapper>
<DynamicT
forKey="subscription.quota_table.monthly_price"
interpolation={{ value: Number(value) / 100 }}
/>
</QuotaValueWrapper>
);
}
export default BasePrice;

View file

@ -0,0 +1,40 @@
import { cond } from '@silverhand/essentials';
import { type TFuncKey } from 'i18next';
import Success from '@/assets/icons/success.svg';
import DynamicT from '@/ds-components/DynamicT';
import QuotaValueWrapper from './QuotaValueWrapper';
type Props = {
isEnabled?: boolean;
isBeta?: boolean;
tipPhraseKey?: TFuncKey<'translation', 'admin_console.subscription.quota_table'>;
paymentType?: 'add-on' | 'usage';
};
function GenericFeatureFlag({ isEnabled, isBeta, tipPhraseKey, paymentType }: Props) {
if (isEnabled === undefined) {
return <DynamicT forKey="subscription.quota_table.contact" />;
}
return (
<QuotaValueWrapper
tip={cond(tipPhraseKey && <DynamicT forKey={`subscription.quota_table.${tipPhraseKey}`} />)}
>
{isEnabled
? cond(!isBeta && <Success />) ?? (
<DynamicT
forKey={
paymentType === 'add-on'
? 'subscription.quota_table.add_on_beta'
: 'subscription.quota_table.beta'
}
/>
)
: '-'}
</QuotaValueWrapper>
);
}
export default GenericFeatureFlag;

View file

@ -0,0 +1,60 @@
import { cond, type Nullable } from '@silverhand/essentials';
import { type TFuncKey } from 'i18next';
import Success from '@/assets/icons/success.svg';
import DynamicT from '@/ds-components/DynamicT';
import QuotaValueWrapper from './QuotaValueWrapper';
type Props = {
quota?: Nullable<number>;
tipPhraseKey?: TFuncKey<'translation', 'admin_console.subscription.quota_table'>;
tipInterpolation?: Record<string, unknown>;
hasCheckmark?: boolean;
formatter?: (quota: number) => string;
};
function GenericQuotaLimit({
quota,
tipPhraseKey,
tipInterpolation,
hasCheckmark,
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 (
<QuotaValueWrapper tip={tipContent}>
{hasCheckmark && <Success />}
<DynamicT forKey="subscription.quota_table.unlimited" />
</QuotaValueWrapper>
);
}
return (
<QuotaValueWrapper tip={tipContent}>
{quota === 0 ? (
'-'
) : (
<>
{hasCheckmark && <Success />}
{formatter?.(quota) ?? quota.toLocaleString()}
</>
)}
</QuotaValueWrapper>
);
}
export default GenericQuotaLimit;

View file

@ -0,0 +1,36 @@
import DynamicT from '@/ds-components/DynamicT';
type Props = {
prices?: string[];
};
/**
* @deprecated
* The unit price is an array of string representing the price in cents.
* Each string represents the price of a tier.
* TODO: @xiaoyijun [Pricing] Remove the unit price after the new pricing feature is ready.
*/
function MauUnitPrices({ prices }: Props) {
console.log(prices);
if (prices === undefined) {
return <DynamicT forKey="subscription.quota_table.contact" />;
}
return prices.length === 0 ? (
<div>-</div>
) : (
<div>
{prices.map((value, index) => (
<div key={value}>
<DynamicT forKey="subscription.quota_table.tier" interpolation={{ value: index + 1 }} />
<DynamicT
forKey="subscription.quota_table.mau_price"
interpolation={{ value: Number(value) / 100 }}
/>
</div>
))}
</div>
);
}
export default MauUnitPrices;

View file

@ -0,0 +1,8 @@
@use '@/scss/underscore' as _;
.quotaValue {
display: flex;
align-items: center;
justify-content: center;
gap: _.unit(2);
}

View file

@ -0,0 +1,29 @@
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 * as styles from './index.module.scss';
type Props = {
children: ReactNode;
tip?: ReactNode;
};
function QuotaValueWrapper({ children, tip }: Props) {
return (
<div className={styles.quotaValue}>
{children}
{tip && (
<ToggleTip content={tip}>
<IconButton size="small">
<Tip />
</IconButton>
</ToggleTip>
)}
</div>
);
}
export default QuotaValueWrapper;

View file

@ -0,0 +1,155 @@
import { ReservedPlanId } from '@logto/schemas';
import { cond } from '@silverhand/essentials';
import { t } from 'i18next';
import { type ReactNode } from 'react';
import { isDevFeaturesEnabled } from '@/consts/env';
import { type SubscriptionPlanTable, type SubscriptionPlanTableData } from '@/types/subscriptions';
import BasePrice from './BasePrice';
import GenericFeatureFlag from './GenericFeatureFlag';
import GenericQuotaLimit from './GenericQuotaLimit';
import MauUnitPrices from './MauUnitPrices';
export const quotaValueRenderer: Record<
keyof SubscriptionPlanTable,
(planTableData: SubscriptionPlanTableData) => ReactNode
> = {
// Base
basePrice: ({ table: { basePrice } }) => <BasePrice value={basePrice} />,
mauUnitPrice: ({ table: { mauUnitPrice } }) => <MauUnitPrices prices={mauUnitPrice} />,
tokenLimit: () => <div />, // Dummy: We don't display token limit as an item in the plan comparison table.
mauLimit: ({ id, table: { tokenLimit, mauLimit } }) => (
<GenericQuotaLimit
quota={mauLimit}
tipPhraseKey={cond(
// Todo @xiaoyijun [Pricing] Remove feature flag
isDevFeaturesEnabled &&
tokenLimit &&
(id === ReservedPlanId.Free ? 'free_token_limit_tip' : 'paid_token_limit_tip')
)}
tipInterpolation={cond(typeof tokenLimit === 'number' && { value: tokenLimit / 1_000_000 })}
/>
),
// Applications
applicationsLimit: ({ table: { applicationsLimit } }) => (
<GenericQuotaLimit quota={applicationsLimit} />
),
machineToMachineLimit: ({ id, table: { machineToMachineLimit } }) => (
<GenericQuotaLimit
quota={machineToMachineLimit}
tipPhraseKey={cond(
// Todo @xiaoyijun [Pricing] Remove feature flag
isDevFeaturesEnabled && id !== ReservedPlanId.Free && 'paid_quota_limit_tip'
)}
/>
),
// Resources
resourcesLimit: ({ id, table: { resourcesLimit } }) => (
<GenericQuotaLimit
quota={resourcesLimit}
tipPhraseKey={cond(
// Todo @xiaoyijun [Pricing] Remove feature flag
isDevFeaturesEnabled && id !== ReservedPlanId.Free && 'paid_quota_limit_tip'
)}
/>
),
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: ({ table: { mfaEnabled } }) => (
<GenericFeatureFlag
isEnabled={mfaEnabled}
// Todo @xiaoyijun [Pricing] Remove feature flag
isBeta={isDevFeaturesEnabled}
// Todo @xiaoyijun [Pricing] Remove feature flag
tipPhraseKey={cond(isDevFeaturesEnabled && mfaEnabled && 'beta_feature_tip')}
paymentType="add-on"
/>
),
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: ({ table: { ssoEnabled } }) => (
<GenericFeatureFlag
isEnabled={ssoEnabled}
// Todo @xiaoyijun [Pricing] Remove feature flag
isBeta={isDevFeaturesEnabled}
// Todo @xiaoyijun [Pricing] Remove feature flag
tipPhraseKey={cond(isDevFeaturesEnabled && ssoEnabled && 'beta_feature_tip')}
paymentType="add-on"
/>
),
// Roles
userManagementEnabled: ({ table: { userManagementEnabled } }) => (
<GenericFeatureFlag isEnabled={userManagementEnabled} />
),
rolesLimit: ({ table: { rolesLimit } }) => <GenericQuotaLimit quota={rolesLimit} />,
scopesPerRoleLimit: ({ table: { scopesPerRoleLimit } }) => (
<GenericQuotaLimit quota={scopesPerRoleLimit} />
),
// Organizations
organizationsEnabled: ({ table: { organizationsEnabled } }) => (
<GenericFeatureFlag
isEnabled={organizationsEnabled}
// Todo @xiaoyijun [Pricing] Remove feature flag
isBeta={isDevFeaturesEnabled}
// Todo @xiaoyijun [Pricing] Remove feature flag & update usage-based feature tip
tipPhraseKey={cond(isDevFeaturesEnabled && organizationsEnabled && 'beta_feature_tip')}
paymentType="usage"
/>
),
// 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)`}
/>
),
};

View file

@ -9,6 +9,7 @@ import {
passwordSignInEnabledMap,
passwordlessSignInEnabledMap,
smsConnectorsEnabledMap,
tokenLimitMap,
userManagementEnabledMap,
} from '@/consts/plan-quotas';
import { type SubscriptionPlanTableData, type SubscriptionPlan } from '@/types/subscriptions';
@ -46,6 +47,7 @@ export const constructPlanTableDataArray = (
emailConnectorsEnabled: emailConnectorsEnabledMap[id],
smsConnectorsEnabled: smsConnectorsEnabledMap[id],
userManagementEnabled: userManagementEnabledMap[id],
tokenLimit: tokenLimitMap[id],
},
};
});

View file

@ -1,176 +0,0 @@
import { conditional } from '@silverhand/essentials';
import { useMemo } from 'react';
import Success from '@/assets/icons/success.svg';
import PlanName from '@/components/PlanName';
import { enterprisePlanTableData, planTableGroupKeyMap } from '@/consts/plan-quotas';
import DynamicT from '@/ds-components/DynamicT';
import Table from '@/ds-components/Table';
import { type RowGroup, type Column } from '@/ds-components/Table/types';
import {
type SubscriptionPlanTableRow,
type SubscriptionPlanTableGroupKey,
type SubscriptionPlan,
} from '@/types/subscriptions';
import PlanQuotaGroupKeyLabel from './PlanQuotaGroupKeyLabel';
import PlanQuotaKeyLabel from './PlanQuotaKeyLabel';
import * as styles from './index.module.scss';
import { constructPlanTableDataArray } from './utils';
type Props = {
subscriptionPlans: SubscriptionPlan[];
};
function PlanQuotaTable({ subscriptionPlans }: Props) {
const quotaTableRowGroups: Array<RowGroup<SubscriptionPlanTableRow>> = useMemo(() => {
const tableDataArray = [
...constructPlanTableDataArray(subscriptionPlans),
// Note: enterprise plan table data is not included in the subscription plans, and it's only for display
enterprisePlanTableData,
];
return Object.entries(planTableGroupKeyMap).map(([groupKey, quotaKeys]) => {
return {
key: groupKey,
// eslint-disable-next-line no-restricted-syntax
label: <PlanQuotaGroupKeyLabel groupKey={groupKey as SubscriptionPlanTableGroupKey} />,
labelClassName: styles.groupLabel,
data: quotaKeys.map((key) => {
const tableData = Object.fromEntries(
tableDataArray.map(({ name, table }) => {
return [name, table[key]];
})
);
return {
quotaKey: key,
...tableData,
};
}),
};
});
}, [subscriptionPlans]);
const quotaTableRowData = quotaTableRowGroups[0]?.data?.at(0);
if (quotaTableRowData === undefined) {
return null;
}
const planQuotaTableColumns = Object.keys(quotaTableRowData) satisfies Array<
keyof SubscriptionPlanTableRow
>;
return (
<div className={styles.container}>
<Table
isRowHoverEffectDisabled
rowGroups={quotaTableRowGroups}
rowIndexKey="quotaKey"
rowClassName={(_, index) => conditional(index % 2 === 0 && styles.colorRow)}
className={styles.table}
headerTableClassName={styles.headerTable}
bodyTableWrapperClassName={styles.bodyTableWrapper}
columns={
planQuotaTableColumns.map((column) => {
const columnTitle = conditional(column !== 'quotaKey' && column);
return {
title: conditional(columnTitle && <PlanName name={columnTitle} />),
dataIndex: column,
className: conditional(column === 'quotaKey' && styles.quotaKeyColumn),
render: (row) => {
const { quotaKey } = row;
if (column === 'quotaKey') {
return <PlanQuotaKeyLabel quotaKey={quotaKey} />;
}
const quotaValue = row[column];
if (quotaValue === undefined) {
return <DynamicT forKey="subscription.quota_table.contact" />;
}
if (quotaValue === null) {
return <DynamicT forKey="subscription.quota_table.unlimited" />;
}
// For base price
if (typeof quotaValue === 'string') {
if (quotaKey === 'basePrice') {
return (
<DynamicT
forKey="subscription.quota_table.monthly_price"
interpolation={{ value: Number(quotaValue) / 100 }}
/>
);
}
return quotaValue;
}
// For mau unit price
if (Array.isArray(quotaValue)) {
return quotaValue.length === 0 ? (
'-'
) : (
<div>
{quotaValue.map((value, index) => (
<div key={value}>
<DynamicT
forKey="subscription.quota_table.tier"
interpolation={{ value: index + 1 }}
/>
<DynamicT
forKey="subscription.quota_table.mau_price"
interpolation={{ value: (Number(value) / 100).toFixed(3) }}
/>
</div>
))}
</div>
);
}
if (typeof quotaValue === 'boolean') {
return quotaValue ? <Success /> : '-';
}
// Note: handle number type
if (quotaValue === 0) {
return '-';
}
if (quotaKey === 'auditLogsRetentionDays') {
return (
<DynamicT
forKey="subscription.quota_table.days"
interpolation={{ count: quotaValue }}
/>
);
}
if (quotaKey === 'ticketSupportResponseTime') {
return (
<div className={styles.ticketSupport}>
<Success />
<span>{`(${quotaValue}h)`}</span>
</div>
);
}
return quotaValue.toLocaleString();
},
};
}) satisfies Array<Column<SubscriptionPlanTableRow>>
}
/>
<div className={styles.footnote}>
<DynamicT forKey="subscription.quota_table.mau_unit_price_footnote" />
</div>
</div>
);
}
export default PlanQuotaTable;

View file

@ -12,7 +12,7 @@ import { pickupFeaturedPlans } from '@/utils/subscription';
import Skeleton from '../components/Skeleton';
import CurrentPlan from './CurrentPlan';
import PlanQuotaTable from './PlanQuotaTable';
import PlanComparisonTable from './PlanComparisonTable';
import SwitchPlanActionBar from './SwitchPlanActionBar';
import * as styles from './index.module.scss';
@ -59,7 +59,7 @@ function Subscription() {
subscriptionPlan={currentSubscriptionPlan}
subscriptionUsage={subscriptionUsage}
/>
<PlanQuotaTable subscriptionPlans={reservedPlans} />
<PlanComparisonTable subscriptionPlans={reservedPlans} />
<SwitchPlanActionBar
currentSubscriptionPlanId={currentSubscription.planId}
subscriptionPlans={reservedPlans}

View file

@ -1,3 +1,4 @@
import { type Nullable } from '@silverhand/essentials';
import { z } from 'zod';
import { type InvoicesResponse, type SubscriptionPlanResponse } from '@/cloud/types/router';
@ -30,6 +31,11 @@ export type SubscriptionPlanTable = Partial<
SubscriptionPlanQuota & {
// Base quota
basePrice: string;
/**
* Token limit
* Todo @xiaoyijun [Pricing] retrieve the token limit from the backend
*/
tokenLimit: Nullable<number>;
mauUnitPrice: string[];
// UI and branding
customCssEnabled: boolean;
@ -67,12 +73,6 @@ export type SubscriptionPlanTableGroupKeyMap = {
[key in SubscriptionPlanTableGroupKey]: Array<keyof Required<SubscriptionPlanTable>>;
};
type SubscriptionPlanTableValue = SubscriptionPlanTable[keyof SubscriptionPlanTable];
export type SubscriptionPlanTableRow = Record<string, SubscriptionPlanTableValue> & {
quotaKey: keyof SubscriptionPlanTable;
};
export const localCheckoutSessionGuard = z.object({
state: z.string(),
sessionId: z.string(),