diff --git a/packages/console/src/cloud/types/router.ts b/packages/console/src/cloud/types/router.ts index 508a41739..fb632a741 100644 --- a/packages/console/src/cloud/types/router.ts +++ b/packages/console/src/cloud/types/router.ts @@ -8,3 +8,5 @@ export type SubscriptionPlanResponse = GuardedResponse< >[number]; export type Subscription = GuardedResponse; + +export type SubscriptionUsage = GuardedResponse; diff --git a/packages/console/src/components/PlanDescription/index.module.scss b/packages/console/src/components/PlanDescription/index.module.scss new file mode 100644 index 000000000..0c3d1440c --- /dev/null +++ b/packages/console/src/components/PlanDescription/index.module.scss @@ -0,0 +1,7 @@ +@use '@/scss/underscore' as _; + +.description { + margin-top: _.unit(1); + font: var(--font-body-2); + color: var(--color-text-secondary); +} diff --git a/packages/console/src/components/PlanDescription/index.tsx b/packages/console/src/components/PlanDescription/index.tsx new file mode 100644 index 000000000..7f51cf709 --- /dev/null +++ b/packages/console/src/components/PlanDescription/index.tsx @@ -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 ( +
+ +
+ ); +} + +export default PlanDescription; diff --git a/packages/console/src/consts/subscriptions.ts b/packages/console/src/consts/subscriptions.ts index fd84079e9..045bc6b11 100644 --- a/packages/console/src/consts/subscriptions.ts +++ b/packages/console/src/consts/subscriptions.ts @@ -6,7 +6,7 @@ import { SubscriptionPlanTableGroupKey, } from '@/types/subscriptions'; -export enum ReservedPlanId { +enum ReservedPlanId { free = 'free', hobby = 'hobby', pro = 'pro', diff --git a/packages/console/src/hooks/use-current-subscription-usage.ts b/packages/console/src/hooks/use-current-subscription-usage.ts new file mode 100644 index 000000000..8ce793512 --- /dev/null +++ b/packages/console/src/hooks/use-current-subscription-usage.ts @@ -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( + isCloud && `/api/tenants/${currentTenantId}/usage`, + async () => + cloudApi.get('/api/tenants/:tenantId/usage', { + params: { tenantId: currentTenantId }, + }) + ); +}; + +export default useCurrentSubscriptionUsage; diff --git a/packages/console/src/hooks/use-current-subscription.ts b/packages/console/src/hooks/use-current-subscription.ts index ab0f60393..233824fe5 100644 --- a/packages/console/src/hooks/use-current-subscription.ts +++ b/packages/console/src/hooks/use-current-subscription.ts @@ -9,25 +9,13 @@ import { TenantsContext } from '@/contexts/TenantsProvider'; const useCurrentSubscription = () => { const { currentTenantId } = useContext(TenantsContext); const cloudApi = useCloudApi(); - const useSwrResponse = useSWR( + return useSWR( 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; diff --git a/packages/console/src/hooks/use-subscription-plans.ts b/packages/console/src/hooks/use-subscription-plans.ts index a2a62a83c..d259585fd 100644 --- a/packages/console/src/hooks/use-subscription-plans.ts +++ b/packages/console/src/hooks/use-subscription-plans.ts @@ -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( + isCloud && '/api/subscription-plans', + async () => cloudApi.get('/api/subscription-plans') + ); + + const { data: subscriptionPlansResponse } = useSwrResponse; const subscriptionPlans: Optional = useMemo(() => { if (!subscriptionPlansResponse) { @@ -49,9 +51,8 @@ const useSubscriptionPlans = () => { }, [subscriptionPlansResponse]); return { + ...useSwrResponse, data: subscriptionPlans, - error, - isLoading: !subscriptionPlans && !error, }; }; diff --git a/packages/console/src/pages/TenantSettings/Subscription/CurrentPlan/MauLimitExceededNotification/index.tsx b/packages/console/src/pages/TenantSettings/Subscription/CurrentPlan/MauLimitExceededNotification/index.tsx new file mode 100644 index 000000000..7e168c8fc --- /dev/null +++ b/packages/console/src/pages/TenantSettings/Subscription/CurrentPlan/MauLimitExceededNotification/index.tsx @@ -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 ( + { + // Todo @xiaoyijun Implement buy plan + }} + > + + + ); +} + +export default MauLimitExceededNotification; diff --git a/packages/console/src/pages/TenantSettings/Subscription/CurrentPlan/NextBillInfo/index.module.scss b/packages/console/src/pages/TenantSettings/Subscription/CurrentPlan/NextBillInfo/index.module.scss new file mode 100644 index 000000000..533ecc11f --- /dev/null +++ b/packages/console/src/pages/TenantSettings/Subscription/CurrentPlan/NextBillInfo/index.module.scss @@ -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); + } +} diff --git a/packages/console/src/pages/TenantSettings/Subscription/CurrentPlan/NextBillInfo/index.tsx b/packages/console/src/pages/TenantSettings/Subscription/CurrentPlan/NextBillInfo/index.tsx new file mode 100644 index 000000000..7a58d6a8c --- /dev/null +++ b/packages/console/src/pages/TenantSettings/Subscription/CurrentPlan/NextBillInfo/index.tsx @@ -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 ( +
+
+
+ {`$${cost.toLocaleString()}`} + {} + {cost > 0 && ( + }> + + + + + )} +
+
+ + ), + }} + > + {t('subscription.next_bill_hint')} + +
+
+ {cost > 0 && ( +
+ ); +} + +export default NextBillInfo; diff --git a/packages/console/src/pages/TenantSettings/Subscription/CurrentPlan/PlanUsage/index.module.scss b/packages/console/src/pages/TenantSettings/Subscription/CurrentPlan/PlanUsage/index.module.scss new file mode 100644 index 000000000..49bf92bd9 --- /dev/null +++ b/packages/console/src/pages/TenantSettings/Subscription/CurrentPlan/PlanUsage/index.module.scss @@ -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); + } + } +} diff --git a/packages/console/src/pages/TenantSettings/Subscription/CurrentPlan/PlanUsage/index.tsx b/packages/console/src/pages/TenantSettings/Subscription/CurrentPlan/PlanUsage/index.tsx new file mode 100644 index 000000000..17e30a70e --- /dev/null +++ b/packages/console/src/pages/TenantSettings/Subscription/CurrentPlan/PlanUsage/index.tsx @@ -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 ( +
+
+ {`${activeUsers} / `} + {mauLimit ? ( + mauLimit.toLocaleString() + ) : ( + + )} + {' MAU'} + {usagePercent && `(${(usagePercent * 100).toFixed(2)}%)`} +
+
+ +
+ {usagePercent && ( +
+
= 1 && styles.overuse)} + style={{ width: `${Math.min(usagePercent, 1) * 100}%` }} + /> +
+ )} +
+ ); +} + +export default PlanUsage; diff --git a/packages/console/src/pages/TenantSettings/Subscription/CurrentPlan/index.module.scss b/packages/console/src/pages/TenantSettings/Subscription/CurrentPlan/index.module.scss new file mode 100644 index 000000000..9d494f06a --- /dev/null +++ b/packages/console/src/pages/TenantSettings/Subscription/CurrentPlan/index.module.scss @@ -0,0 +1,13 @@ +@use '@/scss/underscore' as _; + +.planInfo { + margin-bottom: _.unit(6); + + .name { + font: var(--font-title-1); + } +} + +.notification { + margin-top: _.unit(6); +} diff --git a/packages/console/src/pages/TenantSettings/Subscription/CurrentPlan/index.tsx b/packages/console/src/pages/TenantSettings/Subscription/CurrentPlan/index.tsx new file mode 100644 index 000000000..a616fbbb5 --- /dev/null +++ b/packages/console/src/pages/TenantSettings/Subscription/CurrentPlan/index.tsx @@ -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 ( + +
+
+ +
+ +
+ + + + + {/* Todo @xiaoyijun retrieve cost from subscription usage on the feature is ready in the backend */} + + + +
+ ); +} + +export default CurrentPlan; diff --git a/packages/console/src/pages/TenantSettings/Subscription/index.tsx b/packages/console/src/pages/TenantSettings/Subscription/index.tsx index 835dec40e..ba3acee6c 100644 --- a/packages/console/src/pages/TenantSettings/Subscription/index.tsx +++ b/packages/console/src/pages/TenantSettings/Subscription/index.tsx @@ -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 ; } - if (!subscriptionPlans) { + if (!subscriptionPlans || !currentSubscription || !subscriptionUsage) { + return null; + } + + const currentSubscriptionPlan = subscriptionPlans.find( + (plan) => plan.id === currentSubscription.planId + ); + + if (!currentSubscriptionPlan) { return null; } return ( -
+
+