From 1b4b73c4fd87af651162b4724795bf5ae77b7509 Mon Sep 17 00:00:00 2001 From: Xiao Yijun Date: Mon, 17 Jul 2023 10:36:09 +0800 Subject: [PATCH] feat(console): add switch plan action bar (#4146) --- packages/console/package.json | 2 +- .../console/src/cloud/hooks/use-cloud-swr.ts | 35 ------------- packages/console/src/cloud/types/router.ts | 4 +- packages/console/src/consts/subscriptions.ts | 12 +++++ .../src/hooks/use-current-subscription.ts | 33 ++++++++++++ .../src/hooks/use-subscription-plans.ts | 51 +++++++++++++------ .../PlanQuotaTable/index.module.scss | 2 +- .../Subscription/PlanQuotaTable/utils.ts | 7 +-- .../SwitchPlanActionBar/index.module.scss | 23 +++++++++ .../SwitchPlanActionBar/index.tsx | 50 ++++++++++++++++++ .../Subscription/index.module.scss | 9 ++++ .../TenantSettings/Subscription/index.tsx | 14 +++-- packages/console/src/utils/subscription.ts | 29 +++++++++++ pnpm-lock.yaml | 8 +-- 14 files changed, 216 insertions(+), 63 deletions(-) delete mode 100644 packages/console/src/cloud/hooks/use-cloud-swr.ts create mode 100644 packages/console/src/hooks/use-current-subscription.ts create mode 100644 packages/console/src/pages/TenantSettings/Subscription/SwitchPlanActionBar/index.module.scss create mode 100644 packages/console/src/pages/TenantSettings/Subscription/SwitchPlanActionBar/index.tsx create mode 100644 packages/console/src/pages/TenantSettings/Subscription/index.module.scss create mode 100644 packages/console/src/utils/subscription.ts diff --git a/packages/console/package.json b/packages/console/package.json index afc8a9657..a89b0ec2d 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-4f1d80b", + "@logto/cloud": "0.2.5-4d5e389", "@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 deleted file mode 100644 index 28708b93b..000000000 --- a/packages/console/src/cloud/hooks/use-cloud-swr.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { - type GuardedResponse, - type GuardedPayload, - type EmptyPayloadRoutes, -} from '@withtyped/client'; -import useSWR, { type SWRResponse } 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)); -}; - -// The function type signature is mimicked from `ClientRequestHandler` -// in `@withtyped/client` since TypeScript cannot reuse generic type -// alias. -export const useCloudSwr = ( - ...args: T extends EmptyPayloadRoutes - ? [path: T] - : [path: T, payload: GuardedPayload] -): SWRResponse, Error> => { - const cloudApi = useCloudApi(); - const response = useSWR>(args[0], async () => - cloudApi.get(...args) - ); - - // 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 index a29d586dd..508a41739 100644 --- a/packages/console/src/cloud/types/router.ts +++ b/packages/console/src/cloud/types/router.ts @@ -1,8 +1,10 @@ import type router from '@logto/cloud/routes'; import { type GuardedResponse, type RouterRoutes } from '@withtyped/client'; -export type GetRoutes = RouterRoutes['get']; +type GetRoutes = RouterRoutes['get']; export type SubscriptionPlanResponse = GuardedResponse< GetRoutes['/api/subscription-plans'] >[number]; + +export type Subscription = GuardedResponse; diff --git a/packages/console/src/consts/subscriptions.ts b/packages/console/src/consts/subscriptions.ts index 68fd40258..fd84079e9 100644 --- a/packages/console/src/consts/subscriptions.ts +++ b/packages/console/src/consts/subscriptions.ts @@ -6,6 +6,18 @@ import { SubscriptionPlanTableGroupKey, } from '@/types/subscriptions'; +export enum ReservedPlanId { + free = 'free', + hobby = 'hobby', + pro = 'pro', +} + +export const reservedPlanIdOrder: string[] = [ + ReservedPlanId.free, + ReservedPlanId.hobby, + ReservedPlanId.pro, +]; + export const communitySupportEnabledMap: Record = { [ReservedPlanName.Free]: true, [ReservedPlanName.Hobby]: true, diff --git a/packages/console/src/hooks/use-current-subscription.ts b/packages/console/src/hooks/use-current-subscription.ts new file mode 100644 index 000000000..ab0f60393 --- /dev/null +++ b/packages/console/src/hooks/use-current-subscription.ts @@ -0,0 +1,33 @@ +import { useContext } from 'react'; +import useSWR from 'swr'; + +import { useCloudApi } from '@/cloud/hooks/use-cloud-api'; +import { type Subscription } from '@/cloud/types/router'; +import { isCloud } from '@/consts/env'; +import { TenantsContext } from '@/contexts/TenantsProvider'; + +const useCurrentSubscription = () => { + const { currentTenantId } = useContext(TenantsContext); + const cloudApi = useCloudApi(); + const useSwrResponse = useSWR( + isCloud && `/api/tenants/${currentTenantId}/subscription`, + async () => + cloudApi.get('/api/tenants/:tenantId/subscription', { + params: { tenantId: currentTenantId }, + }), + /** + * Note: since the default subscription feature is WIP, we don't want to retry on error. + * Todo: @xiaoyijun remove this option when the default subscription feature is ready. + */ + { shouldRetryOnError: false } + ); + + const { data, error } = useSwrResponse; + + return { + ...useSwrResponse, + isLoading: !data && !error, + }; +}; + +export default useCurrentSubscription; diff --git a/packages/console/src/hooks/use-subscription-plans.ts b/packages/console/src/hooks/use-subscription-plans.ts index 15c49d261..a2a62a83c 100644 --- a/packages/console/src/hooks/use-subscription-plans.ts +++ b/packages/console/src/hooks/use-subscription-plans.ts @@ -1,36 +1,57 @@ import { type Optional } from '@silverhand/essentials'; import { useMemo } from 'react'; +import useSWRImmutable from 'swr/immutable'; -import { communitySupportEnabledMap, ticketSupportResponseTimeMap } from '@/consts/subscriptions'; +import { useCloudApi } from '@/cloud/hooks/use-cloud-api'; +import { type SubscriptionPlanResponse } from '@/cloud/types/router'; +import { isCloud } from '@/consts/env'; +import { reservedPlanIdOrder } from '@/consts/subscriptions'; import { type SubscriptionPlan } from '@/types/subscriptions'; - -import { useCloudSwr } from '../cloud/hooks/use-cloud-swr'; +import { addSupportQuotaToPlan } from '@/utils/subscription'; const useSubscriptionPlans = () => { - const { data: subscriptionPlansResponse, error } = useCloudSwr('/api/subscription-plans'); + const cloudApi = useCloudApi(); + const { data: subscriptionPlansResponse, error } = useSWRImmutable< + SubscriptionPlanResponse[], + Error + >(isCloud && '/api/subscription-plans', async () => cloudApi.get('/api/subscription-plans')); const subscriptionPlans: Optional = useMemo(() => { if (!subscriptionPlansResponse) { return; } - return subscriptionPlansResponse.map((plan) => { - const { name, quota } = plan; + return subscriptionPlansResponse + .map((plan) => addSupportQuotaToPlan(plan)) + .slice() + .sort(({ id: previousId }, { id: nextId }) => { + const previousIndex = reservedPlanIdOrder.indexOf(previousId); + const nextIndex = reservedPlanIdOrder.indexOf(nextId); - return { - ...plan, - quota: { - ...quota, - communitySupportEnabled: communitySupportEnabledMap[name] ?? false, // Fallback to not supported - ticketSupportResponseTime: ticketSupportResponseTimeMap[name] ?? 0, // Fallback to not supported - }, - }; - }); + if (previousIndex === -1 && nextIndex === -1) { + // Note: If both plan ids not present in `reservedPlanIdOrder`, sort them in default order + return 0; + } + + if (previousIndex === -1) { + // Note: If only the previous plan has an id not present in `reservedPlanIdOrder`, move it to the end + return 1; + } + + if (nextIndex === -1) { + // Note: If only the next plan has an id not present in `reservedPlanIdOrder`, move it to the end + return -1; + } + + // Note: Compare them based on the index in the `reservedPlanIdOrder` array + return previousIndex - nextIndex; + }); }, [subscriptionPlansResponse]); return { data: subscriptionPlans, error, + isLoading: !subscriptionPlans && !error, }; }; diff --git a/packages/console/src/pages/TenantSettings/Subscription/PlanQuotaTable/index.module.scss b/packages/console/src/pages/TenantSettings/Subscription/PlanQuotaTable/index.module.scss index 2794bc575..698b26cd4 100644 --- a/packages/console/src/pages/TenantSettings/Subscription/PlanQuotaTable/index.module.scss +++ b/packages/console/src/pages/TenantSettings/Subscription/PlanQuotaTable/index.module.scss @@ -5,7 +5,7 @@ padding: _.unit(3); border-radius: 12px; background-color: var(--color-layer-1); - margin-bottom: _.unit(4); + margin-bottom: _.unit(3); .table { tbody tr td { diff --git a/packages/console/src/pages/TenantSettings/Subscription/PlanQuotaTable/utils.ts b/packages/console/src/pages/TenantSettings/Subscription/PlanQuotaTable/utils.ts index e65ee257b..5f40ca751 100644 --- a/packages/console/src/pages/TenantSettings/Subscription/PlanQuotaTable/utils.ts +++ b/packages/console/src/pages/TenantSettings/Subscription/PlanQuotaTable/utils.ts @@ -13,9 +13,10 @@ export const constructPlanTableDataArray = ( name, table: { ...quota, - basePrice: conditional( - stripeProducts.find((product) => product.type === 'flat')?.price.unitAmountDecimal - ), + basePrice: + conditional( + stripeProducts.find((product) => product.type === 'flat')?.price.unitAmountDecimal + ) ?? '0', mauUnitPrice: stripeProducts .filter(({ type }) => type !== 'flat') .map(({ price: { unitAmountDecimal } }) => conditionalString(unitAmountDecimal)), diff --git a/packages/console/src/pages/TenantSettings/Subscription/SwitchPlanActionBar/index.module.scss b/packages/console/src/pages/TenantSettings/Subscription/SwitchPlanActionBar/index.module.scss new file mode 100644 index 000000000..ba663455b --- /dev/null +++ b/packages/console/src/pages/TenantSettings/Subscription/SwitchPlanActionBar/index.module.scss @@ -0,0 +1,23 @@ +@use '@/scss/underscore' as _; + +.container { + background-color: var(--color-layer-1); + box-shadow: var(--shadow-2); + border-radius: 12px 12px 0 0; + position: sticky; + width: 100%; + bottom: 0; + padding: _.unit(4) _.unit(3) _.unit(4) _.unit(4); + display: flex; + justify-content: space-between; + + > * { + flex: 1; + display: flex; + justify-content: center; + } +} + +.buttonLink { + text-decoration: none; +} diff --git a/packages/console/src/pages/TenantSettings/Subscription/SwitchPlanActionBar/index.tsx b/packages/console/src/pages/TenantSettings/Subscription/SwitchPlanActionBar/index.tsx new file mode 100644 index 000000000..00ac91f34 --- /dev/null +++ b/packages/console/src/pages/TenantSettings/Subscription/SwitchPlanActionBar/index.tsx @@ -0,0 +1,50 @@ +import { contactEmailLink } from '@/consts'; +import Button from '@/ds-components/Button'; +import Spacer from '@/ds-components/Spacer'; +import { type SubscriptionPlan } from '@/types/subscriptions'; +import { isDowngradePlan } from '@/utils/subscription'; + +import * as styles from './index.module.scss'; + +type Props = { + currentSubscriptionPlanId: string; + subscriptionPlans: SubscriptionPlan[]; +}; + +function SwitchPlanActionBar({ currentSubscriptionPlanId, subscriptionPlans }: Props) { + return ( +
+ + {subscriptionPlans.map(({ id: planId }) => { + const isCurrentPlan = currentSubscriptionPlanId === planId; + const isDowngrade = isDowngradePlan(currentSubscriptionPlanId, planId); + + return ( +
+
+ ); + })} + +
+ ); +} + +export default SwitchPlanActionBar; diff --git a/packages/console/src/pages/TenantSettings/Subscription/index.module.scss b/packages/console/src/pages/TenantSettings/Subscription/index.module.scss new file mode 100644 index 000000000..4d1106838 --- /dev/null +++ b/packages/console/src/pages/TenantSettings/Subscription/index.module.scss @@ -0,0 +1,9 @@ +@use '@/scss/underscore' as _; + +.container { + position: relative; + + > div:not(:last-child) { + margin-bottom: _.unit(4); + } +} diff --git a/packages/console/src/pages/TenantSettings/Subscription/index.tsx b/packages/console/src/pages/TenantSettings/Subscription/index.tsx index fd4c29117..835dec40e 100644 --- a/packages/console/src/pages/TenantSettings/Subscription/index.tsx +++ b/packages/console/src/pages/TenantSettings/Subscription/index.tsx @@ -1,17 +1,20 @@ import { withAppInsights } from '@logto/app-insights/react'; import PageMeta from '@/components/PageMeta'; +import { ReservedPlanId } from '@/consts/subscriptions'; +import useCurrentSubscription from '@/hooks/use-current-subscription'; import useSubscriptionPlans from '@/hooks/use-subscription-plans'; import Skeleton from '../components/Skeleton'; import PlanQuotaTable from './PlanQuotaTable'; +import SwitchPlanActionBar from './SwitchPlanActionBar'; function Subscription() { - const { data: subscriptionPlans, error } = useSubscriptionPlans(); - const isLoading = !subscriptionPlans && !error; + const { data: subscriptionPlans, isLoading: isLoadingPlans } = useSubscriptionPlans(); + const { data: currentSubscription, isLoading: isLoadingSubscription } = useCurrentSubscription(); - if (isLoading) { + if (isLoadingPlans || isLoadingSubscription) { return ; } @@ -23,6 +26,11 @@ function Subscription() {
+
); } diff --git a/packages/console/src/utils/subscription.ts b/packages/console/src/utils/subscription.ts new file mode 100644 index 000000000..084df2d22 --- /dev/null +++ b/packages/console/src/utils/subscription.ts @@ -0,0 +1,29 @@ +import { type SubscriptionPlanResponse } from '@/cloud/types/router'; +import { + communitySupportEnabledMap, + reservedPlanIdOrder, + ticketSupportResponseTimeMap, +} from '@/consts/subscriptions'; + +export const addSupportQuotaToPlan = (subscriptionPlanResponse: SubscriptionPlanResponse) => { + const { name, quota } = subscriptionPlanResponse; + + return { + ...subscriptionPlanResponse, + quota: { + ...quota, + communitySupportEnabled: communitySupportEnabledMap[name] ?? false, // Fallback to not supported + ticketSupportResponseTime: ticketSupportResponseTimeMap[name] ?? 0, // Fallback to not supported + }, + }; +}; + +const getSubscriptionPlanOrderById = (id: string) => { + const index = reservedPlanIdOrder.indexOf(id); + + // Note: if the plan id is not in the reservedPlanIdOrder, it will be treated as the highest priority + return index === -1 ? Number.POSITIVE_INFINITY : index; +}; + +export const isDowngradePlan = (fromPlanId: string, toPlanId: string) => + getSubscriptionPlanOrderById(fromPlanId) > getSubscriptionPlanOrderById(toPlanId); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2e57f6e7d..755d23e6a 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-4f1d80b - version: 0.2.5-4f1d80b(zod@3.20.2) + specifier: 0.2.5-4d5e389 + version: 0.2.5-4d5e389(zod@3.20.2) '@logto/connector-kit': specifier: workspace:^1.1.1 version: link:../toolkit/connector-kit @@ -7185,8 +7185,8 @@ packages: jose: 4.14.4 dev: true - /@logto/cloud@0.2.5-4f1d80b(zod@3.20.2): - resolution: {integrity: sha512-AF0YnJiXDMS3HQ2ugZcwJRBkspvNtlXk992IwTNFZxbZdinpPoVmPnDnHuekACcQY5bFRgHjMB2/o/GKkdLpWg==} + /@logto/cloud@0.2.5-4d5e389(zod@3.20.2): + resolution: {integrity: sha512-vRJZGc0WvjE1rFJ0DNLaOHkhpe4TMdui/pvcTwGb/bDKzw/NM+4HtUoZj1a1DZ8Qqn24ex1WkTjxY8XGd3EruQ==} engines: {node: ^18.12.0} dependencies: '@silverhand/essentials': 2.7.0