mirror of
https://github.com/logto-io/logto.git
synced 2024-12-30 20:33:54 -05:00
feat(console): add switch plan action bar (#4146)
This commit is contained in:
parent
e441c089d7
commit
1b4b73c4fd
14 changed files with 216 additions and 63 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-4f1d80b",
|
"@logto/cloud": "0.2.5-4d5e389",
|
||||||
"@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",
|
||||||
|
|
|
@ -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 = <T extends keyof GetRoutes>(
|
|
||||||
...args: T extends EmptyPayloadRoutes<GetRoutes>
|
|
||||||
? [path: T]
|
|
||||||
: [path: T, payload: GuardedPayload<GetRoutes[T]>]
|
|
||||||
): SWRResponse<GuardedResponse<GetRoutes[T]>, Error> => {
|
|
||||||
const cloudApi = useCloudApi();
|
|
||||||
const response = useSWR<GuardedResponse<GetRoutes[T]>>(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) };
|
|
||||||
};
|
|
|
@ -1,8 +1,10 @@
|
||||||
import type router from '@logto/cloud/routes';
|
import type router from '@logto/cloud/routes';
|
||||||
import { type GuardedResponse, type RouterRoutes } from '@withtyped/client';
|
import { type GuardedResponse, type RouterRoutes } from '@withtyped/client';
|
||||||
|
|
||||||
export type GetRoutes = RouterRoutes<typeof router>['get'];
|
type GetRoutes = RouterRoutes<typeof router>['get'];
|
||||||
|
|
||||||
export type SubscriptionPlanResponse = GuardedResponse<
|
export type SubscriptionPlanResponse = GuardedResponse<
|
||||||
GetRoutes['/api/subscription-plans']
|
GetRoutes['/api/subscription-plans']
|
||||||
>[number];
|
>[number];
|
||||||
|
|
||||||
|
export type Subscription = GuardedResponse<GetRoutes['/api/tenants/:tenantId/subscription']>;
|
||||||
|
|
|
@ -6,6 +6,18 @@ import {
|
||||||
SubscriptionPlanTableGroupKey,
|
SubscriptionPlanTableGroupKey,
|
||||||
} from '@/types/subscriptions';
|
} 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<string, boolean | undefined> = {
|
export const communitySupportEnabledMap: Record<string, boolean | undefined> = {
|
||||||
[ReservedPlanName.Free]: true,
|
[ReservedPlanName.Free]: true,
|
||||||
[ReservedPlanName.Hobby]: true,
|
[ReservedPlanName.Hobby]: true,
|
||||||
|
|
33
packages/console/src/hooks/use-current-subscription.ts
Normal file
33
packages/console/src/hooks/use-current-subscription.ts
Normal file
|
@ -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<Subscription, Error>(
|
||||||
|
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;
|
|
@ -1,36 +1,57 @@
|
||||||
import { type Optional } from '@silverhand/essentials';
|
import { type Optional } from '@silverhand/essentials';
|
||||||
import { useMemo } from 'react';
|
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 { type SubscriptionPlan } from '@/types/subscriptions';
|
||||||
|
import { addSupportQuotaToPlan } from '@/utils/subscription';
|
||||||
import { useCloudSwr } from '../cloud/hooks/use-cloud-swr';
|
|
||||||
|
|
||||||
const useSubscriptionPlans = () => {
|
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<SubscriptionPlan[]> = useMemo(() => {
|
const subscriptionPlans: Optional<SubscriptionPlan[]> = useMemo(() => {
|
||||||
if (!subscriptionPlansResponse) {
|
if (!subscriptionPlansResponse) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
return subscriptionPlansResponse.map((plan) => {
|
return subscriptionPlansResponse
|
||||||
const { name, quota } = plan;
|
.map((plan) => addSupportQuotaToPlan(plan))
|
||||||
|
.slice()
|
||||||
|
.sort(({ id: previousId }, { id: nextId }) => {
|
||||||
|
const previousIndex = reservedPlanIdOrder.indexOf(previousId);
|
||||||
|
const nextIndex = reservedPlanIdOrder.indexOf(nextId);
|
||||||
|
|
||||||
return {
|
if (previousIndex === -1 && nextIndex === -1) {
|
||||||
...plan,
|
// Note: If both plan ids not present in `reservedPlanIdOrder`, sort them in default order
|
||||||
quota: {
|
return 0;
|
||||||
...quota,
|
}
|
||||||
communitySupportEnabled: communitySupportEnabledMap[name] ?? false, // Fallback to not supported
|
|
||||||
ticketSupportResponseTime: ticketSupportResponseTimeMap[name] ?? 0, // Fallback to not supported
|
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]);
|
}, [subscriptionPlansResponse]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: subscriptionPlans,
|
data: subscriptionPlans,
|
||||||
error,
|
error,
|
||||||
|
isLoading: !subscriptionPlans && !error,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -5,7 +5,7 @@
|
||||||
padding: _.unit(3);
|
padding: _.unit(3);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background-color: var(--color-layer-1);
|
background-color: var(--color-layer-1);
|
||||||
margin-bottom: _.unit(4);
|
margin-bottom: _.unit(3);
|
||||||
|
|
||||||
.table {
|
.table {
|
||||||
tbody tr td {
|
tbody tr td {
|
||||||
|
|
|
@ -13,9 +13,10 @@ export const constructPlanTableDataArray = (
|
||||||
name,
|
name,
|
||||||
table: {
|
table: {
|
||||||
...quota,
|
...quota,
|
||||||
basePrice: conditional(
|
basePrice:
|
||||||
stripeProducts.find((product) => product.type === 'flat')?.price.unitAmountDecimal
|
conditional(
|
||||||
),
|
stripeProducts.find((product) => product.type === 'flat')?.price.unitAmountDecimal
|
||||||
|
) ?? '0',
|
||||||
mauUnitPrice: stripeProducts
|
mauUnitPrice: stripeProducts
|
||||||
.filter(({ type }) => type !== 'flat')
|
.filter(({ type }) => type !== 'flat')
|
||||||
.map(({ price: { unitAmountDecimal } }) => conditionalString(unitAmountDecimal)),
|
.map(({ price: { unitAmountDecimal } }) => conditionalString(unitAmountDecimal)),
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
|
@ -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 (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<Spacer />
|
||||||
|
{subscriptionPlans.map(({ id: planId }) => {
|
||||||
|
const isCurrentPlan = currentSubscriptionPlanId === planId;
|
||||||
|
const isDowngrade = isDowngradePlan(currentSubscriptionPlanId, planId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={planId}>
|
||||||
|
<Button
|
||||||
|
title={
|
||||||
|
isCurrentPlan
|
||||||
|
? 'subscription.current'
|
||||||
|
: isDowngrade
|
||||||
|
? 'subscription.downgrade'
|
||||||
|
: 'subscription.buy_now'
|
||||||
|
}
|
||||||
|
type={isDowngrade ? 'default' : 'primary'}
|
||||||
|
disabled={isCurrentPlan}
|
||||||
|
onClick={async () => {
|
||||||
|
// Todo @xiaoyijun handle buy plan
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div>
|
||||||
|
<a href={contactEmailLink} target="_blank" className={styles.buttonLink} rel="noopener">
|
||||||
|
<Button title="subscription.contact_us" type="primary" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SwitchPlanActionBar;
|
|
@ -0,0 +1,9 @@
|
||||||
|
@use '@/scss/underscore' as _;
|
||||||
|
|
||||||
|
.container {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
> div:not(:last-child) {
|
||||||
|
margin-bottom: _.unit(4);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,17 +1,20 @@
|
||||||
import { withAppInsights } from '@logto/app-insights/react';
|
import { withAppInsights } from '@logto/app-insights/react';
|
||||||
|
|
||||||
import PageMeta from '@/components/PageMeta';
|
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 useSubscriptionPlans from '@/hooks/use-subscription-plans';
|
||||||
|
|
||||||
import Skeleton from '../components/Skeleton';
|
import Skeleton from '../components/Skeleton';
|
||||||
|
|
||||||
import PlanQuotaTable from './PlanQuotaTable';
|
import PlanQuotaTable from './PlanQuotaTable';
|
||||||
|
import SwitchPlanActionBar from './SwitchPlanActionBar';
|
||||||
|
|
||||||
function Subscription() {
|
function Subscription() {
|
||||||
const { data: subscriptionPlans, error } = useSubscriptionPlans();
|
const { data: subscriptionPlans, isLoading: isLoadingPlans } = useSubscriptionPlans();
|
||||||
const isLoading = !subscriptionPlans && !error;
|
const { data: currentSubscription, isLoading: isLoadingSubscription } = useCurrentSubscription();
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoadingPlans || isLoadingSubscription) {
|
||||||
return <Skeleton />;
|
return <Skeleton />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -23,6 +26,11 @@ function Subscription() {
|
||||||
<div>
|
<div>
|
||||||
<PageMeta titleKey={['tenants.tabs.subscription', 'tenants.title']} />
|
<PageMeta titleKey={['tenants.tabs.subscription', 'tenants.title']} />
|
||||||
<PlanQuotaTable subscriptionPlans={subscriptionPlans} />
|
<PlanQuotaTable subscriptionPlans={subscriptionPlans} />
|
||||||
|
<SwitchPlanActionBar
|
||||||
|
// Todo @xiaoyijun remove this fallback since we'll have a default subscription later
|
||||||
|
currentSubscriptionPlanId={currentSubscription?.planId ?? ReservedPlanId.free}
|
||||||
|
subscriptionPlans={subscriptionPlans}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
29
packages/console/src/utils/subscription.ts
Normal file
29
packages/console/src/utils/subscription.ts
Normal file
|
@ -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);
|
|
@ -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-4f1d80b
|
specifier: 0.2.5-4d5e389
|
||||||
version: 0.2.5-4f1d80b(zod@3.20.2)
|
version: 0.2.5-4d5e389(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
|
||||||
|
@ -7185,8 +7185,8 @@ packages:
|
||||||
jose: 4.14.4
|
jose: 4.14.4
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/@logto/cloud@0.2.5-4f1d80b(zod@3.20.2):
|
/@logto/cloud@0.2.5-4d5e389(zod@3.20.2):
|
||||||
resolution: {integrity: sha512-AF0YnJiXDMS3HQ2ugZcwJRBkspvNtlXk992IwTNFZxbZdinpPoVmPnDnHuekACcQY5bFRgHjMB2/o/GKkdLpWg==}
|
resolution: {integrity: sha512-vRJZGc0WvjE1rFJ0DNLaOHkhpe4TMdui/pvcTwGb/bDKzw/NM+4HtUoZj1a1DZ8Qqn24ex1WkTjxY8XGd3EruQ==}
|
||||||
engines: {node: ^18.12.0}
|
engines: {node: ^18.12.0}
|
||||||
dependencies:
|
dependencies:
|
||||||
'@silverhand/essentials': 2.7.0
|
'@silverhand/essentials': 2.7.0
|
||||||
|
|
Loading…
Reference in a new issue