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",
|
"@fontsource/roboto-mono": "^5.0.0",
|
||||||
"@jest/types": "^29.5.0",
|
"@jest/types": "^29.5.0",
|
||||||
"@logto/app-insights": "workspace:^1.3.1",
|
"@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/connector-kit": "workspace:^1.1.1",
|
||||||
"@logto/core-kit": "workspace:^2.0.1",
|
"@logto/core-kit": "workspace:^2.0.1",
|
||||||
"@logto/language-kit": "workspace:^1.0.0",
|
"@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';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
forKey: AdminConsoleKey;
|
forKey: TFuncKey<'translation', 'admin_console'>;
|
||||||
interpolation?: Record<string, unknown>;
|
interpolation?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -26,6 +26,7 @@ export type Props<
|
||||||
isRowHoverEffectDisabled?: boolean;
|
isRowHoverEffectDisabled?: boolean;
|
||||||
isRowClickable?: (row: TFieldValues) => boolean;
|
isRowClickable?: (row: TFieldValues) => boolean;
|
||||||
rowClickHandler?: (row: TFieldValues) => void;
|
rowClickHandler?: (row: TFieldValues) => void;
|
||||||
|
rowClassName?: (row: TFieldValues, index: number) => string | undefined;
|
||||||
className?: string;
|
className?: string;
|
||||||
headerTableClassName?: string;
|
headerTableClassName?: string;
|
||||||
bodyTableWrapperClassName?: string;
|
bodyTableWrapperClassName?: string;
|
||||||
|
@ -48,6 +49,7 @@ function Table<
|
||||||
isRowHoverEffectDisabled = false,
|
isRowHoverEffectDisabled = false,
|
||||||
rowClickHandler,
|
rowClickHandler,
|
||||||
isRowClickable = () => Boolean(rowClickHandler),
|
isRowClickable = () => Boolean(rowClickHandler),
|
||||||
|
rowClassName,
|
||||||
className,
|
className,
|
||||||
headerTableClassName,
|
headerTableClassName,
|
||||||
bodyTableWrapperClassName,
|
bodyTableWrapperClassName,
|
||||||
|
@ -120,7 +122,7 @@ function Table<
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
{data?.map((row) => {
|
{data?.map((row, rowIndex) => {
|
||||||
const rowClickable = isRowClickable(row);
|
const rowClickable = isRowClickable(row);
|
||||||
|
|
||||||
const onClick = conditional(
|
const onClick = conditional(
|
||||||
|
@ -136,7 +138,8 @@ function Table<
|
||||||
key={row[rowIndexKey]}
|
key={row[rowIndexKey]}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
rowClickable && styles.clickable,
|
rowClickable && styles.clickable,
|
||||||
!isRowHoverEffectDisabled && styles.hoverEffect
|
!isRowHoverEffectDisabled && styles.hoverEffect,
|
||||||
|
rowClassName?.(row, rowIndex)
|
||||||
)}
|
)}
|
||||||
onClick={onClick}
|
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 PageMeta from '@/components/PageMeta';
|
||||||
|
|
||||||
|
import PlanQuotaTable from './PlanQuotaTable';
|
||||||
|
|
||||||
function Subscription() {
|
function Subscription() {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<PageMeta titleKey={['tenants.tabs.subscription', 'tenants.title']} />
|
<PageMeta titleKey={['tenants.tabs.subscription', 'tenants.title']} />
|
||||||
WIP
|
<PlanQuotaTable />
|
||||||
</div>
|
</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
|
specifier: workspace:^1.3.1
|
||||||
version: link:../app-insights
|
version: link:../app-insights
|
||||||
'@logto/cloud':
|
'@logto/cloud':
|
||||||
specifier: 0.2.5-1a68662
|
specifier: 0.2.5-31703ea
|
||||||
version: 0.2.5-1a68662(zod@3.20.2)
|
version: 0.2.5-31703ea(zod@3.20.2)
|
||||||
'@logto/connector-kit':
|
'@logto/connector-kit':
|
||||||
specifier: workspace:^1.1.1
|
specifier: workspace:^1.1.1
|
||||||
version: link:../toolkit/connector-kit
|
version: link:../toolkit/connector-kit
|
||||||
|
@ -7194,6 +7194,15 @@ packages:
|
||||||
- zod
|
- zod
|
||||||
dev: true
|
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:
|
/@logto/js@2.1.1:
|
||||||
resolution: {integrity: sha512-PHikheavVK+l4ivgtzi14p184hEPgXjqQEAom1Gme1MZoopx+WlwxvHSEQBsmyvVqRtI0oiojhoU5tgYi1FKJw==}
|
resolution: {integrity: sha512-PHikheavVK+l4ivgtzi14p184hEPgXjqQEAom1Gme1MZoopx+WlwxvHSEQBsmyvVqRtI0oiojhoU5tgYi1FKJw==}
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
Loading…
Reference in a new issue