0
Fork 0
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:
Xiao Yijun 2023-07-10 16:38:53 +08:00 committed by GitHub
parent 16925b3df7
commit 7742c7e3d4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 590 additions and 8 deletions

View file

@ -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",

View 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) };
};

View 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];

View 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;

View file

@ -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>;
}; };

View file

@ -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}
> >

View 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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;
}
}

View file

@ -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;

View file

@ -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)),
},
};
});

View file

@ -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>
); );
} }

View 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;
};

View file

@ -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: