mirror of
https://github.com/logto-io/logto.git
synced 2024-12-30 20:33:54 -05:00
refactor(console): separate subscription based usage (#6448)
* refactor(console): separate subscription based usage * refactor: add periodic usage fallback to avoid breaking changes * fix: fix mock tenant data
This commit is contained in:
parent
db42279ed4
commit
87ff8cb8af
10 changed files with 148 additions and 36 deletions
|
@ -27,7 +27,7 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@fontsource/roboto-mono": "^5.0.0",
|
"@fontsource/roboto-mono": "^5.0.0",
|
||||||
"@jest/types": "^29.5.0",
|
"@jest/types": "^29.5.0",
|
||||||
"@logto/cloud": "0.2.5-a6cff75",
|
"@logto/cloud": "0.2.5-9a1b047",
|
||||||
"@logto/connector-kit": "workspace:^4.0.0",
|
"@logto/connector-kit": "workspace:^4.0.0",
|
||||||
"@logto/core-kit": "workspace:^2.5.0",
|
"@logto/core-kit": "workspace:^2.5.0",
|
||||||
"@logto/elements": "workspace:^0.0.0",
|
"@logto/elements": "workspace:^0.0.0",
|
||||||
|
|
|
@ -22,10 +22,14 @@ export type NewSubscriptionUsageResponse = GuardedResponse<
|
||||||
/** The response of `GET /api/tenants/my/subscription/quota` has the same response type. */
|
/** The response of `GET /api/tenants/my/subscription/quota` has the same response type. */
|
||||||
export type NewSubscriptionQuota = NewSubscriptionUsageResponse['quota'];
|
export type NewSubscriptionQuota = NewSubscriptionUsageResponse['quota'];
|
||||||
/** The response of `GET /api/tenants/my/subscription/usage` has the same response type. */
|
/** The response of `GET /api/tenants/my/subscription/usage` has the same response type. */
|
||||||
export type NewSubscriptionUsage = NewSubscriptionUsageResponse['usage'];
|
export type NewSubscriptionCountBasedUsage = NewSubscriptionUsageResponse['usage'];
|
||||||
export type NewSubscriptionResourceScopeUsage = NewSubscriptionUsageResponse['resources'];
|
export type NewSubscriptionResourceScopeUsage = NewSubscriptionUsageResponse['resources'];
|
||||||
export type NewSubscriptionRoleScopeUsage = NewSubscriptionUsageResponse['roles'];
|
export type NewSubscriptionRoleScopeUsage = NewSubscriptionUsageResponse['roles'];
|
||||||
|
|
||||||
|
export type NewSubscriptionPeriodicUsage = GuardedResponse<
|
||||||
|
GetRoutes['/api/tenants/:tenantId/subscription/periodic-usage']
|
||||||
|
>;
|
||||||
|
|
||||||
/* ===== Use `New` in the naming to avoid confusion with legacy types ===== */
|
/* ===== Use `New` in the naming to avoid confusion with legacy types ===== */
|
||||||
|
|
||||||
export type InvoicesResponse = GuardedResponse<GetRoutes['/api/tenants/:tenantId/invoices']>;
|
export type InvoicesResponse = GuardedResponse<GetRoutes['/api/tenants/:tenantId/invoices']>;
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { cond } from '@silverhand/essentials';
|
||||||
import { useContext, useState } from 'react';
|
import { useContext, useState } from 'react';
|
||||||
import { Trans, useTranslation } from 'react-i18next';
|
import { Trans, useTranslation } from 'react-i18next';
|
||||||
import ReactModal from 'react-modal';
|
import ReactModal from 'react-modal';
|
||||||
|
@ -6,6 +7,7 @@ import PlanUsage from '@/components/PlanUsage';
|
||||||
import { contactEmailLink } from '@/consts';
|
import { contactEmailLink } from '@/consts';
|
||||||
import { subscriptionPage } from '@/consts/pages';
|
import { subscriptionPage } from '@/consts/pages';
|
||||||
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
|
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
|
||||||
|
import { TenantsContext } from '@/contexts/TenantsProvider';
|
||||||
import Button from '@/ds-components/Button';
|
import Button from '@/ds-components/Button';
|
||||||
import FormField from '@/ds-components/FormField';
|
import FormField from '@/ds-components/FormField';
|
||||||
import InlineNotification from '@/ds-components/InlineNotification';
|
import InlineNotification from '@/ds-components/InlineNotification';
|
||||||
|
@ -18,13 +20,8 @@ import PlanName from '../PlanName';
|
||||||
import styles from './index.module.scss';
|
import styles from './index.module.scss';
|
||||||
|
|
||||||
function MauExceededModal() {
|
function MauExceededModal() {
|
||||||
const {
|
const { currentPlan, currentSubscription, currentSku } = useContext(SubscriptionDataContext);
|
||||||
currentPlan,
|
const { currentTenant } = useContext(TenantsContext);
|
||||||
currentSubscription,
|
|
||||||
currentSku,
|
|
||||||
currentSubscriptionQuota,
|
|
||||||
currentSubscriptionUsage,
|
|
||||||
} = useContext(SubscriptionDataContext);
|
|
||||||
|
|
||||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||||
const { navigate } = useTenantPathname();
|
const { navigate } = useTenantPathname();
|
||||||
|
@ -41,8 +38,10 @@ function MauExceededModal() {
|
||||||
const { name: planName } = currentPlan;
|
const { name: planName } = currentPlan;
|
||||||
|
|
||||||
const isMauExceeded =
|
const isMauExceeded =
|
||||||
currentSubscriptionQuota.mauLimit !== null &&
|
// eslint-disable-next-line @typescript-eslint/prefer-optional-chain, prettier/prettier
|
||||||
currentSubscriptionUsage.mauLimit >= currentSubscriptionQuota.mauLimit;
|
cond(currentTenant && currentTenant.quota.mauLimit !== null &&
|
||||||
|
currentTenant.usage.activeUsers >= currentTenant.quota.mauLimit
|
||||||
|
);
|
||||||
|
|
||||||
if (!isMauExceeded) {
|
if (!isMauExceeded) {
|
||||||
return null;
|
return null;
|
||||||
|
|
|
@ -2,11 +2,12 @@ import { ReservedPlanId } from '@logto/schemas';
|
||||||
import { cond, conditional } from '@silverhand/essentials';
|
import { cond, conditional } from '@silverhand/essentials';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { useContext } from 'react';
|
import { useContext, useMemo } from 'react';
|
||||||
|
|
||||||
import { type Subscription } from '@/cloud/types/router';
|
import { type Subscription, type NewSubscriptionPeriodicUsage } from '@/cloud/types/router';
|
||||||
import { isDevFeaturesEnabled } from '@/consts/env';
|
import { isDevFeaturesEnabled } from '@/consts/env';
|
||||||
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
|
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
|
||||||
|
import { TenantsContext } from '@/contexts/TenantsProvider';
|
||||||
import DynamicT from '@/ds-components/DynamicT';
|
import DynamicT from '@/ds-components/DynamicT';
|
||||||
import { type SubscriptionPlan } from '@/types/subscriptions';
|
import { type SubscriptionPlan } from '@/types/subscriptions';
|
||||||
import { formatPeriod } from '@/utils/subscription';
|
import { formatPeriod } from '@/utils/subscription';
|
||||||
|
@ -20,28 +21,49 @@ type Props = {
|
||||||
readonly currentSubscription: Subscription;
|
readonly currentSubscription: Subscription;
|
||||||
/** @deprecated */
|
/** @deprecated */
|
||||||
readonly currentPlan: SubscriptionPlan;
|
readonly currentPlan: SubscriptionPlan;
|
||||||
|
readonly periodicUsage?: NewSubscriptionPeriodicUsage;
|
||||||
};
|
};
|
||||||
|
|
||||||
function PlanUsage({ currentSubscription, currentPlan }: Props) {
|
function PlanUsage({ currentSubscription, currentPlan, periodicUsage: rawPeriodicUsage }: Props) {
|
||||||
const {
|
const {
|
||||||
currentSubscriptionQuota,
|
currentSubscriptionQuota,
|
||||||
currentSubscriptionUsage,
|
currentSubscriptionUsage,
|
||||||
currentSubscription: currentSubscriptionFromNewPricingModel,
|
currentSubscription: currentSubscriptionFromNewPricingModel,
|
||||||
} = useContext(SubscriptionDataContext);
|
} = useContext(SubscriptionDataContext);
|
||||||
|
const { currentTenant } = useContext(TenantsContext);
|
||||||
|
|
||||||
const { currentPeriodStart, currentPeriodEnd } = isDevFeaturesEnabled
|
const { currentPeriodStart, currentPeriodEnd } = isDevFeaturesEnabled
|
||||||
? currentSubscriptionFromNewPricingModel
|
? currentSubscriptionFromNewPricingModel
|
||||||
: currentSubscription;
|
: currentSubscription;
|
||||||
|
|
||||||
|
const periodicUsage = useMemo(
|
||||||
|
() =>
|
||||||
|
rawPeriodicUsage ??
|
||||||
|
conditional(
|
||||||
|
currentTenant && {
|
||||||
|
mauLimit: currentTenant.usage.activeUsers,
|
||||||
|
tokenLimit: currentTenant.usage.tokenUsage,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
[currentTenant, rawPeriodicUsage]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!periodicUsage) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const [activeUsers, mauLimit] = [
|
const [activeUsers, mauLimit] = [
|
||||||
currentSubscriptionUsage.mauLimit,
|
periodicUsage.mauLimit,
|
||||||
isDevFeaturesEnabled ? currentSubscriptionQuota.mauLimit : currentPlan.quota.mauLimit,
|
isDevFeaturesEnabled ? currentSubscriptionQuota.mauLimit : currentPlan.quota.mauLimit,
|
||||||
];
|
];
|
||||||
|
|
||||||
const usagePercent = conditional(mauLimit && activeUsers / mauLimit);
|
const usagePercent = conditional(mauLimit && activeUsers / mauLimit);
|
||||||
|
|
||||||
const usages: ProPlanUsageCardProps[] = usageKeys.map((key) => ({
|
const usages: ProPlanUsageCardProps[] = usageKeys.map((key) => ({
|
||||||
usage: currentSubscriptionUsage[key],
|
usage:
|
||||||
|
key === 'mauLimit' || key === 'tokenLimit'
|
||||||
|
? periodicUsage[key]
|
||||||
|
: currentSubscriptionUsage[key],
|
||||||
usageKey: `subscription.usage.${usageKeyMap[key]}`,
|
usageKey: `subscription.usage.${usageKeyMap[key]}`,
|
||||||
titleKey: `subscription.usage.${titleKeyMap[key]}`,
|
titleKey: `subscription.usage.${titleKeyMap[key]}`,
|
||||||
tooltipKey: `subscription.usage.${tooltipKeyMap[key]}`,
|
tooltipKey: `subscription.usage.${tooltipKeyMap[key]}`,
|
||||||
|
|
|
@ -5,7 +5,7 @@ import {
|
||||||
type NewSubscriptionQuota,
|
type NewSubscriptionQuota,
|
||||||
type LogtoSkuResponse,
|
type LogtoSkuResponse,
|
||||||
type TenantResponse,
|
type TenantResponse,
|
||||||
type NewSubscriptionUsage,
|
type NewSubscriptionCountBasedUsage,
|
||||||
} from '@/cloud/types/router';
|
} from '@/cloud/types/router';
|
||||||
import { RegionName } from '@/components/Region';
|
import { RegionName } from '@/components/Region';
|
||||||
import { LogtoSkuType } from '@/types/skus';
|
import { LogtoSkuType } from '@/types/skus';
|
||||||
|
@ -34,9 +34,11 @@ export const defaultTenantResponse: TenantResponse = {
|
||||||
},
|
},
|
||||||
usage: {
|
usage: {
|
||||||
activeUsers: 0,
|
activeUsers: 0,
|
||||||
|
tokenUsage: 0,
|
||||||
},
|
},
|
||||||
quota: {
|
quota: {
|
||||||
mauLimit: null,
|
mauLimit: null,
|
||||||
|
tokenLimit: null,
|
||||||
},
|
},
|
||||||
openInvoices: [],
|
openInvoices: [],
|
||||||
isSuspended: false,
|
isSuspended: false,
|
||||||
|
@ -143,9 +145,7 @@ export const defaultSubscriptionQuota: NewSubscriptionQuota = {
|
||||||
bringYourUiEnabled: false,
|
bringYourUiEnabled: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const defaultSubscriptionUsage: NewSubscriptionUsage = {
|
export const defaultSubscriptionUsage: NewSubscriptionCountBasedUsage = {
|
||||||
mauLimit: 0,
|
|
||||||
tokenLimit: 0,
|
|
||||||
applicationsLimit: 0,
|
applicationsLimit: 0,
|
||||||
machineToMachineLimit: 0,
|
machineToMachineLimit: 0,
|
||||||
resourcesLimit: 0,
|
resourcesLimit: 0,
|
||||||
|
|
|
@ -2,7 +2,7 @@ import {
|
||||||
type LogtoSkuResponse,
|
type LogtoSkuResponse,
|
||||||
type Subscription,
|
type Subscription,
|
||||||
type NewSubscriptionQuota,
|
type NewSubscriptionQuota,
|
||||||
type NewSubscriptionUsage,
|
type NewSubscriptionCountBasedUsage,
|
||||||
type NewSubscriptionResourceScopeUsage,
|
type NewSubscriptionResourceScopeUsage,
|
||||||
type NewSubscriptionRoleScopeUsage,
|
type NewSubscriptionRoleScopeUsage,
|
||||||
} from '@/cloud/types/router';
|
} from '@/cloud/types/router';
|
||||||
|
@ -21,7 +21,7 @@ type NewSubscriptionSupplementContext = {
|
||||||
logtoSkus: LogtoSkuResponse[];
|
logtoSkus: LogtoSkuResponse[];
|
||||||
currentSku: LogtoSkuResponse;
|
currentSku: LogtoSkuResponse;
|
||||||
currentSubscriptionQuota: NewSubscriptionQuota;
|
currentSubscriptionQuota: NewSubscriptionQuota;
|
||||||
currentSubscriptionUsage: NewSubscriptionUsage;
|
currentSubscriptionUsage: NewSubscriptionCountBasedUsage;
|
||||||
currentSubscriptionResourceScopeUsage: NewSubscriptionResourceScopeUsage;
|
currentSubscriptionResourceScopeUsage: NewSubscriptionResourceScopeUsage;
|
||||||
currentSubscriptionRoleScopeUsage: NewSubscriptionRoleScopeUsage;
|
currentSubscriptionRoleScopeUsage: NewSubscriptionRoleScopeUsage;
|
||||||
mutateSubscriptionQuotaAndUsages: () => void;
|
mutateSubscriptionQuotaAndUsages: () => void;
|
||||||
|
|
|
@ -1,7 +1,9 @@
|
||||||
import { ReservedPlanId } from '@logto/schemas';
|
import { ReservedPlanId } from '@logto/schemas';
|
||||||
|
import { conditional } from '@silverhand/essentials';
|
||||||
import { useContext, useMemo, useState } from 'react';
|
import { useContext, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { toastResponseError } from '@/cloud/hooks/use-cloud-api';
|
import { toastResponseError } from '@/cloud/hooks/use-cloud-api';
|
||||||
|
import { type NewSubscriptionPeriodicUsage } from '@/cloud/types/router';
|
||||||
import { isDevFeaturesEnabled } from '@/consts/env';
|
import { isDevFeaturesEnabled } from '@/consts/env';
|
||||||
import { subscriptionPage } from '@/consts/pages';
|
import { subscriptionPage } from '@/consts/pages';
|
||||||
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
|
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
|
||||||
|
@ -23,14 +25,20 @@ type Props = {
|
||||||
/** @deprecated No need to pass in this argument in new pricing model */
|
/** @deprecated No need to pass in this argument in new pricing model */
|
||||||
readonly currentPlan: SubscriptionPlan;
|
readonly currentPlan: SubscriptionPlan;
|
||||||
readonly className?: string;
|
readonly className?: string;
|
||||||
|
readonly periodicUsage?: NewSubscriptionPeriodicUsage;
|
||||||
};
|
};
|
||||||
|
|
||||||
function MauLimitExceededNotification({ currentPlan, className }: Props) {
|
function MauLimitExceededNotification({
|
||||||
|
currentPlan,
|
||||||
|
periodicUsage: rawPeriodicUsage,
|
||||||
|
className,
|
||||||
|
}: Props) {
|
||||||
const { currentTenantId } = useContext(TenantsContext);
|
const { currentTenantId } = useContext(TenantsContext);
|
||||||
const { subscribe } = useSubscribe();
|
const { subscribe } = useSubscribe();
|
||||||
const { show } = useConfirmModal();
|
const { show } = useConfirmModal();
|
||||||
const { subscriptionPlans, logtoSkus, currentSubscriptionQuota, currentSubscriptionUsage } =
|
const { subscriptionPlans, logtoSkus, currentSubscriptionQuota } =
|
||||||
useContext(SubscriptionDataContext);
|
useContext(SubscriptionDataContext);
|
||||||
|
const { currentTenant } = useContext(TenantsContext);
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const proPlan = useMemo(
|
const proPlan = useMemo(
|
||||||
|
@ -43,6 +51,22 @@ function MauLimitExceededNotification({ currentPlan, className }: Props) {
|
||||||
quota: { mauLimit: oldPricingModelMauLimit },
|
quota: { mauLimit: oldPricingModelMauLimit },
|
||||||
} = currentPlan;
|
} = currentPlan;
|
||||||
|
|
||||||
|
const periodicUsage = useMemo(
|
||||||
|
() =>
|
||||||
|
rawPeriodicUsage ??
|
||||||
|
conditional(
|
||||||
|
currentTenant && {
|
||||||
|
mauLimit: currentTenant.usage.activeUsers,
|
||||||
|
tokenLimit: currentTenant.usage.tokenUsage,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
[currentTenant, rawPeriodicUsage]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!periodicUsage) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// Should be safe to access `mauLimit` here since we have excluded the case where `isDevFeaturesEnabled` is `true` but `currentSubscriptionQuota` is `null` in the above condition.
|
// Should be safe to access `mauLimit` here since we have excluded the case where `isDevFeaturesEnabled` is `true` but `currentSubscriptionQuota` is `null` in the above condition.
|
||||||
const mauLimit = isDevFeaturesEnabled
|
const mauLimit = isDevFeaturesEnabled
|
||||||
? currentSubscriptionQuota.mauLimit
|
? currentSubscriptionQuota.mauLimit
|
||||||
|
@ -50,7 +74,7 @@ function MauLimitExceededNotification({ currentPlan, className }: Props) {
|
||||||
|
|
||||||
if (
|
if (
|
||||||
mauLimit === null || // Unlimited
|
mauLimit === null || // Unlimited
|
||||||
currentSubscriptionUsage.mauLimit < mauLimit ||
|
periodicUsage.mauLimit < mauLimit ||
|
||||||
!proPlan ||
|
!proPlan ||
|
||||||
!proSku
|
!proSku
|
||||||
) {
|
) {
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { cond } from '@silverhand/essentials';
|
import { cond, conditional } from '@silverhand/essentials';
|
||||||
import { useContext, useMemo } from 'react';
|
import { useContext, useMemo } from 'react';
|
||||||
|
|
||||||
import { type Subscription } from '@/cloud/types/router';
|
import { type Subscription, type NewSubscriptionPeriodicUsage } from '@/cloud/types/router';
|
||||||
import BillInfo from '@/components/BillInfo';
|
import BillInfo from '@/components/BillInfo';
|
||||||
import ChargeNotification from '@/components/ChargeNotification';
|
import ChargeNotification from '@/components/ChargeNotification';
|
||||||
import FormCard from '@/components/FormCard';
|
import FormCard from '@/components/FormCard';
|
||||||
|
@ -10,6 +10,7 @@ import PlanName from '@/components/PlanName';
|
||||||
import PlanUsage from '@/components/PlanUsage';
|
import PlanUsage from '@/components/PlanUsage';
|
||||||
import { isDevFeaturesEnabled } from '@/consts/env';
|
import { isDevFeaturesEnabled } from '@/consts/env';
|
||||||
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
|
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
|
||||||
|
import { TenantsContext } from '@/contexts/TenantsProvider';
|
||||||
import FormField from '@/ds-components/FormField';
|
import FormField from '@/ds-components/FormField';
|
||||||
import { type SubscriptionPlan } from '@/types/subscriptions';
|
import { type SubscriptionPlan } from '@/types/subscriptions';
|
||||||
import { hasSurpassedQuotaLimit, hasSurpassedSubscriptionQuotaLimit } from '@/utils/quota';
|
import { hasSurpassedQuotaLimit, hasSurpassedSubscriptionQuotaLimit } from '@/utils/quota';
|
||||||
|
@ -23,10 +24,12 @@ type Props = {
|
||||||
readonly subscription: Subscription;
|
readonly subscription: Subscription;
|
||||||
/** @deprecated */
|
/** @deprecated */
|
||||||
readonly subscriptionPlan: SubscriptionPlan;
|
readonly subscriptionPlan: SubscriptionPlan;
|
||||||
|
readonly periodicUsage?: NewSubscriptionPeriodicUsage;
|
||||||
};
|
};
|
||||||
|
|
||||||
function CurrentPlan({ subscription, subscriptionPlan }: Props) {
|
function CurrentPlan({ subscription, subscriptionPlan, periodicUsage: rawPeriodicUsage }: Props) {
|
||||||
const { currentSku, currentSubscription, currentSubscriptionUsage, currentSubscriptionQuota } =
|
const { currentTenant } = useContext(TenantsContext);
|
||||||
|
const { currentSku, currentSubscription, currentSubscriptionQuota } =
|
||||||
useContext(SubscriptionDataContext);
|
useContext(SubscriptionDataContext);
|
||||||
const {
|
const {
|
||||||
id,
|
id,
|
||||||
|
@ -34,6 +37,18 @@ function CurrentPlan({ subscription, subscriptionPlan }: Props) {
|
||||||
quota: { tokenLimit },
|
quota: { tokenLimit },
|
||||||
} = subscriptionPlan;
|
} = subscriptionPlan;
|
||||||
|
|
||||||
|
const periodicUsage = useMemo(
|
||||||
|
() =>
|
||||||
|
rawPeriodicUsage ??
|
||||||
|
conditional(
|
||||||
|
currentTenant && {
|
||||||
|
mauLimit: currentTenant.usage.activeUsers,
|
||||||
|
tokenLimit: currentTenant.usage.tokenUsage,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
[currentTenant, rawPeriodicUsage]
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* After the new pricing model goes live, `upcomingInvoice` will always exist. However, for compatibility reasons, the price of the SKU's corresponding `unitPrice` will be used as a fallback when it does not exist. If `unitPrice` also does not exist, it means that the tenant does not have any applicable paid subscription, and the bill will be 0.
|
* After the new pricing model goes live, `upcomingInvoice` will always exist. However, for compatibility reasons, the price of the SKU's corresponding `unitPrice` will be used as a fallback when it does not exist. If `unitPrice` also does not exist, it means that the tenant does not have any applicable paid subscription, and the bill will be 0.
|
||||||
*/
|
*/
|
||||||
|
@ -42,15 +57,19 @@ function CurrentPlan({ subscription, subscriptionPlan }: Props) {
|
||||||
[currentSku.unitPrice, currentSubscription.upcomingInvoice?.subtotal]
|
[currentSku.unitPrice, currentSubscription.upcomingInvoice?.subtotal]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!periodicUsage) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const hasTokenSurpassedLimit = isDevFeaturesEnabled
|
const hasTokenSurpassedLimit = isDevFeaturesEnabled
|
||||||
? hasSurpassedSubscriptionQuotaLimit({
|
? hasSurpassedSubscriptionQuotaLimit({
|
||||||
quotaKey: 'tokenLimit',
|
quotaKey: 'tokenLimit',
|
||||||
usage: currentSubscriptionUsage.tokenLimit,
|
usage: periodicUsage.tokenLimit,
|
||||||
quota: currentSubscriptionQuota,
|
quota: currentSubscriptionQuota,
|
||||||
})
|
})
|
||||||
: hasSurpassedQuotaLimit({
|
: hasSurpassedQuotaLimit({
|
||||||
quotaKey: 'tokenLimit',
|
quotaKey: 'tokenLimit',
|
||||||
usage: currentSubscriptionUsage.tokenLimit,
|
usage: periodicUsage.tokenLimit,
|
||||||
plan: subscriptionPlan,
|
plan: subscriptionPlan,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -65,12 +84,20 @@ function CurrentPlan({ subscription, subscriptionPlan }: Props) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<FormField title="subscription.plan_usage">
|
<FormField title="subscription.plan_usage">
|
||||||
<PlanUsage currentSubscription={subscription} currentPlan={subscriptionPlan} />
|
<PlanUsage
|
||||||
|
currentSubscription={subscription}
|
||||||
|
currentPlan={subscriptionPlan}
|
||||||
|
periodicUsage={rawPeriodicUsage}
|
||||||
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField title="subscription.next_bill">
|
<FormField title="subscription.next_bill">
|
||||||
<BillInfo cost={upcomingCost} isManagePaymentVisible={Boolean(upcomingCost)} />
|
<BillInfo cost={upcomingCost} isManagePaymentVisible={Boolean(upcomingCost)} />
|
||||||
</FormField>
|
</FormField>
|
||||||
<MauLimitExceedNotification currentPlan={subscriptionPlan} className={styles.notification} />
|
<MauLimitExceedNotification
|
||||||
|
currentPlan={subscriptionPlan}
|
||||||
|
periodicUsage={rawPeriodicUsage}
|
||||||
|
className={styles.notification}
|
||||||
|
/>
|
||||||
<ChargeNotification
|
<ChargeNotification
|
||||||
hasSurpassedLimit={hasTokenSurpassedLimit}
|
hasSurpassedLimit={hasTokenSurpassedLimit}
|
||||||
quotaItemPhraseKey="tokens"
|
quotaItemPhraseKey="tokens"
|
||||||
|
|
|
@ -1,7 +1,12 @@
|
||||||
import { useContext } from 'react';
|
import { useContext } from 'react';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
|
||||||
|
import { useCloudApi } from '@/cloud/hooks/use-cloud-api';
|
||||||
import PageMeta from '@/components/PageMeta';
|
import PageMeta from '@/components/PageMeta';
|
||||||
|
import { isCloud, isDevFeaturesEnabled } from '@/consts/env';
|
||||||
|
import { Skeleton } from '@/containers/ConsoleContent/Sidebar';
|
||||||
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
|
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
|
||||||
|
import { TenantsContext } from '@/contexts/TenantsProvider';
|
||||||
import { pickupFeaturedLogtoSkus, pickupFeaturedPlans } from '@/utils/subscription';
|
import { pickupFeaturedLogtoSkus, pickupFeaturedPlans } from '@/utils/subscription';
|
||||||
|
|
||||||
import CurrentPlan from './CurrentPlan';
|
import CurrentPlan from './CurrentPlan';
|
||||||
|
@ -10,6 +15,7 @@ import SwitchPlanActionBar from './SwitchPlanActionBar';
|
||||||
import styles from './index.module.scss';
|
import styles from './index.module.scss';
|
||||||
|
|
||||||
function Subscription() {
|
function Subscription() {
|
||||||
|
const cloudApi = useCloudApi();
|
||||||
const {
|
const {
|
||||||
subscriptionPlans,
|
subscriptionPlans,
|
||||||
currentPlan,
|
currentPlan,
|
||||||
|
@ -18,14 +24,33 @@ function Subscription() {
|
||||||
currentSubscription,
|
currentSubscription,
|
||||||
onCurrentSubscriptionUpdated,
|
onCurrentSubscriptionUpdated,
|
||||||
} = useContext(SubscriptionDataContext);
|
} = useContext(SubscriptionDataContext);
|
||||||
|
const { currentTenantId } = useContext(TenantsContext);
|
||||||
|
|
||||||
const reservedPlans = pickupFeaturedPlans(subscriptionPlans);
|
const reservedPlans = pickupFeaturedPlans(subscriptionPlans);
|
||||||
const reservedSkus = pickupFeaturedLogtoSkus(logtoSkus);
|
const reservedSkus = pickupFeaturedLogtoSkus(logtoSkus);
|
||||||
|
|
||||||
|
const { data: periodicUsage, isLoading } = useSWR(
|
||||||
|
isCloud &&
|
||||||
|
isDevFeaturesEnabled &&
|
||||||
|
`/api/tenants/${currentTenantId}/subscription/periodic-usage`,
|
||||||
|
async () =>
|
||||||
|
cloudApi.get(`/api/tenants/:tenantId/subscription/periodic-usage`, {
|
||||||
|
params: { tenantId: currentTenantId },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <Skeleton />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<PageMeta titleKey={['tenants.tabs.subscription', 'tenants.title']} />
|
<PageMeta titleKey={['tenants.tabs.subscription', 'tenants.title']} />
|
||||||
<CurrentPlan subscription={currentSubscription} subscriptionPlan={currentPlan} />
|
<CurrentPlan
|
||||||
|
subscription={currentSubscription}
|
||||||
|
subscriptionPlan={currentPlan}
|
||||||
|
periodicUsage={periodicUsage}
|
||||||
|
/>
|
||||||
<PlanComparisonTable />
|
<PlanComparisonTable />
|
||||||
<SwitchPlanActionBar
|
<SwitchPlanActionBar
|
||||||
currentSubscriptionPlanId={currentSubscription.planId}
|
currentSubscriptionPlanId={currentSubscription.planId}
|
||||||
|
|
|
@ -2458,8 +2458,8 @@ importers:
|
||||||
specifier: ^29.5.0
|
specifier: ^29.5.0
|
||||||
version: 29.5.0
|
version: 29.5.0
|
||||||
'@logto/cloud':
|
'@logto/cloud':
|
||||||
specifier: 0.2.5-a6cff75
|
specifier: 0.2.5-9a1b047
|
||||||
version: 0.2.5-a6cff75(zod@3.23.8)
|
version: 0.2.5-9a1b047(zod@3.23.8)
|
||||||
'@logto/connector-kit':
|
'@logto/connector-kit':
|
||||||
specifier: workspace:^4.0.0
|
specifier: workspace:^4.0.0
|
||||||
version: link:../toolkit/connector-kit
|
version: link:../toolkit/connector-kit
|
||||||
|
@ -5080,6 +5080,10 @@ packages:
|
||||||
resolution: {integrity: sha512-19MGifwYGxjQMPrm6monfoQyOp9UTL/chtZE0JugppNwvvLyqr3Nx0maCHuwrydLt0ImBSgVmPW1cJVvu2tVPg==}
|
resolution: {integrity: sha512-19MGifwYGxjQMPrm6monfoQyOp9UTL/chtZE0JugppNwvvLyqr3Nx0maCHuwrydLt0ImBSgVmPW1cJVvu2tVPg==}
|
||||||
engines: {node: ^20.9.0}
|
engines: {node: ^20.9.0}
|
||||||
|
|
||||||
|
'@logto/cloud@0.2.5-9a1b047':
|
||||||
|
resolution: {integrity: sha512-1vOno0Gg5B6f2UOYW275e7zSwItNXJXJdwtcYLn9ThvRi3Lvers+TXDN4SQ/0+l39sjN/1WJ9ZCKvGH89AIGxA==}
|
||||||
|
engines: {node: ^20.9.0}
|
||||||
|
|
||||||
'@logto/cloud@0.2.5-a6cff75':
|
'@logto/cloud@0.2.5-a6cff75':
|
||||||
resolution: {integrity: sha512-VlW8MI8RU5dWbHOXY6HjcaC4cqN+I0FIplZQnQjsf00R7K1EFvWfdzNqMcPsiK0ljnyEkRBH4GO77zJ/MYsNdg==}
|
resolution: {integrity: sha512-VlW8MI8RU5dWbHOXY6HjcaC4cqN+I0FIplZQnQjsf00R7K1EFvWfdzNqMcPsiK0ljnyEkRBH4GO77zJ/MYsNdg==}
|
||||||
engines: {node: ^20.9.0}
|
engines: {node: ^20.9.0}
|
||||||
|
@ -14722,6 +14726,13 @@ snapshots:
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- zod
|
- zod
|
||||||
|
|
||||||
|
'@logto/cloud@0.2.5-9a1b047(zod@3.23.8)':
|
||||||
|
dependencies:
|
||||||
|
'@silverhand/essentials': 2.9.1
|
||||||
|
'@withtyped/server': 0.13.6(zod@3.23.8)
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- zod
|
||||||
|
|
||||||
'@logto/cloud@0.2.5-a6cff75(zod@3.23.8)':
|
'@logto/cloud@0.2.5-a6cff75(zod@3.23.8)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@silverhand/essentials': 2.9.1
|
'@silverhand/essentials': 2.9.1
|
||||||
|
|
Loading…
Reference in a new issue