mirror of
https://github.com/logto-io/logto.git
synced 2024-12-30 20:33:54 -05:00
feat(console): add plan quota table (#4137)
This commit is contained in:
parent
16925b3df7
commit
7742c7e3d4
16 changed files with 590 additions and 8 deletions
|
@ -26,7 +26,7 @@
|
|||
"@fontsource/roboto-mono": "^5.0.0",
|
||||
"@jest/types": "^29.5.0",
|
||||
"@logto/app-insights": "workspace:^1.3.1",
|
||||
"@logto/cloud": "0.2.5-1a68662",
|
||||
"@logto/cloud": "0.2.5-31703ea",
|
||||
"@logto/connector-kit": "workspace:^1.1.1",
|
||||
"@logto/core-kit": "workspace:^2.0.1",
|
||||
"@logto/language-kit": "workspace:^1.0.0",
|
||||
|
|
27
packages/console/src/cloud/hooks/use-cloud-swr.ts
Normal file
27
packages/console/src/cloud/hooks/use-cloud-swr.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
import useSWR from 'swr';
|
||||
|
||||
import { type GetRoutes } from '../types/router';
|
||||
|
||||
import { useCloudApi } from './use-cloud-api';
|
||||
|
||||
const normalizeError = (error: unknown) => {
|
||||
if (error === undefined || error === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
return error instanceof Error ? error : new Error(String(error));
|
||||
};
|
||||
|
||||
/**
|
||||
* Note: Exclude `/api/services/mails/usage` because it requires a payload.
|
||||
* Todo: @xiaoyijun Support non-empty payload routes requests for `useCloudSwr` hook (LOG-6513)
|
||||
*/
|
||||
type EmptyPayloadGetRoutesKey = Exclude<keyof GetRoutes, '/api/services/mails/usage'>;
|
||||
|
||||
export const useCloudSwr = <Key extends EmptyPayloadGetRoutesKey>(key: Key) => {
|
||||
const cloudApi = useCloudApi();
|
||||
const response = useSWR(key, async () => cloudApi.get(key));
|
||||
|
||||
// By default, `useSWR()` uses `any` for the error type which is unexpected under our lint rule set.
|
||||
return { ...response, error: normalizeError(response.error) };
|
||||
};
|
12
packages/console/src/cloud/types/router.ts
Normal file
12
packages/console/src/cloud/types/router.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
import type router from '@logto/cloud/routes';
|
||||
import { type RouterRoutes } from '@withtyped/client';
|
||||
import { type z, type ZodType } from 'zod';
|
||||
|
||||
export type GetRoutes = RouterRoutes<typeof router>['get'];
|
||||
|
||||
type RouteResponseType<T extends { search?: unknown; body?: unknown; response?: ZodType }> =
|
||||
z.infer<NonNullable<T['response']>>;
|
||||
|
||||
export type SubscriptionPlanResponse = RouteResponseType<
|
||||
GetRoutes['/api/subscription-plans']
|
||||
>[number];
|
75
packages/console/src/consts/subscriptions.ts
Normal file
75
packages/console/src/consts/subscriptions.ts
Normal file
|
@ -0,0 +1,75 @@
|
|||
import {
|
||||
type SubscriptionPlanTableData,
|
||||
ReservedPlanName,
|
||||
type SubscriptionPlanTable,
|
||||
type SubscriptionPlanTableGroupKeyMap,
|
||||
SubscriptionPlanTableGroupKey,
|
||||
} from '@/types/subscriptions';
|
||||
|
||||
export const communitySupportEnabledMap: Record<string, boolean | undefined> = {
|
||||
[ReservedPlanName.Free]: true,
|
||||
[ReservedPlanName.Hobby]: true,
|
||||
[ReservedPlanName.Pro]: true,
|
||||
[ReservedPlanName.Enterprise]: true,
|
||||
};
|
||||
|
||||
export const ticketSupportResponseTimeMap: Record<string, number | undefined> = {
|
||||
[ReservedPlanName.Free]: 0,
|
||||
[ReservedPlanName.Hobby]: 72,
|
||||
[ReservedPlanName.Pro]: 48,
|
||||
[ReservedPlanName.Enterprise]: undefined,
|
||||
};
|
||||
|
||||
/**
|
||||
* Note: this is only for display purpose.
|
||||
*
|
||||
* `null` => Unlimited
|
||||
* `undefined` => Contact
|
||||
*/
|
||||
const enterprisePlanTable: SubscriptionPlanTable = {
|
||||
tenantLimit: null,
|
||||
basePrice: undefined,
|
||||
mauUnitPrice: undefined,
|
||||
mauLimit: undefined,
|
||||
applicationsLimit: undefined,
|
||||
machineToMachineLimit: undefined,
|
||||
resourcesLimit: undefined,
|
||||
scopesPerResourceLimit: undefined,
|
||||
customDomainEnabled: true,
|
||||
omniSignInEnabled: true,
|
||||
builtInEmailConnectorEnabled: true,
|
||||
socialConnectorsLimit: null,
|
||||
standardConnectorsLimit: undefined,
|
||||
rolesLimit: undefined,
|
||||
scopesPerRoleLimit: null,
|
||||
auditLogsRetentionDays: undefined,
|
||||
hooksLimit: undefined,
|
||||
communitySupportEnabled: true,
|
||||
ticketSupportResponseTime: ticketSupportResponseTimeMap[ReservedPlanName.Enterprise],
|
||||
};
|
||||
|
||||
/**
|
||||
* 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({
|
||||
[SubscriptionPlanTableGroupKey.base]: ['tenantLimit', 'basePrice', 'mauUnitPrice', 'mauLimit'],
|
||||
[SubscriptionPlanTableGroupKey.applications]: ['applicationsLimit', 'machineToMachineLimit'],
|
||||
[SubscriptionPlanTableGroupKey.resources]: ['resourcesLimit', 'scopesPerResourceLimit'],
|
||||
[SubscriptionPlanTableGroupKey.branding]: ['customDomainEnabled'],
|
||||
[SubscriptionPlanTableGroupKey.userAuthentication]: [
|
||||
'omniSignInEnabled',
|
||||
'builtInEmailConnectorEnabled',
|
||||
'socialConnectorsLimit',
|
||||
'standardConnectorsLimit',
|
||||
],
|
||||
[SubscriptionPlanTableGroupKey.roles]: ['rolesLimit', 'scopesPerRoleLimit'],
|
||||
[SubscriptionPlanTableGroupKey.auditLogs]: ['auditLogsRetentionDays'],
|
||||
[SubscriptionPlanTableGroupKey.hooks]: ['hooksLimit'],
|
||||
[SubscriptionPlanTableGroupKey.support]: ['communitySupportEnabled', 'ticketSupportResponseTime'],
|
||||
}) satisfies SubscriptionPlanTableGroupKeyMap;
|
|
@ -1,8 +1,8 @@
|
|||
import { type AdminConsoleKey } from '@logto/phrases';
|
||||
import { type TFuncKey } from 'i18next';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
type Props = {
|
||||
forKey: AdminConsoleKey;
|
||||
forKey: TFuncKey<'translation', 'admin_console'>;
|
||||
interpolation?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
|
|
|
@ -26,6 +26,7 @@ export type Props<
|
|||
isRowHoverEffectDisabled?: boolean;
|
||||
isRowClickable?: (row: TFieldValues) => boolean;
|
||||
rowClickHandler?: (row: TFieldValues) => void;
|
||||
rowClassName?: (row: TFieldValues, index: number) => string | undefined;
|
||||
className?: string;
|
||||
headerTableClassName?: string;
|
||||
bodyTableWrapperClassName?: string;
|
||||
|
@ -48,6 +49,7 @@ function Table<
|
|||
isRowHoverEffectDisabled = false,
|
||||
rowClickHandler,
|
||||
isRowClickable = () => Boolean(rowClickHandler),
|
||||
rowClassName,
|
||||
className,
|
||||
headerTableClassName,
|
||||
bodyTableWrapperClassName,
|
||||
|
@ -120,7 +122,7 @@ function Table<
|
|||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{data?.map((row) => {
|
||||
{data?.map((row, rowIndex) => {
|
||||
const rowClickable = isRowClickable(row);
|
||||
|
||||
const onClick = conditional(
|
||||
|
@ -136,7 +138,8 @@ function Table<
|
|||
key={row[rowIndexKey]}
|
||||
className={classNames(
|
||||
rowClickable && styles.clickable,
|
||||
!isRowHoverEffectDisabled && styles.hoverEffect
|
||||
!isRowHoverEffectDisabled && styles.hoverEffect,
|
||||
rowClassName?.(row, rowIndex)
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
|
|
34
packages/console/src/hooks/use-subscription-plans.ts
Normal file
34
packages/console/src/hooks/use-subscription-plans.ts
Normal file
|
@ -0,0 +1,34 @@
|
|||
import { useMemo } from 'react';
|
||||
|
||||
import { communitySupportEnabledMap, ticketSupportResponseTimeMap } from '@/consts/subscriptions';
|
||||
import { type SubscriptionPlan } from '@/types/subscriptions';
|
||||
|
||||
import { useCloudSwr } from '../cloud/hooks/use-cloud-swr';
|
||||
|
||||
const useSubscriptionPlans = () => {
|
||||
const { data: subscriptionPlansResponse, error } = useCloudSwr('/api/subscription-plans');
|
||||
|
||||
const subscriptionPlans: SubscriptionPlan[] = useMemo(
|
||||
() =>
|
||||
subscriptionPlansResponse?.map((plan) => {
|
||||
const { name, quota } = plan;
|
||||
|
||||
return {
|
||||
...plan,
|
||||
quota: {
|
||||
...quota,
|
||||
communitySupportEnabled: communitySupportEnabledMap[name] ?? false, // Fallback to not supported
|
||||
ticketSupportResponseTime: ticketSupportResponseTimeMap[name] ?? 0, // Fallback to not supported
|
||||
},
|
||||
};
|
||||
}) ?? [],
|
||||
[subscriptionPlansResponse]
|
||||
);
|
||||
|
||||
return {
|
||||
isLoading: !subscriptionPlansResponse && !error,
|
||||
data: subscriptionPlans,
|
||||
};
|
||||
};
|
||||
|
||||
export default useSubscriptionPlans;
|
|
@ -0,0 +1,34 @@
|
|||
import { type TFuncKey } from 'i18next';
|
||||
|
||||
import DynamicT from '@/ds-components/DynamicT';
|
||||
import { ReservedPlanName } from '@/types/subscriptions';
|
||||
|
||||
const registeredPlanNamePhraseMap: Record<
|
||||
string,
|
||||
TFuncKey<'translation', 'admin_console.subscription'> | undefined
|
||||
> = {
|
||||
quotaKey: undefined,
|
||||
[ReservedPlanName.Free]: 'free_plan',
|
||||
[ReservedPlanName.Hobby]: 'hobby_plan',
|
||||
[ReservedPlanName.Pro]: 'pro_plan',
|
||||
[ReservedPlanName.Enterprise]: 'enterprise',
|
||||
};
|
||||
|
||||
type Props = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
function PlanName({ name }: Props) {
|
||||
const planNamePhrase = registeredPlanNamePhraseMap[name];
|
||||
|
||||
/**
|
||||
* Note: fallback to the plan name if the phrase is not registered.
|
||||
*/
|
||||
if (!planNamePhrase) {
|
||||
return <span>{name}</span>;
|
||||
}
|
||||
|
||||
return <DynamicT forKey={`subscription.${planNamePhrase}`} />;
|
||||
}
|
||||
|
||||
export default PlanName;
|
|
@ -0,0 +1,31 @@
|
|||
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]: 'roles.title',
|
||||
[SubscriptionPlanTableGroupKey.hooks]: 'hooks.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;
|
|
@ -0,0 +1,41 @@
|
|||
import { type TFuncKey } from 'i18next';
|
||||
|
||||
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'
|
||||
>;
|
||||
} = {
|
||||
tenantLimit: 'quota.tenant_limit',
|
||||
mauLimit: 'quota.mau_limit',
|
||||
applicationsLimit: 'application.total',
|
||||
machineToMachineLimit: 'application.m2m',
|
||||
resourcesLimit: 'resource.resource_count',
|
||||
scopesPerResourceLimit: 'resource.scopes_per_resource',
|
||||
customDomainEnabled: 'branding.custom_domain',
|
||||
omniSignInEnabled: 'user_authn.omni_sign_in',
|
||||
builtInEmailConnectorEnabled: 'user_authn.built_in_email_connector',
|
||||
socialConnectorsLimit: 'user_authn.social_connectors',
|
||||
standardConnectorsLimit: 'user_authn.standard_connectors',
|
||||
rolesLimit: 'roles.roles',
|
||||
scopesPerRoleLimit: 'roles.scopes_per_role',
|
||||
hooksLimit: 'hooks.amount',
|
||||
auditLogsRetentionDays: 'audit_logs.retention',
|
||||
basePrice: 'quota.base_price',
|
||||
mauUnitPrice: 'quota.mau_unit_price',
|
||||
communitySupportEnabled: 'support.community',
|
||||
ticketSupportResponseTime: 'support.customer_ticket',
|
||||
};
|
||||
|
||||
type Props = {
|
||||
quotaKey: keyof SubscriptionPlanTable;
|
||||
};
|
||||
|
||||
function PlanQuotaKeyLabel({ quotaKey }: Props) {
|
||||
return <DynamicT forKey={`subscription.quota_table.${planQuotaKeyPhraseMap[quotaKey]}`} />;
|
||||
}
|
||||
|
||||
export default PlanQuotaKeyLabel;
|
|
@ -0,0 +1,67 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
|
||||
.container {
|
||||
padding: _.unit(3);
|
||||
border-radius: 12px;
|
||||
background-color: var(--color-layer-1);
|
||||
margin-bottom: _.unit(4);
|
||||
|
||||
.table {
|
||||
tbody tr td {
|
||||
border: none;
|
||||
text-align: center;
|
||||
|
||||
&:first-child {
|
||||
border-radius: 6px 0 0 6px;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-radius: 0 6px 6px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.headerTable {
|
||||
background-color: var(--color-layer-light);
|
||||
border-radius: 6px;
|
||||
|
||||
thead tr th {
|
||||
font: var(--font-title-2);
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.bodyTableWrapper {
|
||||
border-radius: unset;
|
||||
padding: 0;
|
||||
|
||||
.groupLabel {
|
||||
font: var(--font-title-2);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.quotaKeyColumn {
|
||||
padding: _.unit(4) _.unit(6);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.colorRow {
|
||||
background-color: var(--color-layer-light);
|
||||
}
|
||||
|
||||
.ticketSupport {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.footnote {
|
||||
margin-top: _.unit(8);
|
||||
text-align: right;
|
||||
font: var(--font-body-3);
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,173 @@
|
|||
import { conditional } from '@silverhand/essentials';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import Success from '@/assets/icons/success.svg';
|
||||
import { enterprisePlanTableData, planTableGroupKeyMap } from '@/consts/subscriptions';
|
||||
import DynamicT from '@/ds-components/DynamicT';
|
||||
import Table from '@/ds-components/Table';
|
||||
import { type RowGroup, type Column } from '@/ds-components/Table/types';
|
||||
import useSubscriptionPlans from '@/hooks/use-subscription-plans';
|
||||
import {
|
||||
type SubscriptionPlanTableRow,
|
||||
type SubscriptionPlanTableGroupKey,
|
||||
} from '@/types/subscriptions';
|
||||
|
||||
import PlanName from './PlanName';
|
||||
import PlanQuotaGroupKeyLabel from './PlanQuotaGroupKeyLabel';
|
||||
import PlanQuotaKeyLabel from './PlanQuotaKeyLabel';
|
||||
import * as styles from './index.module.scss';
|
||||
import { constructPlanTableDataArray } from './utils';
|
||||
|
||||
function PlanQuotaTable() {
|
||||
const { data: subscriptionPlans, isLoading } = useSubscriptionPlans();
|
||||
|
||||
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
|
||||
isLoading={isLoading}
|
||||
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)) {
|
||||
if (quotaValue.length === 0) {
|
||||
return <DynamicT forKey="subscription.quota_table.contact" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{quotaValue.map((value) => (
|
||||
<div key={value}>
|
||||
<DynamicT
|
||||
forKey="subscription.quota_table.mau_price"
|
||||
interpolation={{ value: Number(value) / 100 }}
|
||||
/>
|
||||
</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;
|
|
@ -0,0 +1,24 @@
|
|||
import { conditional, conditionalString } from '@silverhand/essentials';
|
||||
|
||||
import { type SubscriptionPlanTableData, type SubscriptionPlan } from '@/types/subscriptions';
|
||||
|
||||
export const constructPlanTableDataArray = (
|
||||
plans: SubscriptionPlan[]
|
||||
): SubscriptionPlanTableData[] =>
|
||||
plans.map((plan) => {
|
||||
const { id, name, products, quota } = plan;
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
table: {
|
||||
...quota,
|
||||
basePrice: conditional(
|
||||
products.find((product) => product.type === 'flat')?.price.unitAmountDecimal
|
||||
),
|
||||
mauUnitPrice: products
|
||||
.filter(({ type }) => type !== 'flat')
|
||||
.map(({ price: { unitAmountDecimal } }) => conditionalString(unitAmountDecimal)),
|
||||
},
|
||||
};
|
||||
});
|
|
@ -2,11 +2,13 @@ import { withAppInsights } from '@logto/app-insights/react';
|
|||
|
||||
import PageMeta from '@/components/PageMeta';
|
||||
|
||||
import PlanQuotaTable from './PlanQuotaTable';
|
||||
|
||||
function Subscription() {
|
||||
return (
|
||||
<div>
|
||||
<PageMeta titleKey={['tenants.tabs.subscription', 'tenants.title']} />
|
||||
WIP
|
||||
<PlanQuotaTable />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
50
packages/console/src/types/subscriptions.ts
Normal file
50
packages/console/src/types/subscriptions.ts
Normal file
|
@ -0,0 +1,50 @@
|
|||
import { type SubscriptionPlanResponse } from '@/cloud/types/router';
|
||||
|
||||
export enum ReservedPlanName {
|
||||
Free = 'Free',
|
||||
Hobby = 'Hobby',
|
||||
Pro = 'Pro',
|
||||
Enterprise = 'Enterprise',
|
||||
}
|
||||
|
||||
type SubscriptionPlanQuota = SubscriptionPlanResponse['quota'] & {
|
||||
communitySupportEnabled: boolean;
|
||||
ticketSupportResponseTime: number;
|
||||
};
|
||||
|
||||
export type SubscriptionPlan = Omit<SubscriptionPlanResponse, 'quota'> & {
|
||||
quota: SubscriptionPlanQuota;
|
||||
};
|
||||
|
||||
export type SubscriptionPlanTable = Partial<
|
||||
SubscriptionPlanQuota & {
|
||||
basePrice: string;
|
||||
mauUnitPrice: string[];
|
||||
}
|
||||
>;
|
||||
|
||||
export type SubscriptionPlanTableData = Pick<SubscriptionPlanResponse, 'id' | 'name'> & {
|
||||
table: SubscriptionPlanTable;
|
||||
};
|
||||
|
||||
export enum SubscriptionPlanTableGroupKey {
|
||||
base = 'base',
|
||||
applications = 'applications',
|
||||
resources = 'resources',
|
||||
branding = 'branding',
|
||||
userAuthentication = 'userAuthentication',
|
||||
roles = 'roles',
|
||||
auditLogs = 'auditLogs',
|
||||
hooks = 'hooks',
|
||||
support = 'support',
|
||||
}
|
||||
|
||||
export type SubscriptionPlanTableGroupKeyMap = {
|
||||
[key in SubscriptionPlanTableGroupKey]: Array<keyof Required<SubscriptionPlanTable>>;
|
||||
};
|
||||
|
||||
type SubscriptionPlanTableValue = SubscriptionPlanTable[keyof SubscriptionPlanTable];
|
||||
|
||||
export type SubscriptionPlanTableRow = Record<string, SubscriptionPlanTableValue> & {
|
||||
quotaKey: keyof SubscriptionPlanTable;
|
||||
};
|
|
@ -2755,8 +2755,8 @@ importers:
|
|||
specifier: workspace:^1.3.1
|
||||
version: link:../app-insights
|
||||
'@logto/cloud':
|
||||
specifier: 0.2.5-1a68662
|
||||
version: 0.2.5-1a68662(zod@3.20.2)
|
||||
specifier: 0.2.5-31703ea
|
||||
version: 0.2.5-31703ea(zod@3.20.2)
|
||||
'@logto/connector-kit':
|
||||
specifier: workspace:^1.1.1
|
||||
version: link:../toolkit/connector-kit
|
||||
|
@ -7194,6 +7194,15 @@ packages:
|
|||
- zod
|
||||
dev: true
|
||||
|
||||
/@logto/cloud@0.2.5-31703ea(zod@3.20.2):
|
||||
resolution: {integrity: sha512-d0UfWpm5djW8+pEA4LJnJg9yDUebwcdxll7YLJGCPeo0SxUaWwHeef3yfmlKavx6Q4LCkVLY1NinQg+WBLW9Xw==}
|
||||
engines: {node: ^18.12.0}
|
||||
dependencies:
|
||||
'@withtyped/server': 0.12.0(zod@3.20.2)
|
||||
transitivePeerDependencies:
|
||||
- zod
|
||||
dev: true
|
||||
|
||||
/@logto/js@2.1.1:
|
||||
resolution: {integrity: sha512-PHikheavVK+l4ivgtzi14p184hEPgXjqQEAom1Gme1MZoopx+WlwxvHSEQBsmyvVqRtI0oiojhoU5tgYi1FKJw==}
|
||||
dependencies:
|
||||
|
|
Loading…
Reference in a new issue