0
Fork 0
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:
Xiao Yijun 2023-07-17 10:36:09 +08:00 committed by GitHub
parent e441c089d7
commit 1b4b73c4fd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 216 additions and 63 deletions

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,9 @@
@use '@/scss/underscore' as _;
.container {
position: relative;
> div:not(:last-child) {
margin-bottom: _.unit(4);
}
}

View file

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

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

View file

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