diff --git a/packages/console/package.json b/packages/console/package.json index d9a20851c..b96b7aa28 100644 --- a/packages/console/package.json +++ b/packages/console/package.json @@ -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-1795c3d", + "@logto/cloud": "0.2.5-71b7fea", "@logto/connector-kit": "workspace:^1.1.1", "@logto/core-kit": "workspace:^2.0.1", "@logto/language-kit": "workspace:^1.0.0", @@ -60,7 +60,7 @@ "@types/react-helmet": "^6.1.6", "@types/react-modal": "^3.13.1", "@types/react-syntax-highlighter": "^15.5.1", - "@withtyped/client": "^0.7.21", + "@withtyped/client": "^0.7.22", "buffer": "^5.7.1", "classnames": "^2.3.1", "clean-deep": "^3.4.0", diff --git a/packages/console/src/components/MauExceededModal/index.tsx b/packages/console/src/components/MauExceededModal/index.tsx index f2884dd90..3b0fbc705 100644 --- a/packages/console/src/components/MauExceededModal/index.tsx +++ b/packages/console/src/components/MauExceededModal/index.tsx @@ -1,9 +1,7 @@ import { useContext, useState } from 'react'; import { Trans, useTranslation } from 'react-i18next'; import ReactModal from 'react-modal'; -import useSWRImmutable from 'swr/immutable'; -import { useCloudApi } from '@/cloud/hooks/use-cloud-api'; import PlanUsage from '@/components/PlanUsage'; import { contactEmailLink } from '@/consts'; import { subscriptionPage } from '@/consts/pages'; @@ -21,35 +19,22 @@ import PlanName from '../PlanName'; import * as styles from './index.module.scss'; function MauExceededModal() { - const { currentTenantId } = useContext(TenantsContext); - const cloudApi = useCloudApi(); + const { currentTenant } = useContext(TenantsContext); + const { usage, subscription } = currentTenant ?? {}; + const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); const { navigate } = useTenantPathname(); - const [hasClosed, setHasClosed] = useState(false); + const [hasClosed, setHasClosed] = useState(false); const handleCloseModal = () => { setHasClosed(true); }; const { data: subscriptionPlans } = useSubscriptionPlans(); - const { data: currentSubscription } = useSWRImmutable( - `/api/tenants/${currentTenantId}/subscription`, - async () => - cloudApi.get('/api/tenants/:tenantId/subscription', { params: { tenantId: currentTenantId } }) - ); + const currentPlan = subscriptionPlans?.find((plan) => plan.id === subscription?.planId); - const { data: currentUsage } = useSWRImmutable( - `/api/tenants/${currentTenantId}/usage`, - async () => - cloudApi.get('/api/tenants/:tenantId/usage', { params: { tenantId: currentTenantId } }) - ); - - const currentPlan = - currentSubscription && - subscriptionPlans?.find((plan) => plan.id === currentSubscription.planId); - - if (!currentPlan || !currentUsage || hasClosed) { + if (!subscription || !usage || !currentPlan || hasClosed) { return null; } @@ -58,7 +43,7 @@ function MauExceededModal() { name: planName, } = currentPlan; - const isMauExceeded = mauLimit !== null && currentUsage.activeUsers >= mauLimit; + const isMauExceeded = mauLimit !== null && usage.activeUsers >= mauLimit; if (!isMauExceeded) { return null; @@ -102,8 +87,8 @@ function MauExceededModal() { diff --git a/packages/console/src/components/PaymentOverdueModal/index.tsx b/packages/console/src/components/PaymentOverdueModal/index.tsx index f4f5762fc..968ef6fe7 100644 --- a/packages/console/src/components/PaymentOverdueModal/index.tsx +++ b/packages/console/src/components/PaymentOverdueModal/index.tsx @@ -1,11 +1,9 @@ -import { conditional } from '@silverhand/essentials'; -import { useContext, useMemo, useState } from 'react'; +import { useContext, useState } from 'react'; import { Trans, useTranslation } from 'react-i18next'; import ReactModal from 'react-modal'; -import useSWRImmutable from 'swr/immutable'; -import { useCloudApi } from '@/cloud/hooks/use-cloud-api'; import { contactEmailLink } from '@/consts'; +import { isCloud } from '@/consts/env'; import { TenantsContext } from '@/contexts/TenantsProvider'; import Button from '@/ds-components/Button'; import FormField from '@/ds-components/FormField'; @@ -13,7 +11,6 @@ import InlineNotification from '@/ds-components/InlineNotification'; import ModalLayout from '@/ds-components/ModalLayout'; import useSubscribe from '@/hooks/use-subscribe'; import * as modalStyles from '@/scss/modal.module.scss'; -import { getLatestUnpaidInvoice } from '@/utils/subscription'; import BillInfo from '../BillInfo'; @@ -22,26 +19,17 @@ import * as styles from './index.module.scss'; function PaymentOverdueModal() { const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); const { currentTenant, currentTenantId } = useContext(TenantsContext); - const cloudApi = useCloudApi(); - const { data: invoicesResponse } = useSWRImmutable( - `/api/tenants/${currentTenantId}/invoices`, - async () => - cloudApi.get('/api/tenants/:tenantId/invoices', { params: { tenantId: currentTenantId } }) - ); + const { openInvoices = [] } = currentTenant ?? {}; + const { visitManagePaymentPage } = useSubscribe(); const [isActionLoading, setIsActionLoading] = useState(false); - const latestUnpaidInvoice = useMemo( - () => conditional(invoicesResponse && getLatestUnpaidInvoice(invoicesResponse.invoices)), - [invoicesResponse] - ); - const [hasClosed, setHasClosed] = useState(false); const handleCloseModal = () => { setHasClosed(true); }; - if (!invoicesResponse || !latestUnpaidInvoice || hasClosed) { + if (!isCloud || openInvoices.length === 0 || hasClosed) { return null; } @@ -82,7 +70,12 @@ function PaymentOverdueModal() { )} - + total + currentInvoice.amountDue, + 0 + )} + /> diff --git a/packages/console/src/containers/AppContent/components/Topbar/TenantSelector/TenantDropdownItem/TenantStatusTag.tsx b/packages/console/src/containers/AppContent/components/Topbar/TenantSelector/TenantDropdownItem/TenantStatusTag.tsx index 3f5f5be0d..9aab7c5ab 100644 --- a/packages/console/src/containers/AppContent/components/Topbar/TenantSelector/TenantDropdownItem/TenantStatusTag.tsx +++ b/packages/console/src/containers/AppContent/components/Topbar/TenantSelector/TenantDropdownItem/TenantStatusTag.tsx @@ -1,35 +1,16 @@ -import { conditional } from '@silverhand/essentials'; -import { useMemo } from 'react'; - +import { type TenantResponse } from '@/cloud/types/router'; import DynamicT from '@/ds-components/DynamicT'; import Tag from '@/ds-components/Tag'; -import useInvoices from '@/hooks/use-invoices'; -import useSubscriptionPlan from '@/hooks/use-subscription-plan'; -import useSubscriptionUsage from '@/hooks/use-subscription-usage'; -import { getLatestUnpaidInvoice } from '@/utils/subscription'; +import { type SubscriptionPlan } from '@/types/subscriptions'; type Props = { - tenantId: string; + tenantData: TenantResponse; + tenantPlan: SubscriptionPlan; className?: string; }; -function TenantStatusTag({ tenantId, className }: Props) { - const { data: usage, error: fetchUsageError } = useSubscriptionUsage(tenantId); - const { data: invoices, error: fetchInvoiceError } = useInvoices(tenantId); - const { data: subscriptionPlan, error: fetchSubscriptionError } = useSubscriptionPlan(tenantId); - - const isLoadingUsage = !usage && !fetchUsageError; - const isLoadingInvoice = !invoices && !fetchInvoiceError; - const isLoadingSubscription = !subscriptionPlan && !fetchSubscriptionError; - - const latestUnpaidInvoice = useMemo( - () => conditional(invoices && getLatestUnpaidInvoice(invoices)), - [invoices] - ); - - if (isLoadingUsage || isLoadingInvoice || isLoadingSubscription) { - return null; - } +function TenantStatusTag({ tenantData, tenantPlan, className }: Props) { + const { usage, openInvoices } = tenantData; /** * Tenant status priority: @@ -38,7 +19,7 @@ function TenantStatusTag({ tenantId, className }: Props) { * 3. mau exceeded */ - if (invoices && latestUnpaidInvoice) { + if (openInvoices.length > 0) { return ( @@ -46,22 +27,20 @@ function TenantStatusTag({ tenantId, className }: Props) { ); } - if (subscriptionPlan && usage) { - const { activeUsers } = usage; + const { activeUsers } = usage; - const { - quota: { mauLimit }, - } = subscriptionPlan; + const { + quota: { mauLimit }, + } = tenantPlan; - const isMauExceeded = mauLimit !== null && activeUsers >= mauLimit; + const isMauExceeded = mauLimit !== null && activeUsers >= mauLimit; - if (isMauExceeded) { - return ( - - - - ); - } + if (isMauExceeded) { + return ( + + + + ); } return null; diff --git a/packages/console/src/containers/AppContent/components/Topbar/TenantSelector/TenantDropdownItem/index.tsx b/packages/console/src/containers/AppContent/components/Topbar/TenantSelector/TenantDropdownItem/index.tsx index b18a7ca4b..31d2e6515 100644 --- a/packages/console/src/containers/AppContent/components/Topbar/TenantSelector/TenantDropdownItem/index.tsx +++ b/packages/console/src/containers/AppContent/components/Topbar/TenantSelector/TenantDropdownItem/index.tsx @@ -1,10 +1,11 @@ import classNames from 'classnames'; +import { useMemo } from 'react'; import Tick from '@/assets/icons/tick.svg'; import { type TenantResponse } from '@/cloud/types/router'; import PlanName from '@/components/PlanName'; import { DropdownItem } from '@/ds-components/Dropdown'; -import useSubscriptionPlan from '@/hooks/use-subscription-plan'; +import useSubscriptionPlans from '@/hooks/use-subscription-plans'; import TenantEnvTag from '../TenantEnvTag'; @@ -18,8 +19,18 @@ type Props = { }; function TenantDropdownItem({ tenantData, isSelected, onClick }: Props) { - const { id, name, tag } = tenantData; - const { data: tenantPlan } = useSubscriptionPlan(id); + const { + name, + tag, + subscription: { planId }, + } = tenantData; + + const { data: plans } = useSubscriptionPlans(); + const tenantPlan = useMemo(() => plans?.find((plan) => plan.id === planId), [plans, planId]); + + if (!tenantPlan) { + return null; + } return ( @@ -27,9 +38,15 @@ function TenantDropdownItem({ tenantData, isSelected, onClick }: Props) {
{name}
- + +
+
+
-
{tenantPlan && }
diff --git a/packages/console/src/contexts/TenantsProvider.tsx b/packages/console/src/contexts/TenantsProvider.tsx index 69d5de2ea..da10471a3 100644 --- a/packages/console/src/contexts/TenantsProvider.tsx +++ b/packages/console/src/contexts/TenantsProvider.tsx @@ -1,6 +1,7 @@ import { defaultManagementApi, defaultTenantId } from '@logto/schemas'; import { TenantTag } from '@logto/schemas/models'; import { conditionalArray, noop } from '@silverhand/essentials'; +import dayjs from 'dayjs'; import type { ReactNode } from 'react'; import { useCallback, useMemo, createContext, useState } from 'react'; import { useMatch, useNavigate } from 'react-router-dom'; @@ -65,17 +66,27 @@ const { tenantId, indicator } = defaultManagementApi.resource; * - For cloud, the initial tenants data is empty, and it will be fetched from the cloud API. * - OSS has a fixed tenant with ID `default` and no cloud API to dynamically fetch tenants. */ -const initialTenants = Object.freeze( - conditionalArray( - !isCloud && { - id: tenantId, - name: `tenant_${tenantId}`, - tag: TenantTag.Development, - indicator, - planId: `${ReservedPlanId.free}`, // `planId` is string type. - } - ) -); +const defaultTenantResponse: TenantResponse = { + id: tenantId, + name: `tenant_${tenantId}`, + tag: TenantTag.Development, + indicator, + subscription: { + status: 'active', + planId: ReservedPlanId.free, + currentPeriodStart: dayjs().toDate(), + currentPeriodEnd: dayjs().add(1, 'month').toDate(), + }, + usage: { + activeUsers: 0, + cost: 0, + }, + openInvoices: [], + isSuspended: false, + planId: ReservedPlanId.free, // Reserved for compatibility with cloud +}; + +const initialTenants = Object.freeze(conditionalArray(!isCloud && defaultTenantResponse)); export const TenantsContext = createContext({ tenants: initialTenants, diff --git a/packages/console/src/pages/TenantSettings/Subscription/CurrentPlan/PaymentOverdueNotification/index.tsx b/packages/console/src/pages/TenantSettings/Subscription/CurrentPlan/PaymentOverdueNotification/index.tsx index 22afa2c4c..c69812612 100644 --- a/packages/console/src/pages/TenantSettings/Subscription/CurrentPlan/PaymentOverdueNotification/index.tsx +++ b/packages/console/src/pages/TenantSettings/Subscription/CurrentPlan/PaymentOverdueNotification/index.tsx @@ -1,32 +1,29 @@ -import { conditional } from '@silverhand/essentials'; -import { useContext, useMemo, useState } from 'react'; +import { useContext, useState } from 'react'; import { TenantsContext } from '@/contexts/TenantsProvider'; import DynamicT from '@/ds-components/DynamicT'; import InlineNotification from '@/ds-components/InlineNotification'; -import useInvoices from '@/hooks/use-invoices'; import useSubscribe from '@/hooks/use-subscribe'; -import { getLatestUnpaidInvoice } from '@/utils/subscription'; type Props = { className?: string; }; function PaymentOverdueNotification({ className }: Props) { - const { currentTenantId } = useContext(TenantsContext); + const { currentTenant, currentTenantId } = useContext(TenantsContext); + const { openInvoices = [] } = currentTenant ?? {}; const { visitManagePaymentPage } = useSubscribe(); const [isActionLoading, setIsActionLoading] = useState(false); - const { data: invoices, error } = useInvoices(currentTenantId); - const isLoadingInvoices = !invoices && !error; - const latestUnpaidInvoice = useMemo( - () => conditional(invoices && getLatestUnpaidInvoice(invoices)), - [invoices] - ); - if (isLoadingInvoices || !latestUnpaidInvoice) { + if (openInvoices.length === 0) { return null; } + const totalAmountDue = openInvoices.reduce( + (total, currentInvoice) => total + currentInvoice.amountDue, + 0 + ); + return ( ); diff --git a/packages/console/src/types/subscriptions.ts b/packages/console/src/types/subscriptions.ts index 429535541..d1f3f20d2 100644 --- a/packages/console/src/types/subscriptions.ts +++ b/packages/console/src/types/subscriptions.ts @@ -76,6 +76,4 @@ export const localCheckoutSessionGuard = z.object({ export type LocalCheckoutSession = z.infer; -export type Invoice = InvoicesResponse['invoices'][number]; - -export type InvoiceStatus = Invoice['status']; +export type InvoiceStatus = InvoicesResponse['invoices'][number]['status']; diff --git a/packages/console/src/utils/subscription.ts b/packages/console/src/utils/subscription.ts index 343efa5ab..35b4a4e44 100644 --- a/packages/console/src/utils/subscription.ts +++ b/packages/console/src/utils/subscription.ts @@ -5,7 +5,6 @@ import { tryReadResponseErrorBody } from '@/cloud/hooks/use-cloud-api'; import { type SubscriptionPlanResponse } from '@/cloud/types/router'; import { communitySupportEnabledMap, ticketSupportResponseTimeMap } from '@/consts/plan-quotas'; import { reservedPlanIdOrder } from '@/consts/subscriptions'; -import { type Invoice } from '@/types/subscriptions'; export const addSupportQuotaToPlan = (subscriptionPlanResponse: SubscriptionPlanResponse) => { const { id, quota } = subscriptionPlanResponse; @@ -43,15 +42,6 @@ export const formatPeriod = ({ periodStart, periodEnd, displayYear }: FormatPeri return `${formattedStart} - ${formattedEnd}`; }; -export const getLatestUnpaidInvoice = (invoices: Invoice[]) => - invoices - .slice() - .sort( - (invoiceA, invoiceB) => - new Date(invoiceB.createdAt).getTime() - new Date(invoiceA.createdAt).getTime() - ) - .find(({ status }) => status === 'uncollectible'); - /** * Note: this is a temporary solution to handle the case when the user tries to downgrade but the quota limit is exceeded. * Need a better solution to handle this case by sharing the error type between the console and cloud. - LOG-6608 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 704a01571..29bd68951 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2834,8 +2834,8 @@ importers: specifier: workspace:^1.3.1 version: link:../app-insights '@logto/cloud': - specifier: 0.2.5-1795c3d - version: 0.2.5-1795c3d(zod@3.20.2) + specifier: 0.2.5-71b7fea + version: 0.2.5-71b7fea(zod@3.20.2) '@logto/connector-kit': specifier: workspace:^1.1.1 version: link:../toolkit/connector-kit @@ -2936,8 +2936,8 @@ importers: specifier: ^15.5.1 version: 15.5.1 '@withtyped/client': - specifier: ^0.7.21 - version: 0.7.21(zod@3.20.2) + specifier: ^0.7.22 + version: 0.7.22(zod@3.20.2) buffer: specifier: ^5.7.1 version: 5.7.1 @@ -7330,12 +7330,12 @@ packages: jose: 4.14.4 dev: true - /@logto/cloud@0.2.5-1795c3d(zod@3.20.2): - resolution: {integrity: sha512-zxy9zr5swOxbzYJNYtKXofj2tSIS9565d+1pT6RSbmx3Hn+JG6uzsb75PZXW9vlmmm7p1sGZeTQ+xVzKNFPsMg==} + /@logto/cloud@0.2.5-71b7fea(zod@3.20.2): + resolution: {integrity: sha512-howllmEV6kWAgusP+2OSloG5bZQ146UiKn0PpA7xi9HcpgM6Fd1NPuNjc3BZdInJ5Qn0en6LOZL7c2EwTRx3jw==} engines: {node: ^18.12.0} dependencies: '@silverhand/essentials': 2.8.4 - '@withtyped/server': 0.12.8(zod@3.20.2) + '@withtyped/server': 0.12.9(zod@3.20.2) transitivePeerDependencies: - zod dev: true @@ -10048,15 +10048,6 @@ packages: eslint-visitor-keys: 3.4.1 dev: true - /@withtyped/client@0.7.21(zod@3.20.2): - resolution: {integrity: sha512-N9dvH5nqIwaT7YxaIm83RRQf9AEjxwJ4ugJviZJSxtWy8zLul2/odEMc6epieylFVa6CcLg82yJmRSlqPtJiTw==} - dependencies: - '@withtyped/server': 0.12.8(zod@3.20.2) - '@withtyped/shared': 0.2.2 - transitivePeerDependencies: - - zod - dev: true - /@withtyped/client@0.7.22(zod@3.20.2): resolution: {integrity: sha512-emNtcO0jc0dFWhvL7eUIRYzhTfn+JqgIvCmXb8ZUFOR8wdSSGrr9VDlm+wgQD06DEBBpmqtTHMMHTNXJdUC/Qw==} dependencies: @@ -10064,17 +10055,6 @@ packages: '@withtyped/shared': 0.2.2 transitivePeerDependencies: - zod - dev: false - - /@withtyped/server@0.12.8(zod@3.20.2): - resolution: {integrity: sha512-fv9feTOKJhtlaoYM/Kbs2gSTcIXlmu4OMUFwGmK5jqdbVNIOkDBIPxtcC5ZEwevWFgOcd5OqBW+FvbjiaF27Fw==} - peerDependencies: - zod: ^3.19.1 - dependencies: - '@silverhand/essentials': 2.8.4 - '@withtyped/shared': 0.2.2 - zod: 3.20.2 - dev: true /@withtyped/server@0.12.9(zod@3.20.2): resolution: {integrity: sha512-K5zoV9D+WpawbghtlJKF1KOshKkBjq+gYzNRWuZk13YmFWFLcmZn+QCblNP55z9IGdcHWpTRknqb1APuicdzgA==}