mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -05:00
feat(console): add current plan form for subscription page (#4158)
This commit is contained in:
parent
f4a66f74ce
commit
68a725926e
15 changed files with 404 additions and 29 deletions
|
@ -8,3 +8,5 @@ export type SubscriptionPlanResponse = GuardedResponse<
|
|||
>[number];
|
||||
|
||||
export type Subscription = GuardedResponse<GetRoutes['/api/tenants/:tenantId/subscription']>;
|
||||
|
||||
export type SubscriptionUsage = GuardedResponse<GetRoutes['/api/tenants/:tenantId/usage']>;
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.description {
|
||||
margin-top: _.unit(1);
|
||||
font: var(--font-body-2);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
33
packages/console/src/components/PlanDescription/index.tsx
Normal file
33
packages/console/src/components/PlanDescription/index.tsx
Normal file
|
@ -0,0 +1,33 @@
|
|||
import { type TFuncKey } from 'i18next';
|
||||
|
||||
import DynamicT from '@/ds-components/DynamicT';
|
||||
import { ReservedPlanName } from '@/types/subscriptions';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
const registeredPlanDescriptionPhrasesMap: Record<
|
||||
string,
|
||||
TFuncKey<'translation', 'admin_console.subscription'> | undefined
|
||||
> = {
|
||||
[ReservedPlanName.Free]: 'free_plan_description',
|
||||
[ReservedPlanName.Hobby]: 'hobby_plan_description',
|
||||
[ReservedPlanName.Pro]: 'pro_plan_description',
|
||||
};
|
||||
|
||||
type Props = { planName: string };
|
||||
|
||||
function PlanDescription({ planName }: Props) {
|
||||
const description = registeredPlanDescriptionPhrasesMap[planName];
|
||||
|
||||
if (!description) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.description}>
|
||||
<DynamicT forKey={`subscription.${description}`} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PlanDescription;
|
|
@ -6,7 +6,7 @@ import {
|
|||
SubscriptionPlanTableGroupKey,
|
||||
} from '@/types/subscriptions';
|
||||
|
||||
export enum ReservedPlanId {
|
||||
enum ReservedPlanId {
|
||||
free = 'free',
|
||||
hobby = 'hobby',
|
||||
pro = 'pro',
|
||||
|
|
22
packages/console/src/hooks/use-current-subscription-usage.ts
Normal file
22
packages/console/src/hooks/use-current-subscription-usage.ts
Normal file
|
@ -0,0 +1,22 @@
|
|||
import { useContext } from 'react';
|
||||
import useSWR from 'swr';
|
||||
|
||||
import { useCloudApi } from '@/cloud/hooks/use-cloud-api';
|
||||
import { type SubscriptionUsage } from '@/cloud/types/router';
|
||||
import { isCloud } from '@/consts/env';
|
||||
import { TenantsContext } from '@/contexts/TenantsProvider';
|
||||
|
||||
const useCurrentSubscriptionUsage = () => {
|
||||
const { currentTenantId } = useContext(TenantsContext);
|
||||
const cloudApi = useCloudApi();
|
||||
|
||||
return useSWR<SubscriptionUsage, Error>(
|
||||
isCloud && `/api/tenants/${currentTenantId}/usage`,
|
||||
async () =>
|
||||
cloudApi.get('/api/tenants/:tenantId/usage', {
|
||||
params: { tenantId: currentTenantId },
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
export default useCurrentSubscriptionUsage;
|
|
@ -9,25 +9,13 @@ import { TenantsContext } from '@/contexts/TenantsProvider';
|
|||
const useCurrentSubscription = () => {
|
||||
const { currentTenantId } = useContext(TenantsContext);
|
||||
const cloudApi = useCloudApi();
|
||||
const useSwrResponse = useSWR<Subscription, Error>(
|
||||
return 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;
|
||||
|
|
|
@ -11,10 +11,12 @@ import { addSupportQuotaToPlan } from '@/utils/subscription';
|
|||
|
||||
const useSubscriptionPlans = () => {
|
||||
const cloudApi = useCloudApi();
|
||||
const { data: subscriptionPlansResponse, error } = useSWRImmutable<
|
||||
SubscriptionPlanResponse[],
|
||||
Error
|
||||
>(isCloud && '/api/subscription-plans', async () => cloudApi.get('/api/subscription-plans'));
|
||||
const useSwrResponse = useSWRImmutable<SubscriptionPlanResponse[], Error>(
|
||||
isCloud && '/api/subscription-plans',
|
||||
async () => cloudApi.get('/api/subscription-plans')
|
||||
);
|
||||
|
||||
const { data: subscriptionPlansResponse } = useSwrResponse;
|
||||
|
||||
const subscriptionPlans: Optional<SubscriptionPlan[]> = useMemo(() => {
|
||||
if (!subscriptionPlansResponse) {
|
||||
|
@ -49,9 +51,8 @@ const useSubscriptionPlans = () => {
|
|||
}, [subscriptionPlansResponse]);
|
||||
|
||||
return {
|
||||
...useSwrResponse,
|
||||
data: subscriptionPlans,
|
||||
error,
|
||||
isLoading: !subscriptionPlans && !error,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -0,0 +1,36 @@
|
|||
import DynamicT from '@/ds-components/DynamicT';
|
||||
import InlineNotification from '@/ds-components/InlineNotification';
|
||||
import { type SubscriptionPlan } from '@/types/subscriptions';
|
||||
|
||||
type Props = {
|
||||
activeUsers: number;
|
||||
currentPlan: SubscriptionPlan;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function MauLimitExceededNotification({ activeUsers, currentPlan, className }: Props) {
|
||||
const {
|
||||
quota: { mauLimit },
|
||||
} = currentPlan;
|
||||
if (
|
||||
!mauLimit || // Unlimited
|
||||
activeUsers < mauLimit
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<InlineNotification
|
||||
severity="error"
|
||||
action="subscription.upgrade_pro"
|
||||
className={className}
|
||||
onClick={() => {
|
||||
// Todo @xiaoyijun Implement buy plan
|
||||
}}
|
||||
>
|
||||
<DynamicT forKey="subscription.overfill_quota_warning" />
|
||||
</InlineNotification>
|
||||
);
|
||||
}
|
||||
|
||||
export default MauLimitExceededNotification;
|
|
@ -0,0 +1,38 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--color-divider);
|
||||
background: var(--color-layer-1);
|
||||
padding: _.unit(5) _.unit(6);
|
||||
}
|
||||
|
||||
.billInfo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.price {
|
||||
font: var(--font-title-2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin-top: _.unit(2);
|
||||
font: var(--font-body-2);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.articleLink {
|
||||
color: var(--color-text-secondary);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
|
||||
&:active {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,61 @@
|
|||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
import Tip from '@/assets/icons/tip.svg';
|
||||
import Button from '@/ds-components/Button';
|
||||
import DynamicT from '@/ds-components/DynamicT';
|
||||
import IconButton from '@/ds-components/IconButton';
|
||||
import TextLink from '@/ds-components/TextLink';
|
||||
import { ToggleTip } from '@/ds-components/Tip';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
type Props = {
|
||||
cost: number;
|
||||
};
|
||||
|
||||
function NextBillInfo({ cost }: Props) {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.billInfo}>
|
||||
<div className={styles.price}>
|
||||
<span>{`$${cost.toLocaleString()}`}</span>
|
||||
{}
|
||||
{cost > 0 && (
|
||||
<ToggleTip content={<DynamicT forKey="subscription.next_bill_tip" />}>
|
||||
<IconButton size="small">
|
||||
<Tip />
|
||||
</IconButton>
|
||||
</ToggleTip>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.description}>
|
||||
<Trans
|
||||
components={{
|
||||
a: (
|
||||
<TextLink
|
||||
href="https://blog.logto.io/logto-pricing-model"
|
||||
target="_blank"
|
||||
className={styles.articleLink}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{t('subscription.next_bill_hint')}
|
||||
</Trans>
|
||||
</div>
|
||||
</div>
|
||||
{cost > 0 && (
|
||||
<Button
|
||||
title="subscription.manage_payment"
|
||||
onClick={() => {
|
||||
// Todo @xiaoyijun Management payment
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default NextBillInfo;
|
|
@ -0,0 +1,39 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.container {
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--color-divider);
|
||||
background: var(--color-layer-1);
|
||||
padding: _.unit(5) _.unit(6);
|
||||
|
||||
> div:not(:first-child) {
|
||||
margin-top: _.unit(2);
|
||||
}
|
||||
}
|
||||
|
||||
.usage {
|
||||
font: var(--font-title-2);
|
||||
vertical-align: middle;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.planCycle {
|
||||
font: var(--font-body-2);
|
||||
}
|
||||
|
||||
.usageBar {
|
||||
border-radius: 4px;
|
||||
background-color: var(--color-layer-2);
|
||||
height: 18px;
|
||||
|
||||
.usageBarInner {
|
||||
border-radius: 4px;
|
||||
background-color: var(--color-primary-80);
|
||||
height: 18px;
|
||||
|
||||
&.overuse {
|
||||
background-color: var(--color-error-40);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,65 @@
|
|||
import { conditional } from '@silverhand/essentials';
|
||||
import classNames from 'classnames';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { type SubscriptionUsage, type Subscription } from '@/cloud/types/router';
|
||||
import DynamicT from '@/ds-components/DynamicT';
|
||||
import { type SubscriptionPlan } from '@/types/subscriptions';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
type Props = {
|
||||
subscriptionUsage: SubscriptionUsage;
|
||||
currentSubscription: Subscription;
|
||||
currentPlan: SubscriptionPlan;
|
||||
};
|
||||
|
||||
const formatPeriod = (start: Date, end: Date) => {
|
||||
const formattedStart = dayjs(start).format('MMM D');
|
||||
const formattedEnd = dayjs(end).format('MMM D');
|
||||
return `${formattedStart} - ${formattedEnd}`;
|
||||
};
|
||||
|
||||
function PlanUsage({ subscriptionUsage, currentSubscription, currentPlan }: Props) {
|
||||
const { currentPeriodStart, currentPeriodEnd } = currentSubscription;
|
||||
const { activeUsers } = subscriptionUsage;
|
||||
const {
|
||||
quota: { mauLimit },
|
||||
} = currentPlan;
|
||||
|
||||
const usagePercent = conditional(mauLimit && activeUsers / mauLimit);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.usage}>
|
||||
{`${activeUsers} / `}
|
||||
{mauLimit ? (
|
||||
mauLimit.toLocaleString()
|
||||
) : (
|
||||
<DynamicT forKey="subscription.quota_table.unlimited" />
|
||||
)}
|
||||
{' MAU'}
|
||||
{usagePercent && `(${(usagePercent * 100).toFixed(2)}%)`}
|
||||
</div>
|
||||
<div className={styles.planCycle}>
|
||||
<DynamicT
|
||||
forKey="subscription.plan_cycle"
|
||||
interpolation={{
|
||||
period: formatPeriod(currentPeriodStart, currentPeriodEnd),
|
||||
renewDate: dayjs(currentPeriodEnd).add(1, 'day').format('MMM D, YYYY'),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{usagePercent && (
|
||||
<div className={styles.usageBar}>
|
||||
<div
|
||||
className={classNames(styles.usageBarInner, usagePercent >= 1 && styles.overuse)}
|
||||
style={{ width: `${Math.min(usagePercent, 1) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PlanUsage;
|
|
@ -0,0 +1,13 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.planInfo {
|
||||
margin-bottom: _.unit(6);
|
||||
|
||||
.name {
|
||||
font: var(--font-title-1);
|
||||
}
|
||||
}
|
||||
|
||||
.notification {
|
||||
margin-top: _.unit(6);
|
||||
}
|
|
@ -0,0 +1,50 @@
|
|||
import { type SubscriptionUsage, type Subscription } from '@/cloud/types/router';
|
||||
import FormCard from '@/components/FormCard';
|
||||
import PlanDescription from '@/components/PlanDescription';
|
||||
import PlanName from '@/components/PlanName';
|
||||
import FormField from '@/ds-components/FormField';
|
||||
import { type SubscriptionPlan } from '@/types/subscriptions';
|
||||
|
||||
import MauLimitExceedNotification from './MauLimitExceededNotification';
|
||||
import NextBillInfo from './NextBillInfo';
|
||||
import PlanUsage from './PlanUsage';
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
type Props = {
|
||||
subscription: Subscription;
|
||||
subscriptionPlan: SubscriptionPlan;
|
||||
subscriptionUsage: SubscriptionUsage;
|
||||
};
|
||||
|
||||
function CurrentPlan({ subscription, subscriptionPlan, subscriptionUsage }: Props) {
|
||||
const { name } = subscriptionPlan;
|
||||
|
||||
return (
|
||||
<FormCard title="subscription.current_plan" description="subscription.current_plan_description">
|
||||
<div className={styles.planInfo}>
|
||||
<div className={styles.name}>
|
||||
<PlanName name={name} />
|
||||
</div>
|
||||
<PlanDescription planName={name} />
|
||||
</div>
|
||||
<FormField title="subscription.plan_usage">
|
||||
<PlanUsage
|
||||
currentSubscription={subscription}
|
||||
currentPlan={subscriptionPlan}
|
||||
subscriptionUsage={subscriptionUsage}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField title="subscription.next_bill">
|
||||
{/* Todo @xiaoyijun retrieve cost from subscription usage on the feature is ready in the backend */}
|
||||
<NextBillInfo cost={1000} />
|
||||
</FormField>
|
||||
<MauLimitExceedNotification
|
||||
activeUsers={subscriptionUsage.activeUsers}
|
||||
currentPlan={subscriptionPlan}
|
||||
className={styles.notification}
|
||||
/>
|
||||
</FormCard>
|
||||
);
|
||||
}
|
||||
|
||||
export default CurrentPlan;
|
|
@ -1,34 +1,54 @@
|
|||
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 useCurrentSubscriptionUsage from '@/hooks/use-current-subscription-usage';
|
||||
import useSubscriptionPlans from '@/hooks/use-subscription-plans';
|
||||
|
||||
import Skeleton from '../components/Skeleton';
|
||||
|
||||
import CurrentPlan from './CurrentPlan';
|
||||
import PlanQuotaTable from './PlanQuotaTable';
|
||||
import SwitchPlanActionBar from './SwitchPlanActionBar';
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
function Subscription() {
|
||||
const { data: subscriptionPlans, isLoading: isLoadingPlans } = useSubscriptionPlans();
|
||||
const { data: currentSubscription, isLoading: isLoadingSubscription } = useCurrentSubscription();
|
||||
const { data: subscriptionPlans, error: fetchPlansError } = useSubscriptionPlans();
|
||||
const { data: currentSubscription, error: fetchSubscriptionError } = useCurrentSubscription();
|
||||
const { data: subscriptionUsage, error: fetchSubscriptionUsageError } =
|
||||
useCurrentSubscriptionUsage();
|
||||
|
||||
if (isLoadingPlans || isLoadingSubscription) {
|
||||
const isLoadingPlans = !subscriptionPlans && !fetchPlansError;
|
||||
const isLoadingSubscription = !currentSubscription && !fetchSubscriptionError;
|
||||
const isLoadingSubscriptionUsage = !subscriptionUsage && !fetchSubscriptionUsageError;
|
||||
|
||||
if (isLoadingPlans || isLoadingSubscription || isLoadingSubscriptionUsage) {
|
||||
return <Skeleton />;
|
||||
}
|
||||
|
||||
if (!subscriptionPlans) {
|
||||
if (!subscriptionPlans || !currentSubscription || !subscriptionUsage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentSubscriptionPlan = subscriptionPlans.find(
|
||||
(plan) => plan.id === currentSubscription.planId
|
||||
);
|
||||
|
||||
if (!currentSubscriptionPlan) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.container}>
|
||||
<PageMeta titleKey={['tenants.tabs.subscription', 'tenants.title']} />
|
||||
<CurrentPlan
|
||||
subscription={currentSubscription}
|
||||
subscriptionPlan={currentSubscriptionPlan}
|
||||
subscriptionUsage={subscriptionUsage}
|
||||
/>
|
||||
<PlanQuotaTable subscriptionPlans={subscriptionPlans} />
|
||||
<SwitchPlanActionBar
|
||||
// Todo @xiaoyijun remove this fallback since we'll have a default subscription later
|
||||
currentSubscriptionPlanId={currentSubscription?.planId ?? ReservedPlanId.free}
|
||||
currentSubscriptionPlanId={currentSubscription.planId}
|
||||
subscriptionPlans={subscriptionPlans}
|
||||
/>
|
||||
</div>
|
||||
|
|
Loading…
Reference in a new issue