diff --git a/packages/console/package.json b/packages/console/package.json index 530a4eefe..b5eb52eec 100644 --- a/packages/console/package.json +++ b/packages/console/package.json @@ -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", diff --git a/packages/console/src/cloud/hooks/use-cloud-swr.ts b/packages/console/src/cloud/hooks/use-cloud-swr.ts new file mode 100644 index 000000000..5ff446619 --- /dev/null +++ b/packages/console/src/cloud/hooks/use-cloud-swr.ts @@ -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; + +export const useCloudSwr = (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) }; +}; diff --git a/packages/console/src/cloud/types/router.ts b/packages/console/src/cloud/types/router.ts new file mode 100644 index 000000000..0b61f4593 --- /dev/null +++ b/packages/console/src/cloud/types/router.ts @@ -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['get']; + +type RouteResponseType = + z.infer>; + +export type SubscriptionPlanResponse = RouteResponseType< + GetRoutes['/api/subscription-plans'] +>[number]; diff --git a/packages/console/src/consts/subscriptions.ts b/packages/console/src/consts/subscriptions.ts new file mode 100644 index 000000000..c05ed2fb7 --- /dev/null +++ b/packages/console/src/consts/subscriptions.ts @@ -0,0 +1,75 @@ +import { + type SubscriptionPlanTableData, + ReservedPlanName, + type SubscriptionPlanTable, + type SubscriptionPlanTableGroupKeyMap, + SubscriptionPlanTableGroupKey, +} from '@/types/subscriptions'; + +export const communitySupportEnabledMap: Record = { + [ReservedPlanName.Free]: true, + [ReservedPlanName.Hobby]: true, + [ReservedPlanName.Pro]: true, + [ReservedPlanName.Enterprise]: true, +}; + +export const ticketSupportResponseTimeMap: Record = { + [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; diff --git a/packages/console/src/ds-components/DynamicT/index.tsx b/packages/console/src/ds-components/DynamicT/index.tsx index 98f9b54c3..6a927fa59 100644 --- a/packages/console/src/ds-components/DynamicT/index.tsx +++ b/packages/console/src/ds-components/DynamicT/index.tsx @@ -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; }; diff --git a/packages/console/src/ds-components/Table/index.tsx b/packages/console/src/ds-components/Table/index.tsx index 54ab8c2d9..fb5d5e780 100644 --- a/packages/console/src/ds-components/Table/index.tsx +++ b/packages/console/src/ds-components/Table/index.tsx @@ -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< )} - {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} > diff --git a/packages/console/src/hooks/use-subscription-plans.ts b/packages/console/src/hooks/use-subscription-plans.ts new file mode 100644 index 000000000..79a0c6212 --- /dev/null +++ b/packages/console/src/hooks/use-subscription-plans.ts @@ -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; diff --git a/packages/console/src/pages/TenantSettings/Subscription/PlanQuotaTable/PlanName/index.tsx b/packages/console/src/pages/TenantSettings/Subscription/PlanQuotaTable/PlanName/index.tsx new file mode 100644 index 000000000..72ca91041 --- /dev/null +++ b/packages/console/src/pages/TenantSettings/Subscription/PlanQuotaTable/PlanName/index.tsx @@ -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 {name}; + } + + return ; +} + +export default PlanName; diff --git a/packages/console/src/pages/TenantSettings/Subscription/PlanQuotaTable/PlanQuotaGroupKeyLabel/index.tsx b/packages/console/src/pages/TenantSettings/Subscription/PlanQuotaTable/PlanQuotaGroupKeyLabel/index.tsx new file mode 100644 index 000000000..1068ba2a1 --- /dev/null +++ b/packages/console/src/pages/TenantSettings/Subscription/PlanQuotaTable/PlanQuotaGroupKeyLabel/index.tsx @@ -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 ; +} + +export default PlanQuotaGroupKeyLabel; diff --git a/packages/console/src/pages/TenantSettings/Subscription/PlanQuotaTable/PlanQuotaKeyLabel/index.tsx b/packages/console/src/pages/TenantSettings/Subscription/PlanQuotaTable/PlanQuotaKeyLabel/index.tsx new file mode 100644 index 000000000..1ff4abd6c --- /dev/null +++ b/packages/console/src/pages/TenantSettings/Subscription/PlanQuotaTable/PlanQuotaKeyLabel/index.tsx @@ -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]: 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 ; +} + +export default PlanQuotaKeyLabel; diff --git a/packages/console/src/pages/TenantSettings/Subscription/PlanQuotaTable/index.module.scss b/packages/console/src/pages/TenantSettings/Subscription/PlanQuotaTable/index.module.scss new file mode 100644 index 000000000..2794bc575 --- /dev/null +++ b/packages/console/src/pages/TenantSettings/Subscription/PlanQuotaTable/index.module.scss @@ -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; + } +} + diff --git a/packages/console/src/pages/TenantSettings/Subscription/PlanQuotaTable/index.tsx b/packages/console/src/pages/TenantSettings/Subscription/PlanQuotaTable/index.tsx new file mode 100644 index 000000000..470be1e09 --- /dev/null +++ b/packages/console/src/pages/TenantSettings/Subscription/PlanQuotaTable/index.tsx @@ -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> = 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: , + 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 ( +
+ 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 && ), + dataIndex: column, + className: conditional(column === 'quotaKey' && styles.quotaKeyColumn), + render: (row) => { + const { quotaKey } = row; + + if (column === 'quotaKey') { + return ; + } + + const quotaValue = row[column]; + + if (quotaValue === undefined) { + return ; + } + + if (quotaValue === null) { + return ; + } + + // For base price + if (typeof quotaValue === 'string') { + if (quotaKey === 'basePrice') { + return ( + + ); + } + + return quotaValue; + } + + // For mau unit price + if (Array.isArray(quotaValue)) { + if (quotaValue.length === 0) { + return ; + } + + return ( +
+ {quotaValue.map((value) => ( +
+ +
+ ))} +
+ ); + } + + if (typeof quotaValue === 'boolean') { + return quotaValue ? : '- '; + } + + // Note: handle number type + if (quotaValue === 0) { + return '-'; + } + + if (quotaKey === 'auditLogsRetentionDays') { + return ( + + ); + } + + if (quotaKey === 'ticketSupportResponseTime') { + return ( +
+ + {`(${quotaValue}h)`} +
+ ); + } + + return quotaValue.toLocaleString(); + }, + }; + }) satisfies Array> + } + /> +
+ +
+ + ); +} + +export default PlanQuotaTable; diff --git a/packages/console/src/pages/TenantSettings/Subscription/PlanQuotaTable/utils.ts b/packages/console/src/pages/TenantSettings/Subscription/PlanQuotaTable/utils.ts new file mode 100644 index 000000000..085390b2d --- /dev/null +++ b/packages/console/src/pages/TenantSettings/Subscription/PlanQuotaTable/utils.ts @@ -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)), + }, + }; + }); diff --git a/packages/console/src/pages/TenantSettings/Subscription/index.tsx b/packages/console/src/pages/TenantSettings/Subscription/index.tsx index 4c998b79b..3b1c9bbf8 100644 --- a/packages/console/src/pages/TenantSettings/Subscription/index.tsx +++ b/packages/console/src/pages/TenantSettings/Subscription/index.tsx @@ -2,11 +2,13 @@ import { withAppInsights } from '@logto/app-insights/react'; import PageMeta from '@/components/PageMeta'; +import PlanQuotaTable from './PlanQuotaTable'; + function Subscription() { return (
- WIP +
); } diff --git a/packages/console/src/types/subscriptions.ts b/packages/console/src/types/subscriptions.ts new file mode 100644 index 000000000..65ca10b43 --- /dev/null +++ b/packages/console/src/types/subscriptions.ts @@ -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 & { + quota: SubscriptionPlanQuota; +}; + +export type SubscriptionPlanTable = Partial< + SubscriptionPlanQuota & { + basePrice: string; + mauUnitPrice: string[]; + } +>; + +export type SubscriptionPlanTableData = Pick & { + 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>; +}; + +type SubscriptionPlanTableValue = SubscriptionPlanTable[keyof SubscriptionPlanTable]; + +export type SubscriptionPlanTableRow = Record & { + quotaKey: keyof SubscriptionPlanTable; +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 28f48f889..464707315 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: