diff --git a/web/sdk/admin/views/organizations/details/apis/index.tsx b/web/sdk/admin/views/organizations/details/apis/index.tsx index 55c0ff414..0d2b00738 100644 --- a/web/sdk/admin/views/organizations/details/apis/index.tsx +++ b/web/sdk/admin/views/organizations/details/apis/index.tsx @@ -18,7 +18,7 @@ import { DEFAULT_PAGE_SIZE, } from "~/utils/connect-pagination"; import { transformDataTableQueryToRQLRequest } from "~/utils/transform-query"; -import { useDebounceValue } from "usehooks-ts"; +import { useDebouncedValue } from "~hooks"; import { useTerminology } from "../../../../hooks/useTerminology"; const NoCredentials = () => { @@ -81,7 +81,7 @@ export function OrganizationApisView() { }; }, [tableQuery, searchQuery]); - const [query] = useDebounceValue(computedQuery, 200); + const query = useDebouncedValue(computedQuery, 200); const [selectedServiceUser, setSelectedServiceUser] = useState( diff --git a/web/sdk/admin/views/organizations/details/invoices/index.tsx b/web/sdk/admin/views/organizations/details/invoices/index.tsx index aee5e68e3..76eff1d09 100644 --- a/web/sdk/admin/views/organizations/details/invoices/index.tsx +++ b/web/sdk/admin/views/organizations/details/invoices/index.tsx @@ -14,7 +14,7 @@ import { getGroupCountMapFromFirstPage, } from "~/utils/connect-pagination"; import { transformDataTableQueryToRQLRequest } from "~/utils/transform-query"; -import { useDebounceValue } from "usehooks-ts"; +import { useDebouncedValue } from "~hooks"; import { useTerminology } from "../../../../hooks/useTerminology"; const DEFAULT_SORT: DataTableSort = { name: 'createdAt', order: 'desc' }; @@ -111,7 +111,7 @@ export function OrganizationInvoicesView() { }; }, [tableQuery, searchQuery]); - const [query] = useDebounceValue(computedQuery, 200); + const query = useDebouncedValue(computedQuery, 200); const { data: infiniteData, diff --git a/web/sdk/admin/views/organizations/details/members/index.tsx b/web/sdk/admin/views/organizations/details/members/index.tsx index 534b9cc7c..a706ebb3c 100644 --- a/web/sdk/admin/views/organizations/details/members/index.tsx +++ b/web/sdk/admin/views/organizations/details/members/index.tsx @@ -22,7 +22,7 @@ import { DEFAULT_PAGE_SIZE } from '~/utils/connect-pagination'; import { transformDataTableQueryToRQLRequest } from '~/utils/transform-query'; -import { useDebounceValue } from 'usehooks-ts'; +import { useDebouncedValue } from '~hooks'; const updateRoleDialogHandle = AlertDialog.createHandle(); @@ -97,7 +97,7 @@ export function OrganizationMembersView() { }; }, [tableQuery, searchQuery]); - const [query] = useDebounceValue(computedQuery, 200); + const query = useDebouncedValue(computedQuery, 200); const { data: infiniteData, diff --git a/web/sdk/admin/views/organizations/details/pat/index.tsx b/web/sdk/admin/views/organizations/details/pat/index.tsx index 66e54d0c0..e2d1b7131 100644 --- a/web/sdk/admin/views/organizations/details/pat/index.tsx +++ b/web/sdk/admin/views/organizations/details/pat/index.tsx @@ -9,7 +9,7 @@ import { type Project, type SearchOrganizationPATsResponse_OrganizationPAT, } from "@raystack/proton/frontier"; -import { useDebounceValue } from "usehooks-ts"; +import { useDebouncedValue } from "~hooks"; import { OrganizationContext } from "../contexts/organization-context"; import { PageTitle } from "~/admin/components/PageTitle"; import { @@ -106,7 +106,7 @@ export function OrganizationPatView() { }; }, [tableQuery, searchQuery]); - const [query] = useDebounceValue(computedQuery, 200); + const query = useDebouncedValue(computedQuery, 200); const { data: infiniteData, diff --git a/web/sdk/admin/views/organizations/details/projects/index.tsx b/web/sdk/admin/views/organizations/details/projects/index.tsx index 7f24dc73d..9cd2a9e86 100644 --- a/web/sdk/admin/views/organizations/details/projects/index.tsx +++ b/web/sdk/admin/views/organizations/details/projects/index.tsx @@ -20,7 +20,7 @@ import { DEFAULT_PAGE_SIZE } from '~/utils/connect-pagination'; import { transformDataTableQueryToRQLRequest } from '~/utils/transform-query'; -import { useDebounceValue } from 'usehooks-ts'; +import { useDebouncedValue } from '~hooks'; import { useTerminology } from "../../../../hooks/useTerminology"; const DEFAULT_SORT: DataTableSort = { name: 'createdAt', order: 'desc' }; @@ -95,7 +95,7 @@ export function OrganizationProjectsView() { }; }, [tableQuery, searchQuery]); - const [query] = useDebounceValue(computedQuery, 200); + const query = useDebouncedValue(computedQuery, 200); const { data: infiniteData, diff --git a/web/sdk/admin/views/organizations/details/tokens/index.tsx b/web/sdk/admin/views/organizations/details/tokens/index.tsx index 95e35896b..31f953bcd 100644 --- a/web/sdk/admin/views/organizations/details/tokens/index.tsx +++ b/web/sdk/admin/views/organizations/details/tokens/index.tsx @@ -11,7 +11,7 @@ import { useInfiniteQuery } from "@connectrpc/connect-query"; import { getConnectNextPageParam, DEFAULT_PAGE_SIZE } from "~/utils/connect-pagination"; import { transformDataTableQueryToRQLRequest } from "~/utils/transform-query"; import { getColumns } from "./columns"; -import { useDebounceValue } from "usehooks-ts"; +import { useDebouncedValue } from "~hooks"; import { useTerminology } from "../../../../hooks/useTerminology"; const DEFAULT_SORT: DataTableSort = { name: 'createdAt', order: 'desc' }; @@ -82,7 +82,7 @@ export function OrganizationTokensView() { }; }, [tableQuery, searchQuery]); - const [query] = useDebounceValue(computedQuery, 200); + const query = useDebouncedValue(computedQuery, 200); const { data: infiniteData, diff --git a/web/sdk/client/hooks/useOrganizationInvoices.ts b/web/sdk/client/hooks/useOrganizationInvoices.ts new file mode 100644 index 000000000..3b536bdb4 --- /dev/null +++ b/web/sdk/client/hooks/useOrganizationInvoices.ts @@ -0,0 +1,84 @@ +import { useMemo } from 'react'; +import { useInfiniteQuery } from '@connectrpc/connect-query'; +import { FrontierServiceQueries } from '@raystack/proton/frontier'; +import type { + RQLRequest, + SearchOrganizationInvoicesResponse_OrganizationInvoice +} from '@raystack/proton/frontier'; +import { useFrontier } from '../contexts/FrontierContext'; +import { + getConnectNextPageParam, + getGroupCountMapFromFirstPage +} from '../utils/connect-pagination'; + +export interface UseOrganizationInvoicesOptions { + query: RQLRequest; + enabled?: boolean; +} + +export interface UseOrganizationInvoicesReturn { + invoices: SearchOrganizationInvoicesResponse_OrganizationInvoice[]; + groupCountMap: Record>; + isLoading: boolean; + isFetchingNextPage: boolean; + fetchNextPage: () => Promise; + hasNextPage: boolean; + isError: boolean; + error: Error | null; +} + +/** + * Common data source for organization invoices, backed by the + * `SearchOrganizationInvoices` RPC as a paginated infinite query. + */ +export function useOrganizationInvoices({ + query, + enabled = true +}: UseOrganizationInvoicesOptions): UseOrganizationInvoicesReturn { + const { activeOrganization } = useFrontier(); + const organizationId = activeOrganization?.id || ''; + + const { + data: infiniteData, + isLoading, + isFetchingNextPage, + fetchNextPage, + hasNextPage, + isError, + error + } = useInfiniteQuery( + FrontierServiceQueries.searchOrganizationInvoices, + { id: organizationId, query }, + { + enabled: !!organizationId && enabled, + pageParamKey: 'query', + getNextPageParam: lastPage => + getConnectNextPageParam(lastPage, { query }, 'organizationInvoices'), + staleTime: 0, + refetchOnWindowFocus: false, + retry: 1, + retryDelay: 1000 + } + ); + + const invoices = useMemo( + () => infiniteData?.pages?.flatMap(page => page.organizationInvoices) ?? [], + [infiniteData] + ); + + const groupCountMap = useMemo( + () => (infiniteData ? getGroupCountMapFromFirstPage(infiniteData) : {}), + [infiniteData] + ); + + return { + invoices, + groupCountMap, + isLoading, + isFetchingNextPage, + fetchNextPage, + hasNextPage, + isError, + error + }; +} diff --git a/web/sdk/client/views/billing/billing-view.tsx b/web/sdk/client/views/billing/billing-view.tsx index cda8fa84b..6246b68fb 100644 --- a/web/sdk/client/views/billing/billing-view.tsx +++ b/web/sdk/client/views/billing/billing-view.tsx @@ -1,17 +1,13 @@ 'use client'; -import { useCallback, useEffect, useMemo } from 'react'; +import { useCallback } from 'react'; import qs from 'query-string'; import { Flex, Dialog, EmptyState, toastManager } from '@raystack/apsara'; import { CreateCheckoutRequestSchema, - ListInvoicesRequestSchema, FrontierServiceQueries } from '@raystack/proton/frontier'; -import { - useMutation, - useQuery as useConnectQuery -} from '@connectrpc/connect-query'; +import { useMutation } from '@connectrpc/connect-query'; import { create } from '@bufbuild/protobuf'; import { useFrontier } from '../../contexts/FrontierContext'; import { useBillingPermission } from '../../hooks/useBillingPermission'; @@ -55,37 +51,9 @@ export function BillingView({ onNavigateToPlans }: BillingViewProps) { const isPermissionsLoading = !activeOrganization?.id || isFetching; const hasNoAccess = !canSeeBilling && !isPermissionsLoading; - const { - data: invoicesData, - isLoading: isInvoicesLoading, - error: invoicesError - } = useConnectQuery( - FrontierServiceQueries.listInvoices, - create(ListInvoicesRequestSchema, { - orgId: activeOrganization?.id || '', - nonzeroAmountOnly: true - }), - { - enabled: !!activeOrganization?.id && canSeeBilling - } - ); - - const invoices = useMemo(() => invoicesData?.invoices || [], [invoicesData]); - - useEffect(() => { - if (invoicesError) { - toastManager.add({ - title: 'Failed to load invoices', - description: invoicesError?.message, - type: 'error' - }); - } - }, [invoicesError]); - const isLoading = !activeOrganization?.id || isBillingAccountLoading || isActiveSubscriptionLoading || - isInvoicesLoading || isFetching || isOrganizationKycLoading; @@ -175,7 +143,7 @@ export function BillingView({ onNavigateToPlans }: BillingViewProps) { ( export function Invoices() { const { activeOrganization, config } = useFrontier(); - const organizationId = activeOrganization?.id || ''; const [tableQuery, setTableQuery] = useState(INITIAL_QUERY); @@ -163,35 +155,18 @@ export function Invoices() { [tableQuery] ); - const [query] = useDebounceValue(computedQuery, 200); + const query = useDebouncedValue(computedQuery, 200); const { - data: infiniteData, + invoices, + groupCountMap, isLoading, isFetchingNextPage, fetchNextPage, hasNextPage, isError - } = useInfiniteQuery( - FrontierServiceQueries.searchOrganizationInvoices, - { id: organizationId, query }, - { - enabled: !!organizationId, - pageParamKey: 'query', - getNextPageParam: lastPage => - getConnectNextPageParam(lastPage, { query }, 'organizationInvoices'), - staleTime: 0, - refetchOnWindowFocus: false, - retry: 1, - retryDelay: 1000 - } - ); + } = useOrganizationInvoices({ query }); - const invoices = useMemo( - () => - infiniteData?.pages?.flatMap(page => page.organizationInvoices) ?? [], - [infiniteData] - ); const loading = (!activeOrganization?.id || isLoading || isFetchingNextPage) && !isError; const onTableQueryChange = (newQuery: DataTableQuery) => { @@ -204,11 +179,6 @@ export function Invoices() { } }; - const groupCountMap = useMemo( - () => (infiniteData ? getGroupCountMapFromFirstPage(infiniteData) : {}), - [infiniteData] - ); - const columns = getColumns({ dateFormat: config?.dateFormat || DEFAULT_DATE_FORMAT, groupCountMap diff --git a/web/sdk/client/views/billing/components/payment-issue.tsx b/web/sdk/client/views/billing/components/payment-issue.tsx index d16326591..93dfc1e3d 100644 --- a/web/sdk/client/views/billing/components/payment-issue.tsx +++ b/web/sdk/client/views/billing/components/payment-issue.tsx @@ -1,39 +1,81 @@ 'use client'; -import { useCallback } from 'react'; -import { Button, Text, Flex } from '@raystack/apsara'; +import { useCallback, useEffect } from 'react'; +import { Button, Text, Flex, toastManager } from '@raystack/apsara'; import { ExclamationTriangleIcon } from '@radix-ui/react-icons'; +import { + Subscription, + RQLRequestSchema, + RQLFilterSchema, + RQLSortSchema +} from '@raystack/proton/frontier'; +import { create } from '@bufbuild/protobuf'; import { INVOICE_STATES, SUBSCRIPTION_STATES } from '../../../utils/constants'; -import { Subscription, Invoice } from '@raystack/proton/frontier'; -import { timestampToDayjs } from '../../../../utils/timestamp'; +import { DEFAULT_PAGE_SIZE } from '../../../utils/connect-pagination'; +import { useOrganizationInvoices } from '../../../hooks/useOrganizationInvoices'; import styles from '../billing-view.module.css'; +// Open invoices with a non-zero amount, newest first — the invoice that needs +// payment when a subscription is past due. +const OPEN_INVOICES_QUERY = create(RQLRequestSchema, { + filters: [ + create(RQLFilterSchema, { + name: 'state', + operator: 'eq', + value: { case: 'stringValue', value: INVOICE_STATES.OPEN } + }), + create(RQLFilterSchema, { + name: 'amount', + operator: 'gt', + value: { case: 'numberValue', value: 0 } + }) + ], + sort: [create(RQLSortSchema, { name: 'created_at', order: 'desc' })], + offset: 0, + limit: DEFAULT_PAGE_SIZE +}); + interface PaymentIssueProps { isLoading?: boolean; subscription?: Subscription; - invoices: Invoice[]; + hasAccess?: boolean; } export function PaymentIssue({ isLoading, subscription, - invoices + hasAccess }: PaymentIssueProps) { const isPastDue = subscription?.state === SUBSCRIPTION_STATES.PAST_DUE; - const openInvoices = invoices - .filter(inv => inv.state === INVOICE_STATES.OPEN) - .sort((a, b) => { - const dateA = timestampToDayjs(a.dueDate); - const dateB = timestampToDayjs(b.dueDate); - if (!dateA || !dateB) return 0; - return dateA.isAfter(dateB) ? -1 : 1; - }); + + const { + invoices, + isLoading: isInvoicesLoading, + error: invoicesError + } = useOrganizationInvoices({ + query: OPEN_INVOICES_QUERY, + enabled: hasAccess + }); + + useEffect(() => { + if (invoicesError) { + toastManager.add({ + title: 'Failed to load invoices', + description: invoicesError?.message, + type: 'error' + }); + } + }, [invoicesError]); + + const openInvoices = invoices.filter( + inv => inv.state === INVOICE_STATES.OPEN + ); const onRetryPayment = useCallback(() => { - window.location.href = openInvoices[0]?.hostedUrl || ''; + window.location.href = openInvoices[0]?.invoiceLink || ''; }, [openInvoices]); - if (isLoading || !isPastDue) return null; + if (isLoading || isInvoicesLoading || !isPastDue) return null; return ( diff --git a/web/sdk/client/views/tokens/tokens-view.tsx b/web/sdk/client/views/tokens/tokens-view.tsx index 452ceb78f..b5b768895 100644 --- a/web/sdk/client/views/tokens/tokens-view.tsx +++ b/web/sdk/client/views/tokens/tokens-view.tsx @@ -23,7 +23,7 @@ import { } from '@radix-ui/react-icons'; import { useInfiniteQuery } from '@connectrpc/connect-query'; import { FrontierServiceQueries } from '@raystack/proton/frontier'; -import { useDebounceValue } from 'usehooks-ts'; +import { useDebouncedValue } from '~hooks'; import { useFrontier } from '~/client/contexts/FrontierContext'; import { useBillingPermission } from '~/client/hooks/useBillingPermission'; import { useTokens } from '~/client/hooks/useTokens'; @@ -94,7 +94,7 @@ export function TokensView() { [tableQuery] ); - const [query] = useDebounceValue(computedQuery, 200); + const query = useDebouncedValue(computedQuery, 200); const { data: infiniteData, diff --git a/web/sdk/hooks/index.ts b/web/sdk/hooks/index.ts index 8006486f0..9efc91315 100644 --- a/web/sdk/hooks/index.ts +++ b/web/sdk/hooks/index.ts @@ -19,3 +19,5 @@ export { useQueryClient } from '@tanstack/react-query'; // Re-export protobuf utilities export { create } from '@bufbuild/protobuf'; + +export { useDebouncedValue } from './useDebouncedValue'; diff --git a/web/sdk/hooks/useDebouncedValue.ts b/web/sdk/hooks/useDebouncedValue.ts new file mode 100644 index 000000000..dd25f3ffb --- /dev/null +++ b/web/sdk/hooks/useDebouncedValue.ts @@ -0,0 +1,25 @@ +import { useEffect, useState } from 'react'; + +/** + * Returns a debounced copy of `value` that only updates after `delay` + * milliseconds have elapsed without `value` changing. + * + * Unlike a debounced *setter* (which delays writes to state), this debounces + * the *value* itself, so it can wrap a derived/computed value (e.g. a memoized + * request query) while the source state stays instant. The debounce runs inside + * an effect (post-commit), which is safe under React's concurrent rendering. + * + * @example + * const computedQuery = useMemo(() => transform(tableQuery), [tableQuery]); + * const query = useDebouncedValue(computedQuery, 200); + */ +export function useDebouncedValue(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => setDebouncedValue(value), delay); + return () => clearTimeout(timer); + }, [value, delay]); + + return debouncedValue; +}