0
Fork 0
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:
Xiao Yijun 2023-07-17 14:32:11 +08:00 committed by GitHub
parent f4a66f74ce
commit 68a725926e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 404 additions and 29 deletions

View file

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

View file

@ -0,0 +1,7 @@
@use '@/scss/underscore' as _;
.description {
margin-top: _.unit(1);
font: var(--font-body-2);
color: var(--color-text-secondary);
}

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

View file

@ -6,7 +6,7 @@ import {
SubscriptionPlanTableGroupKey,
} from '@/types/subscriptions';
export enum ReservedPlanId {
enum ReservedPlanId {
free = 'free',
hobby = 'hobby',
pro = 'pro',

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,13 @@
@use '@/scss/underscore' as _;
.planInfo {
margin-bottom: _.unit(6);
.name {
font: var(--font-title-1);
}
}
.notification {
margin-top: _.unit(6);
}

View file

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

View file

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