mirror of
https://github.com/logto-io/logto.git
synced 2025-03-31 22:51:25 -05:00
refactor(console): update plan comparison table (#5115)
This commit is contained in:
parent
72fa53f1f3
commit
a5ef0d014e
17 changed files with 518 additions and 221 deletions
packages/console/src
consts
pages/TenantSettings/Subscription
PlanComparisonTable
PlanQuotaGroupKeyLabel
PlanQuotaKeyLabel
index.module.scssindex.tsxrenderers
utils.tsPlanQuotaTable
index.tsxtypes
|
@ -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);
|
||||
|
|
|
@ -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]: [
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -0,0 +1,8 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.quotaValue {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: _.unit(2);
|
||||
}
|
|
@ -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;
|
|
@ -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)`}
|
||||
/>
|
||||
),
|
||||
};
|
|
@ -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],
|
||||
},
|
||||
};
|
||||
});
|
|
@ -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;
|
|
@ -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}
|
||||
|
|
|
@ -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(),
|
||||
|
|
Loading…
Add table
Reference in a new issue