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",
|
||||
"@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",
|
||||
|
|
|
@ -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 GuardedResponse, type RouterRoutes } from '@withtyped/client';
|
||||
|
||||
export type GetRoutes = RouterRoutes<typeof router>['get'];
|
||||
type GetRoutes = RouterRoutes<typeof router>['get'];
|
||||
|
||||
export type SubscriptionPlanResponse = GuardedResponse<
|
||||
GetRoutes['/api/subscription-plans']
|
||||
>[number];
|
||||
|
||||
export type Subscription = GuardedResponse<GetRoutes['/api/tenants/:tenantId/subscription']>;
|
||||
|
|
|
@ -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<string, boolean | undefined> = {
|
||||
[ReservedPlanName.Free]: 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 { 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<SubscriptionPlan[]> = 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,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -13,9 +13,10 @@ export const constructPlanTableDataArray = (
|
|||
name,
|
||||
table: {
|
||||
...quota,
|
||||
basePrice: conditional(
|
||||
basePrice:
|
||||
conditional(
|
||||
stripeProducts.find((product) => product.type === 'flat')?.price.unitAmountDecimal
|
||||
),
|
||||
) ?? '0',
|
||||
mauUnitPrice: stripeProducts
|
||||
.filter(({ type }) => type !== 'flat')
|
||||
.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 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 <Skeleton />;
|
||||
}
|
||||
|
||||
|
@ -23,6 +26,11 @@ function Subscription() {
|
|||
<div>
|
||||
<PageMeta titleKey={['tenants.tabs.subscription', 'tenants.title']} />
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
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
|
||||
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
|
||||
|
|
Loading…
Reference in a new issue