From 174dc67659f6aba00e31aebd570f6f2c68543ba7 Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Tue, 11 Aug 2026 20:33:43 +0530 Subject: [PATCH 01/28] feat(billing): add account balance overview with available vs reserved and runway Show the managed-wallet balance as Total = Available + Reserved, with a per-deployment segmented breakdown and a runway line (days remaining and lasts-until, or an Auto Recharge reassurance). Values come from existing client-side balance and lease data, so no API change is needed. Split the former AccountOverview into AddToBalanceButton and AutoTopUpSection, and share the per-block spend math with the home dashboard via getLeasesCostPerBlockUsd. Part of CON-733. --- .../AccountBalanceOverview.spec.tsx | 98 +++++ .../AccountBalanceOverview.tsx | 108 ++++++ .../BalanceBreakdownBar.spec.tsx | 61 +++ .../BalanceBreakdownBar.tsx | 53 +++ .../useAccountBalanceOverview.spec.ts | 100 +++++ .../useAccountBalanceOverview.ts | 94 +++++ .../AccountOverview/AccountOverview.spec.tsx | 353 ------------------ .../AccountOverview/AccountOverview.tsx | 326 ---------------- .../AddToBalanceButton.spec.tsx | 104 ++++++ .../AddToBalanceButton/AddToBalanceButton.tsx | 70 ++++ .../AutoTopUpSection.spec.tsx | 190 ++++++++++ .../AutoTopUpSection/AutoTopUpSection.tsx | 206 ++++++++++ .../components/billing-usage/BillingPage.tsx | 20 +- .../home/YourAccount/YourAccount.tsx | 20 +- apps/deploy-web/src/utils/priceUtils.ts | 20 + 15 files changed, 1123 insertions(+), 700 deletions(-) create mode 100644 apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/AccountBalanceOverview.spec.tsx create mode 100644 apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/AccountBalanceOverview.tsx create mode 100644 apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/BalanceBreakdownBar.spec.tsx create mode 100644 apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/BalanceBreakdownBar.tsx create mode 100644 apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/useAccountBalanceOverview.spec.ts create mode 100644 apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/useAccountBalanceOverview.ts delete mode 100644 apps/deploy-web/src/components/billing-usage/AccountOverview/AccountOverview.spec.tsx delete mode 100644 apps/deploy-web/src/components/billing-usage/AccountOverview/AccountOverview.tsx create mode 100644 apps/deploy-web/src/components/billing-usage/AddToBalanceButton/AddToBalanceButton.spec.tsx create mode 100644 apps/deploy-web/src/components/billing-usage/AddToBalanceButton/AddToBalanceButton.tsx create mode 100644 apps/deploy-web/src/components/billing-usage/AutoTopUpSection/AutoTopUpSection.spec.tsx create mode 100644 apps/deploy-web/src/components/billing-usage/AutoTopUpSection/AutoTopUpSection.tsx diff --git a/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/AccountBalanceOverview.spec.tsx b/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/AccountBalanceOverview.spec.tsx new file mode 100644 index 0000000000..cf96aed4fa --- /dev/null +++ b/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/AccountBalanceOverview.spec.tsx @@ -0,0 +1,98 @@ +import { describe, expect, it, vi } from "vitest"; + +import { AccountBalanceOverview, DEPENDENCIES } from "./AccountBalanceOverview"; +import type { AccountBalanceOverview as AccountBalanceOverviewData } from "./useAccountBalanceOverview"; + +import { render, screen } from "@testing-library/react"; +import { MockComponents } from "@tests/unit/mocks"; + +describe(AccountBalanceOverview.name, () => { + it("renders the total account balance", () => { + setup({ totalUsd: 3211.2 }); + + expect(screen.getByLabelText("Total account balance")).toHaveTextContent("3211.2"); + }); + + it("shows the runway badge and lasts-until date while spending", () => { + setup({ runwayDays: 12, perHour: 11.15, lastsUntil: new Date(2026, 7, 23) }); + + expect(screen.getByText("12 days of runway")).toBeInTheDocument(); + expect(screen.getByText(/lasts until/)).toHaveTextContent("Aug 23"); + }); + + it("hides the runway indicator when nothing is being spent", () => { + setup({ runwayDays: null, lastsUntil: null }); + + expect(screen.queryByText(/of runway/)).not.toBeInTheDocument(); + expect(screen.queryByText(/lasts until/)).not.toBeInTheDocument(); + }); + + it("explains reserved funds with the running deployment count", () => { + setup({ reserved: 1338, available: 1873.2, activeDeploymentCount: 7 }); + + expect(screen.getByText(/is reserved to keep your 7 running deployments online/)).toBeInTheDocument(); + expect(screen.getByText(/is available for new deployments/)).toBeInTheDocument(); + }); + + it("omits the reserved sentence when nothing is reserved", () => { + setup({ reserved: 0, available: 500 }); + + expect(screen.queryByText(/is reserved to keep/)).not.toBeInTheDocument(); + expect(screen.getByText(/is available for new deployments/)).toBeInTheDocument(); + }); + + it("lists each deployment and the available balance in the legend", () => { + setup({ deployments: [{ dseq: "1", name: "llama-chat", reservedUsd: 508.8 }], available: 1873.2 }); + + expect(screen.getByText("llama-chat")).toBeInTheDocument(); + expect(screen.getByText("Available")).toBeInTheDocument(); + }); + + it("reassures when auto recharge is on", () => { + setup({ autoReloadEnabled: true }); + + expect(screen.getByText(/Auto Recharge is on/)).toBeInTheDocument(); + }); + + it("stays quiet about auto recharge when it is off", () => { + setup({ autoReloadEnabled: false }); + + expect(screen.queryByText(/Auto Recharge is on/)).not.toBeInTheDocument(); + }); + + it("renders a skeleton instead of balance while loading", () => { + setup({ isLoading: true }); + + expect(screen.queryByLabelText("Total account balance")).not.toBeInTheDocument(); + }); + + function setup(overview: Partial) { + const data: AccountBalanceOverviewData = { + totalUsd: 0, + reserved: 0, + available: 0, + deployments: [], + activeDeploymentCount: 0, + perHour: 0, + perMonth: 0, + lastsUntil: null, + runwayDays: null, + autoReloadEnabled: false, + isLoading: false, + ...overview + }; + const MockFormattedNumber = vi.fn(({ value }: { value: number }) => <>{value}); + + return render( + data, + FormattedNumber: MockFormattedNumber + } as unknown as typeof DEPENDENCIES + } + /> + ); + } +}); diff --git a/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/AccountBalanceOverview.tsx b/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/AccountBalanceOverview.tsx new file mode 100644 index 0000000000..9f8ecb5704 --- /dev/null +++ b/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/AccountBalanceOverview.tsx @@ -0,0 +1,108 @@ +"use client"; +import React from "react"; +import { FormattedNumber } from "react-intl"; +import { Card, CardContent, CardHeader, CustomTooltip, Skeleton } from "@akashnetwork/ui/components"; +import format from "date-fns/format"; +import { InfoCircle, Wallet } from "iconoir-react"; + +import { BalanceBreakdownBar, buildBalanceSegments } from "./BalanceBreakdownBar"; +import { useAccountBalanceOverview } from "./useAccountBalanceOverview"; + +export const DEPENDENCIES = { + useAccountBalanceOverview, + Card, + CardContent, + CardHeader, + CustomTooltip, + Skeleton, + FormattedNumber, + BalanceBreakdownBar, + Wallet, + InfoCircle +}; + +export const AccountBalanceOverview: React.FunctionComponent<{ dependencies?: typeof DEPENDENCIES }> = ({ dependencies: d = DEPENDENCIES }) => { + const overview = d.useAccountBalanceOverview(); + const usd = (value: number) => ; + + if (overview.isLoading) { + return ( + + + + + + + + + + + + ); + } + + const segments = buildBalanceSegments(overview.deployments, overview.available); + const hasRunway = overview.runwayDays !== null && overview.lastsUntil !== null; + + return ( + + +

Account balance

+ +
+ +
+
+ + {usd(overview.totalUsd)} + + {hasRunway && ( + {`${overview.runwayDays} days of runway`} + )} + + {usd(overview.available)} + {" available"} + +
+ {hasRunway && ( +

+ Spending {usd(overview.perHour)}/hr · lasts until {format(overview.lastsUntil!, "MMM d")} +

+ )} +
+ + + + {segments.length > 0 && ( +
    + {segments.map(segment => ( +
  • + + + {segment.label} + + {usd(segment.amountUsd)} +
  • + ))} +
+ )} + +

+ {overview.reserved > 0 && ( + <> + {usd(overview.reserved)} + {` is reserved to keep your ${overview.activeDeploymentCount} running deployment${overview.activeDeploymentCount === 1 ? "" : "s"} online. `} + + )} + {usd(overview.available)} + {" is available for new deployments."} + + + +

+ + {overview.autoReloadEnabled &&

Auto Recharge is on — your deployments stay funded.

} +
+
+ ); +}; diff --git a/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/BalanceBreakdownBar.spec.tsx b/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/BalanceBreakdownBar.spec.tsx new file mode 100644 index 0000000000..5308d7e0f8 --- /dev/null +++ b/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/BalanceBreakdownBar.spec.tsx @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; + +import { BalanceBreakdownBar, buildBalanceSegments } from "./BalanceBreakdownBar"; +import type { ReservedDeployment } from "./useAccountBalanceOverview"; + +import { render, screen } from "@testing-library/react"; + +describe(buildBalanceSegments.name, () => { + it("orders reserved deployments before the available segment", () => { + const segments = buildBalanceSegments(deployments([100, 50]), 200); + + expect(segments.map(s => s.key)).toEqual(["dseq-0", "dseq-1", "available"]); + expect(segments.map(s => s.amountUsd)).toEqual([100, 50, 200]); + }); + + it("colors the available segment with the success token", () => { + const [available] = buildBalanceSegments([], 200); + + expect(available.key).toBe("available"); + expect(available.color).toBe("hsl(var(--success))"); + }); + + it("gives the largest deployment the most opaque ramp step", () => { + const segments = buildBalanceSegments(deployments([100, 50]), 0); + + expect(segments[0].color).toContain("0.9"); + expect(segments[1].color).not.toBe(segments[0].color); + }); + + it("drops zero-value segments", () => { + expect(buildBalanceSegments(deployments([0]), 0)).toEqual([]); + }); + + function deployments(amounts: number[]): ReservedDeployment[] { + return amounts.map((reservedUsd, index) => ({ dseq: `dseq-${index}`, name: `deployment-${index}`, reservedUsd })); + } +}); + +describe(BalanceBreakdownBar.name, () => { + it("renders one element per segment sized by flex-grow", () => { + setup([ + { key: "d1", label: "llama", amountUsd: 100, color: "hsl(var(--primary) / 0.9)" }, + { key: "available", label: "Available", amountUsd: 300, color: "hsl(var(--success))" } + ]); + + const bar = screen.getByRole("img"); + expect(bar.children).toHaveLength(2); + expect((bar.children[0] as HTMLElement).style.flexGrow).toBe("100"); + expect((bar.children[1] as HTMLElement).style.flexGrow).toBe("300"); + }); + + it("summarizes every segment in the aria-label", () => { + setup([{ key: "d1", label: "llama", amountUsd: 100, color: "hsl(var(--primary))" }]); + + expect(screen.getByRole("img").getAttribute("aria-label")).toContain("llama"); + }); + + function setup(segments: Parameters[0]["segments"]) { + return render(); + } +}); diff --git a/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/BalanceBreakdownBar.tsx b/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/BalanceBreakdownBar.tsx new file mode 100644 index 0000000000..eeee7174b9 --- /dev/null +++ b/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/BalanceBreakdownBar.tsx @@ -0,0 +1,53 @@ +"use client"; +import React from "react"; + +import type { ReservedDeployment } from "./useAccountBalanceOverview"; + +export type BalanceSegment = { + key: string; + label: string; + amountUsd: number; + color: string; +}; + +const usdFormatter = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }); + +/** Stepped opacity for the reserved ramp: largest deployment is the most opaque, tapering to 0.35. */ +function reservedAlpha(index: number, count: number): number { + if (count <= 1) return 0.9; + return Number((0.9 - (index / (count - 1)) * 0.55).toFixed(3)); +} + +/** + * Builds the ordered segments for the balance bar and its legend so both share identical colors. + * Reserved deployments use a single-hue ramp (sorted largest-first); Available is the success green. + */ +export function buildBalanceSegments(deployments: ReservedDeployment[], available: number): BalanceSegment[] { + const reservedSegments = deployments.map((deployment, index) => ({ + key: deployment.dseq, + label: deployment.name, + amountUsd: deployment.reservedUsd, + color: `hsl(var(--primary) / ${reservedAlpha(index, deployments.length)})` + })); + + return [...reservedSegments, { key: "available", label: "Available", amountUsd: available, color: "hsl(var(--success))" }].filter( + segment => segment.amountUsd > 0 + ); +} + +export const BalanceBreakdownBar: React.FunctionComponent<{ segments: BalanceSegment[] }> = ({ segments }) => { + const label = segments.map(segment => `${segment.label} ${usdFormatter.format(segment.amountUsd)}`).join(", "); + + return ( +
+ {segments.map(segment => ( +
+ ))} +
+ ); +}; diff --git a/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/useAccountBalanceOverview.spec.ts b/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/useAccountBalanceOverview.spec.ts new file mode 100644 index 0000000000..75166a0a30 --- /dev/null +++ b/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/useAccountBalanceOverview.spec.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "vitest"; + +import { UAKT_DENOM } from "@src/config/denom.config"; +import { DEPENDENCIES, useAccountBalanceOverview } from "./useAccountBalanceOverview"; + +import { renderHook } from "@testing-library/react"; + +describe(useAccountBalanceOverview.name, () => { + it("splits total into available and reserved", () => { + const { result } = setup({ totalUsd: 500, reservedUsd: 150 }); + + expect(result.current.totalUsd).toBe(500); + expect(result.current.reserved).toBe(150); + expect(result.current.available).toBe(350); + }); + + it("never returns negative available", () => { + const { result } = setup({ totalUsd: 100, reservedUsd: 150 }); + + expect(result.current.available).toBe(0); + }); + + it("builds a per-deployment reserved breakdown sorted largest-first", () => { + const { result } = setup({ + deployments: [ + { dseq: "1", fundsUsd: 50 }, + { dseq: "2", fundsUsd: 100 } + ], + names: { "2": "llama-chat" } + }); + + expect(result.current.deployments).toEqual([ + { dseq: "2", name: "llama-chat", reservedUsd: 100 }, + { dseq: "1", name: "Deployment 1", reservedUsd: 50 } + ]); + expect(result.current.activeDeploymentCount).toBe(2); + }); + + it("reports runway when deployments are actively spending", () => { + const { result } = setup({ totalUsd: 500, hasLiveLease: true }); + + expect(result.current.runwayDays).toEqual(expect.any(Number)); + expect(result.current.runwayDays).toBeGreaterThanOrEqual(0); + expect(result.current.lastsUntil).toBeInstanceOf(Date); + }); + + it("has no runway when nothing is being spent", () => { + const { result } = setup({ totalUsd: 500, hasLiveLease: false }); + + expect(result.current.runwayDays).toBeNull(); + expect(result.current.lastsUntil).toBeNull(); + }); + + it("passes through the auto reload setting", () => { + const { result } = setup({ autoReloadEnabled: true }); + + expect(result.current.autoReloadEnabled).toBe(true); + }); + + it("is loading until the wallet balance resolves", () => { + const { result } = setup({ walletBalanceMissing: true }); + + expect(result.current.isLoading).toBe(true); + }); + + function setup(input: { + totalUsd?: number; + reservedUsd?: number; + deployments?: Array<{ dseq: string; fundsUsd: number }>; + names?: Record; + hasLiveLease?: boolean; + autoReloadEnabled?: boolean; + walletBalanceMissing?: boolean; + }) { + const activeDeployments = (input.deployments ?? []).map(deployment => ({ + dseq: deployment.dseq, + escrowAccount: { state: { funds: [{ denom: UAKT_DENOM, amount: String(deployment.fundsUsd) }] } } + })); + + const leases = input.hasLiveLease ? [{ state: "active", price: { denom: UAKT_DENOM, amount: "1000000" } }] : []; + + const dependencies = { + ...DEPENDENCIES, + useWallet: () => ({ address: "akash1abc" }), + usePricing: () => ({ price: 1, isLoaded: true, udenomToUsd: (amount: string | number) => Number(amount) }), + useUsdcDenom: () => "ibc/usdc", + useWalletBalance: () => ({ + balance: input.walletBalanceMissing ? null : { totalUsd: input.totalUsd ?? 0, totalDeploymentEscrowUSD: input.reservedUsd ?? 0 }, + isLoading: false, + refetch: () => undefined + }), + useBalances: () => ({ data: { activeDeployments } }), + useAllLeases: () => ({ data: leases }), + useWalletSettingsQuery: () => ({ data: { autoReloadEnabled: input.autoReloadEnabled ?? false } }), + useLocalNotes: () => ({ getDeploymentName: (dseq: string | number | null) => input.names?.[String(dseq)] ?? null }) + } as unknown as typeof DEPENDENCIES; + + return renderHook(() => useAccountBalanceOverview({ dependencies })); + } +}); diff --git a/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/useAccountBalanceOverview.ts b/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/useAccountBalanceOverview.ts new file mode 100644 index 0000000000..bc74ab3576 --- /dev/null +++ b/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/useAccountBalanceOverview.ts @@ -0,0 +1,94 @@ +"use client"; +import { useMemo } from "react"; + +import { useLocalNotes } from "@src/components/LocalNoteManager/useLocalNotes"; +import { useWallet } from "@src/context/WalletProvider"; +import { useUsdcDenom } from "@src/hooks/useDenom"; +import { usePricing } from "@src/hooks/usePricing/usePricing"; +import { useWalletBalance } from "@src/hooks/useWalletBalance"; +import { useWalletSettingsQuery } from "@src/queries"; +import { useBalances } from "@src/queries/useBalancesQuery"; +import { useAllLeases } from "@src/queries/useLeaseQuery"; +import { getAvgCostPerMonth, getLeasesCostPerBlockUsd, getTimeLeft, perBlockToHourly } from "@src/utils/priceUtils"; +import { isLeaseLive } from "@src/utils/reclamationUtils"; + +export const DEPENDENCIES = { + useWallet, + usePricing, + useUsdcDenom, + useWalletBalance, + useBalances, + useAllLeases, + useWalletSettingsQuery, + useLocalNotes +}; + +export type ReservedDeployment = { + dseq: string; + name: string; + reservedUsd: number; +}; + +export type AccountBalanceOverview = { + totalUsd: number; + reserved: number; + available: number; + deployments: ReservedDeployment[]; + activeDeploymentCount: number; + perHour: number; + perMonth: number; + /** null when nothing is being spent (runway is effectively infinite). */ + lastsUntil: Date | null; + runwayDays: number | null; + autoReloadEnabled: boolean; + isLoading: boolean; +}; + +const HOURS_PER_DAY = 24; + +export function useAccountBalanceOverview({ dependencies: d = DEPENDENCIES }: { dependencies?: typeof DEPENDENCIES } = {}): AccountBalanceOverview { + const { address } = d.useWallet(); + const { price, isLoaded: isPriceLoaded, udenomToUsd } = d.usePricing(); + const usdcDenom = d.useUsdcDenom(); + const { balance: walletBalance, isLoading: isWalletBalanceLoading } = d.useWalletBalance(); + const { data: balances } = d.useBalances(address); + const { data: leases } = d.useAllLeases(address); + const { data: walletSettings } = d.useWalletSettingsQuery(); + const { getDeploymentName } = d.useLocalNotes(); + + const deployments = useMemo(() => { + if (!balances) return []; + return balances.activeDeployments + .map(deployment => ({ + dseq: deployment.dseq, + name: getDeploymentName(deployment.dseq) ?? `Deployment ${deployment.dseq}`, + reservedUsd: deployment.escrowAccount.state.funds.reduce((sum, fund) => sum + udenomToUsd(fund.amount, fund.denom), 0) + })) + .sort((a, b) => b.reservedUsd - a.reservedUsd); + }, [balances, getDeploymentName, udenomToUsd]); + + const spend = useMemo(() => { + if (!leases || !isPriceLoaded) return { perBlockUsd: 0, perHour: 0, perMonth: 0 }; + const perBlockUsd = getLeasesCostPerBlockUsd(leases.filter(isLeaseLive), price ?? 0, usdcDenom); + return { perBlockUsd, perHour: perBlockToHourly(perBlockUsd), perMonth: getAvgCostPerMonth(perBlockUsd) }; + }, [leases, isPriceLoaded, price, usdcDenom]); + + const totalUsd = walletBalance?.totalUsd ?? 0; + const reserved = walletBalance?.totalDeploymentEscrowUSD ?? 0; + const available = Math.max(0, totalUsd - reserved); + const hasSpend = spend.perBlockUsd > 0; + + return { + totalUsd, + reserved, + available, + deployments, + activeDeploymentCount: balances?.activeDeployments.length ?? deployments.length, + perHour: spend.perHour, + perMonth: spend.perMonth, + lastsUntil: hasSpend ? getTimeLeft(spend.perBlockUsd, totalUsd) : null, + runwayDays: hasSpend ? Math.floor(totalUsd / spend.perHour / HOURS_PER_DAY) : null, + autoReloadEnabled: walletSettings?.autoReloadEnabled ?? false, + isLoading: isWalletBalanceLoading || !walletBalance + }; +} diff --git a/apps/deploy-web/src/components/billing-usage/AccountOverview/AccountOverview.spec.tsx b/apps/deploy-web/src/components/billing-usage/AccountOverview/AccountOverview.spec.tsx deleted file mode 100644 index 735ace4563..0000000000 --- a/apps/deploy-web/src/components/billing-usage/AccountOverview/AccountOverview.spec.tsx +++ /dev/null @@ -1,353 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -import { DEPENDENCIES } from "./AccountOverview"; -import { AccountOverview } from "./AccountOverview"; - -import { act, fireEvent, render, screen } from "@testing-library/react"; -import { MockComponents } from "@tests/unit/mocks"; - -describe(AccountOverview.name, () => { - it("opens the add credits sheet when openPayment query param is true", () => { - const mockReplace = vi.fn(); - const { dependencies } = setup({ - searchParamsGet: (key: string) => (key === "openPayment" ? "true" : null), - routerReplace: mockReplace, - defaultPaymentMethod: { id: "pm_123" }, - isLoading: false - }); - - expect(mockReplace).toHaveBeenCalledWith("/billing", { scroll: false }); - expect(screen.getByTestId("add-credits-sheet")).toBeInTheDocument(); - expect(dependencies.AddCreditsSheet).toHaveBeenCalledWith(expect.objectContaining({ open: true, initialTab: "purchase" }), expect.anything()); - }); - - it("does not open the add credits sheet when openPayment query param is absent", () => { - const mockReplace = vi.fn(); - setup({ - searchParamsGet: () => null, - routerReplace: mockReplace, - defaultPaymentMethod: { id: "pm_123" }, - isLoading: false - }); - - expect(mockReplace).not.toHaveBeenCalled(); - expect(screen.queryByTestId("add-credits-sheet")).not.toBeInTheDocument(); - }); - - it("opens the add credits sheet when openPayment query param is true even without default payment method", () => { - const mockReplace = vi.fn(); - setup({ - searchParamsGet: (key: string) => (key === "openPayment" ? "true" : null), - routerReplace: mockReplace, - defaultPaymentMethod: undefined, - isLoading: false - }); - - expect(mockReplace).toHaveBeenCalledWith("/billing", { scroll: false }); - expect(screen.getByTestId("add-credits-sheet")).toBeInTheDocument(); - }); - - it("does not open the add credits sheet while still loading", () => { - const mockReplace = vi.fn(); - setup({ - searchParamsGet: (key: string) => (key === "openPayment" ? "true" : null), - routerReplace: mockReplace, - defaultPaymentMethod: { id: "pm_123" }, - isLoading: true - }); - - expect(mockReplace).not.toHaveBeenCalled(); - }); - - it("opens the add credits sheet from the Add Funds button even without a default payment method", () => { - setup({ - searchParamsGet: () => null, - defaultPaymentMethod: undefined, - isLoading: false - }); - - const addFundsButton = screen.getByRole("button", { name: /add funds/i }); - expect(addFundsButton).toBeEnabled(); - - fireEvent.click(addFundsButton); - - expect(screen.getByTestId("add-credits-sheet")).toBeInTheDocument(); - }); - - it("shows the payment success animation and closes the sheet when a purchase completes", () => { - const { dependencies, MockAddCreditsSheet } = setup({ - searchParamsGet: (key: string) => (key === "openPayment" ? "true" : null), - defaultPaymentMethod: { id: "pm_123" }, - isLoading: false - }); - - act(() => MockAddCreditsSheet.mock.calls.at(-1)![0].onDone(100)); - - expect(dependencies.PaymentSuccessAnimation).toHaveBeenCalledWith(expect.objectContaining({ show: true, amount: "100" }), expect.anything()); - expect(MockAddCreditsSheet).toHaveBeenCalledWith(expect.objectContaining({ open: false }), expect.anything()); - }); - - it("forwards the granted first-purchase bonus to the payment success animation", () => { - const { dependencies, MockAddCreditsSheet } = setup({ - searchParamsGet: (key: string) => (key === "openPayment" ? "true" : null), - defaultPaymentMethod: { id: "pm_123" }, - isLoading: false - }); - - act(() => MockAddCreditsSheet.mock.calls.at(-1)![0].onDone(100, undefined, 10)); - - expect(dependencies.PaymentSuccessAnimation).toHaveBeenCalledWith( - expect.objectContaining({ show: true, amount: "100", bonusAmount: "10" }), - expect.anything() - ); - }); - - describe("when the fixed-threshold flag is enabled", () => { - it("renders the Auto Top-Up title", () => { - setup({ isFixedThresholdEnabled: true, defaultPaymentMethod: { id: "pm_123" }, isLoading: false }); - - expect(screen.getByText("Auto Top-Up")).toBeInTheDocument(); - }); - - it("shows the threshold summary with stored values when enabled", () => { - setup({ - isFixedThresholdEnabled: true, - defaultPaymentMethod: { id: "pm_123" }, - isLoading: false, - walletSettings: { autoReloadEnabled: true, autoReloadThreshold: 20, autoReloadAmount: 100 } - }); - - expect(screen.getByText(/Top up/)).toHaveTextContent("Top up 100 when balance ≤ 20"); - expect(screen.queryByText(/per week/)).not.toBeInTheDocument(); - }); - - it("shows 'Add funds automatically' when disabled with a payment method", () => { - setup({ - isFixedThresholdEnabled: true, - defaultPaymentMethod: { id: "pm_123" }, - isLoading: false, - walletSettings: { autoReloadEnabled: false } - }); - - expect(screen.getByText("Add funds automatically")).toBeInTheDocument(); - }); - - it("opens the settings dialog in enable mode without mutating when the switch is turned on", () => { - const { dependencies, upsertMutate } = setup({ - isFixedThresholdEnabled: true, - defaultPaymentMethod: { id: "pm_123" }, - isLoading: false, - walletSettings: { autoReloadEnabled: false } - }); - - fireEvent.click(screen.getByRole("switch")); - - expect(upsertMutate).not.toHaveBeenCalled(); - expect(dependencies.AutoTopUpSettingsPopup).toHaveBeenCalledWith(expect.objectContaining({ open: true, enableOnSave: true }), expect.anything()); - }); - - it("confirms then disables auto top-up when the switch is turned off", async () => { - const upsertMutate = vi.fn(); - setup({ - isFixedThresholdEnabled: true, - defaultPaymentMethod: { id: "pm_123" }, - isLoading: false, - walletSettings: { autoReloadEnabled: true, autoReloadThreshold: 20, autoReloadAmount: 100 }, - confirmResult: true, - upsertMutate - }); - - fireEvent.click(screen.getByRole("switch")); - - await vi.waitFor(() => { - expect(upsertMutate).toHaveBeenCalledWith(expect.objectContaining({ data: { autoReloadEnabled: false } }), expect.anything()); - }); - }); - - it("opens the settings dialog in edit mode from the edit button", () => { - const { dependencies } = setup({ - isFixedThresholdEnabled: true, - defaultPaymentMethod: { id: "pm_123" }, - isLoading: false, - walletSettings: { autoReloadEnabled: true, autoReloadThreshold: 20, autoReloadAmount: 100 } - }); - - fireEvent.click(screen.getByRole("button", { name: /edit auto top-up settings/i })); - - expect(dependencies.AutoTopUpSettingsPopup).toHaveBeenCalledWith(expect.objectContaining({ open: true, enableOnSave: false }), expect.anything()); - }); - - it("disables the switch and hides the edit button without a payment method", () => { - setup({ isFixedThresholdEnabled: true, defaultPaymentMethod: undefined, isLoading: false }); - - expect(screen.getByRole("switch")).toBeDisabled(); - expect(screen.queryByRole("button", { name: /edit auto top-up settings/i })).not.toBeInTheDocument(); - expect(screen.getByText(/Add a payment method/)).toBeInTheDocument(); - }); - - it("hides the edit button while auto top-up is off", () => { - setup({ - isFixedThresholdEnabled: true, - defaultPaymentMethod: { id: "pm_123" }, - isLoading: false, - walletSettings: { autoReloadEnabled: false } - }); - - expect(screen.queryByRole("button", { name: /edit auto top-up settings/i })).not.toBeInTheDocument(); - }); - - it("disables the edit button while a settings update is in flight", () => { - setup({ - isFixedThresholdEnabled: true, - defaultPaymentMethod: { id: "pm_123" }, - isLoading: false, - walletSettings: { autoReloadEnabled: true, autoReloadThreshold: 20, autoReloadAmount: 100 }, - isPending: true - }); - - expect(screen.getByRole("button", { name: /edit auto top-up settings/i })).toBeDisabled(); - }); - - it("does not disable auto top-up when the confirmation is cancelled", async () => { - const upsertMutate = vi.fn(); - setup({ - isFixedThresholdEnabled: true, - defaultPaymentMethod: { id: "pm_123" }, - isLoading: false, - walletSettings: { autoReloadEnabled: true, autoReloadThreshold: 20, autoReloadAmount: 100 }, - confirmResult: false, - upsertMutate - }); - - await act(async () => { - fireEvent.click(screen.getByRole("switch")); - }); - - expect(upsertMutate).not.toHaveBeenCalled(); - }); - - it("shows a success snackbar when disabling auto top-up succeeds", async () => { - const enqueueSnackbar = vi.fn(); - const upsertMutate = vi.fn((_payload, options) => options?.onSuccess?.({ data: { autoReloadEnabled: false } })); - setup({ - isFixedThresholdEnabled: true, - defaultPaymentMethod: { id: "pm_123" }, - isLoading: false, - walletSettings: { autoReloadEnabled: true, autoReloadThreshold: 20, autoReloadAmount: 100 }, - confirmResult: true, - upsertMutate, - enqueueSnackbar - }); - - await act(async () => { - fireEvent.click(screen.getByRole("switch")); - }); - - expect(enqueueSnackbar).toHaveBeenCalled(); - }); - - it("shows an error snackbar when disabling auto top-up fails", async () => { - const enqueueSnackbar = vi.fn(); - const upsertMutate = vi.fn((_payload, options) => options?.onError?.(new Error("failed"))); - setup({ - isFixedThresholdEnabled: true, - defaultPaymentMethod: { id: "pm_123" }, - isLoading: false, - walletSettings: { autoReloadEnabled: true, autoReloadThreshold: 20, autoReloadAmount: 100 }, - confirmResult: true, - upsertMutate, - enqueueSnackbar - }); - - await act(async () => { - fireEvent.click(screen.getByRole("switch")); - }); - - expect(enqueueSnackbar).toHaveBeenCalled(); - }); - - it("closes the settings dialog when the popup requests it", () => { - const { dependencies } = setup({ - isFixedThresholdEnabled: true, - defaultPaymentMethod: { id: "pm_123" }, - isLoading: false, - walletSettings: { autoReloadEnabled: true, autoReloadThreshold: 20, autoReloadAmount: 100 } - }); - - fireEvent.click(screen.getByRole("button", { name: /edit auto top-up settings/i })); - const openedProps = vi.mocked(dependencies.AutoTopUpSettingsPopup).mock.calls.at(-1)![0]; - expect(openedProps.open).toBe(true); - - act(() => openedProps.onClose()); - - expect(dependencies.AutoTopUpSettingsPopup).toHaveBeenLastCalledWith(expect.objectContaining({ open: false }), expect.anything()); - }); - }); - - function setup(input: { - searchParamsGet?: (key: string) => string | null; - routerReplace?: ReturnType; - defaultPaymentMethod?: { id: string }; - isLoading?: boolean; - isFixedThresholdEnabled?: boolean; - walletSettings?: { autoReloadEnabled: boolean; autoReloadThreshold?: number; autoReloadAmount?: number }; - confirmResult?: boolean; - upsertMutate?: ReturnType; - enqueueSnackbar?: ReturnType; - isPending?: boolean; - }) { - const mockReplace = input.routerReplace ?? vi.fn(); - const mockSearchParams = { get: vi.fn(input.searchParamsGet ?? (() => null)) }; - const mockRouter = { replace: mockReplace, push: vi.fn() }; - const upsertMutate = input.upsertMutate ?? vi.fn(); - const enqueueSnackbar = input.enqueueSnackbar ?? vi.fn(); - - const MockAddCreditsSheet = vi.fn((props: Parameters[0]) => - props.open ?
: null - ); - - const MockButton = vi.fn(({ children, ...props }: Parameters[0]) => ); - - const MockSwitch = vi.fn(({ checked, onCheckedChange, disabled }: Parameters[0]) => ( - onCheckedChange?.(e.target.checked)} /> - )); - - const MockFormattedNumber = vi.fn(({ value }: Parameters[0]) => <>{value}); - - const dependencies = { - ...MockComponents(DEPENDENCIES), - useFlag: vi.fn(() => input.isFixedThresholdEnabled ?? false), - useSnackbar: vi.fn(() => ({ enqueueSnackbar })), - useDefaultPaymentMethodQuery: vi.fn(() => ({ - data: input.defaultPaymentMethod, - isLoading: input.isLoading ?? false - })), - useWalletBalance: vi.fn(() => ({ - balance: { totalDeploymentGrantsUSD: 100, totalDeploymentEscrowUSD: 10 }, - isLoading: false - })), - useWalletSettingsQuery: vi.fn(() => ({ data: input.walletSettings ?? { autoReloadEnabled: false } })), - useWeeklyDeploymentCostQuery: vi.fn(() => ({ data: 5 })), - useWalletSettingsMutations: vi.fn(() => ({ - upsertWalletSettings: { mutate: upsertMutate, isPending: input.isPending ?? false } - })), - usePopup: vi.fn(() => ({ confirm: vi.fn().mockResolvedValue(input.confirmResult ?? true) })), - useSearchParams: vi.fn(() => mockSearchParams), - useRouter: vi.fn(() => mockRouter), - useServices: vi.fn(() => ({ - urlService: { - billing: () => "/billing", - paymentMethods: () => "/payment-methods" - } - })), - Button: MockButton, - Switch: MockSwitch, - FormattedNumber: MockFormattedNumber, - AddCreditsSheet: MockAddCreditsSheet - } as unknown as typeof DEPENDENCIES; - - render(); - - return { dependencies, MockAddCreditsSheet, upsertMutate }; - } -}); diff --git a/apps/deploy-web/src/components/billing-usage/AccountOverview/AccountOverview.tsx b/apps/deploy-web/src/components/billing-usage/AccountOverview/AccountOverview.tsx deleted file mode 100644 index 9706210b0c..0000000000 --- a/apps/deploy-web/src/components/billing-usage/AccountOverview/AccountOverview.tsx +++ /dev/null @@ -1,326 +0,0 @@ -import React, { useCallback, useEffect, useMemo, useState } from "react"; -import { FormattedNumber } from "react-intl"; -import { Button, Card, CardContent, CardHeader, CustomTooltip, Skeleton, Snackbar, Switch } from "@akashnetwork/ui/components"; -import { usePopup } from "@akashnetwork/ui/context"; -import { LinearProgress } from "@mui/material"; -import { Edit, InfoCircle, Plus, Wallet } from "iconoir-react"; -import Link from "next/link"; -import { useRouter, useSearchParams } from "next/navigation"; -import { useSnackbar } from "notistack"; - -import { AddCreditsSheet } from "@src/components/auth/AddCreditsSheet/AddCreditsSheet"; -import { - AutoTopUpSettingsPopup, - DEFAULT_AUTO_RELOAD_AMOUNT, - DEFAULT_AUTO_RELOAD_THRESHOLD -} from "@src/components/billing-usage/AutoTopUpSettingsPopup/AutoTopUpSettingsPopup"; -import { PaymentSuccessAnimation } from "@src/components/billing-usage/PaymentSuccessAnimation/PaymentSuccessAnimation"; -import { Title } from "@src/components/shared/Title"; -import { useServices } from "@src/context/ServicesProvider/ServicesProvider"; -import { useFlag } from "@src/hooks/useFlag"; -import { useWalletBalance } from "@src/hooks/useWalletBalance"; -import { useDefaultPaymentMethodQuery, useWalletSettingsMutations, useWalletSettingsQuery, useWeeklyDeploymentCostQuery } from "@src/queries"; - -export const DEPENDENCIES = { - useSnackbar, - useDefaultPaymentMethodQuery, - useWalletBalance, - useWalletSettingsQuery, - useWeeklyDeploymentCostQuery, - useWalletSettingsMutations, - usePopup, - useSearchParams, - useRouter, - useServices, - useFlag, - AddCreditsSheet, - AutoTopUpSettingsPopup, - PaymentSuccessAnimation, - Title, - FormattedNumber, - Link, - Button, - Card, - CardContent, - CardHeader, - CustomTooltip, - Skeleton, - Snackbar, - Switch, - Edit, - LinearProgress -}; - -export const AccountOverview: React.FunctionComponent<{ dependencies?: typeof DEPENDENCIES }> = ({ dependencies: d = DEPENDENCIES }) => { - const [showAddCredits, setShowAddCredits] = useState(false); - const [autoTopUpPopup, setAutoTopUpPopup] = useState<{ open: boolean; enableOnSave: boolean }>({ open: false, enableOnSave: false }); - const [showPaymentSuccess, setShowPaymentSuccess] = useState<{ amount: string; bonusAmount: string; show: boolean }>({ - amount: "", - bonusAmount: "", - show: false - }); - const isFixedThresholdEnabled = d.useFlag("auto_reload_fixed_threshold"); - const { enqueueSnackbar } = d.useSnackbar(); - const { data: defaultPaymentMethod, isLoading: isLoadingDefaultPaymentMethod } = d.useDefaultPaymentMethodQuery(); - const { balance: walletBalance, isLoading: isWalletBalanceLoading } = d.useWalletBalance(); - const { data: walletSettings } = d.useWalletSettingsQuery(); - const { data: weeklyCost } = d.useWeeklyDeploymentCostQuery({ enabled: !isFixedThresholdEnabled }); - const { upsertWalletSettings } = d.useWalletSettingsMutations(); - const { confirm } = d.usePopup(); - - const searchParams = d.useSearchParams(); - const router = d.useRouter(); - const { urlService } = d.useServices(); - const isLoading = isLoadingDefaultPaymentMethod; - - useEffect(() => { - if (!isLoading && searchParams.get("openPayment") === "true") { - setShowAddCredits(true); - router.replace(urlService.billing(), { scroll: false }); - } - }, [isLoading, searchParams, router, urlService]); - - const toggleAutoReload = useCallback( - async (autoReloadEnabled: boolean) => { - const promptMessage = autoReloadEnabled - ? { - title: "Enable automatic credit reloading?", - message: "Your default payment method will be charged automatically when credits run low, so your deployments keep running." - } - : { - title: "Disable automatic credit reloading?", - message: "Your deployments may stop if your credit balance runs out, and no automatic charges will be made." - }; - const isConfirmed = await confirm(promptMessage); - - if (!isConfirmed) { - return; - } - - const settings = { - data: { - autoReloadEnabled - } - }; - - upsertWalletSettings.mutate(settings, { - onSuccess: response => - enqueueSnackbar(, { - variant: "success", - autoHideDuration: 3000 - }), - onError: () => enqueueSnackbar(, { variant: "error" }) - }); - }, - [confirm, enqueueSnackbar, upsertWalletSettings] - ); - - const disableAutoTopUp = useCallback(async () => { - const isConfirmed = await confirm({ - title: "Disable Auto Top-Up?", - message: "Your deployments may stop if your credit balance runs out, and no automatic charges will be made." - }); - - if (!isConfirmed) { - return; - } - - upsertWalletSettings.mutate( - { data: { autoReloadEnabled: false } }, - { - onSuccess: () => enqueueSnackbar(, { variant: "success", autoHideDuration: 3000 }), - onError: () => enqueueSnackbar(, { variant: "error" }) - } - ); - }, [confirm, enqueueSnackbar, upsertWalletSettings, d]); - - const handleAutoTopUpSwitch = useCallback( - (checked: boolean) => (checked ? setAutoTopUpPopup({ open: true, enableOnSave: true }) : disableAutoTopUp()), - [disableAutoTopUp] - ); - - const hasPaymentMethod = !!defaultPaymentMethod; - const autoReloadThreshold = walletSettings?.autoReloadThreshold ?? DEFAULT_AUTO_RELOAD_THRESHOLD; - const autoReloadAmount = walletSettings?.autoReloadAmount ?? DEFAULT_AUTO_RELOAD_AMOUNT; - - const isReloadChangeDisabled = useMemo(() => { - return !hasPaymentMethod || upsertWalletSettings.isPending; - }, [hasPaymentMethod, upsertWalletSettings.isPending]); - - if (isLoading) { - return ( -
-
- Your account - -
- -
-
- - - - - - -
- - -
-
-
- - - - - - -
- - -
-
-
-
-
-
- ); - } - - return ( -
-
- Your account - setShowAddCredits(true)} disabled={isWalletBalanceLoading} size="sm"> - - Add Funds - -
- -
-
- - {(!walletBalance || isWalletBalanceLoading) && ( -
- -
- )} - -

Available Balance

- -
- -
-

- {walletBalance && } -

-

- {walletBalance && } used in deployments -

-
-
-
- - {upsertWalletSettings.isPending && ( -
- -
- )} - -

{isFixedThresholdEnabled ? "Auto Top-Up" : "Auto Recharge"}

- - - -
- -
- - {!hasPaymentMethod ? ( -

- - Add a payment method - {" "} - to enable auto {isFixedThresholdEnabled ? "top-up" : "recharge"} -

- ) : isFixedThresholdEnabled ? ( - walletSettings?.autoReloadEnabled ? ( -
-

- Top up{" "} - - - {" "} - when balance ≤{" "} - - - -

- setAutoTopUpPopup({ open: true, enableOnSave: false })} - > - - -
- ) : ( -

Add funds automatically

- ) - ) : ( -

- Recharge amount is approximately{" "} - - - {" "} - per week -

- )} -
-
-
-
- - setShowPaymentSuccess({ amount: "", bonusAmount: "", show: false })} - /> -
- - { - setShowPaymentSuccess({ amount: String(amount), bonusAmount: String(bonusAmount ?? 0), show: true }); - setShowAddCredits(false); - }} - /> - - {isFixedThresholdEnabled && ( - setAutoTopUpPopup(prev => ({ ...prev, open: false }))} - enableOnSave={autoTopUpPopup.enableOnSave} - threshold={walletSettings?.autoReloadThreshold} - amount={walletSettings?.autoReloadAmount} - /> - )} -
- ); -}; diff --git a/apps/deploy-web/src/components/billing-usage/AddToBalanceButton/AddToBalanceButton.spec.tsx b/apps/deploy-web/src/components/billing-usage/AddToBalanceButton/AddToBalanceButton.spec.tsx new file mode 100644 index 0000000000..cf3d40e3b9 --- /dev/null +++ b/apps/deploy-web/src/components/billing-usage/AddToBalanceButton/AddToBalanceButton.spec.tsx @@ -0,0 +1,104 @@ +import { describe, expect, it, vi } from "vitest"; + +import { AddToBalanceButton, DEPENDENCIES } from "./AddToBalanceButton"; + +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { MockComponents } from "@tests/unit/mocks"; + +describe(AddToBalanceButton.name, () => { + it("opens the add credits sheet and cleans the url when openPayment is true", () => { + const { mockReplace } = setup({ searchParamsGet: key => (key === "openPayment" ? "true" : null), defaultPaymentMethod: { id: "pm_123" } }); + + expect(mockReplace).toHaveBeenCalledWith("/billing", { scroll: false }); + expect(screen.getByTestId("add-credits-sheet")).toBeInTheDocument(); + }); + + it("does not open the add credits sheet when openPayment is absent", () => { + const { mockReplace } = setup({ searchParamsGet: () => null }); + + expect(mockReplace).not.toHaveBeenCalled(); + expect(screen.queryByTestId("add-credits-sheet")).not.toBeInTheDocument(); + }); + + it("opens the add credits sheet even without a default payment method", () => { + setup({ searchParamsGet: key => (key === "openPayment" ? "true" : null), defaultPaymentMethod: undefined }); + + expect(screen.getByTestId("add-credits-sheet")).toBeInTheDocument(); + }); + + it("does not open while the default payment method is still loading", () => { + const { mockReplace } = setup({ + searchParamsGet: key => (key === "openPayment" ? "true" : null), + defaultPaymentMethod: { id: "pm_123" }, + isLoadingDefaultPaymentMethod: true + }); + + expect(mockReplace).not.toHaveBeenCalled(); + }); + + it("opens the add credits sheet from the Add to Balance button", () => { + setup({ searchParamsGet: () => null }); + + fireEvent.click(screen.getByRole("button", { name: /add to balance/i })); + + expect(screen.getByTestId("add-credits-sheet")).toBeInTheDocument(); + }); + + it("shows the payment success animation and closes the sheet when a purchase completes", () => { + const { dependencies, MockAddCreditsSheet } = setup({ + searchParamsGet: key => (key === "openPayment" ? "true" : null), + defaultPaymentMethod: { id: "pm_123" } + }); + + act(() => MockAddCreditsSheet.mock.calls.at(-1)![0].onDone(100)); + + expect(dependencies.PaymentSuccessAnimation).toHaveBeenCalledWith(expect.objectContaining({ show: true, amount: "100" }), expect.anything()); + expect(MockAddCreditsSheet).toHaveBeenCalledWith(expect.objectContaining({ open: false }), expect.anything()); + }); + + it("forwards the granted first-purchase bonus to the payment success animation", () => { + const { dependencies, MockAddCreditsSheet } = setup({ + searchParamsGet: key => (key === "openPayment" ? "true" : null), + defaultPaymentMethod: { id: "pm_123" } + }); + + act(() => MockAddCreditsSheet.mock.calls.at(-1)![0].onDone(100, undefined, 10)); + + expect(dependencies.PaymentSuccessAnimation).toHaveBeenCalledWith( + expect.objectContaining({ show: true, amount: "100", bonusAmount: "10" }), + expect.anything() + ); + }); + + function setup(input: { + searchParamsGet?: (key: string) => string | null; + routerReplace?: ReturnType; + defaultPaymentMethod?: { id: string }; + isLoadingDefaultPaymentMethod?: boolean; + isWalletBalanceLoading?: boolean; + }) { + const mockReplace = input.routerReplace ?? vi.fn(); + const mockSearchParams = { get: vi.fn(input.searchParamsGet ?? (() => null)) }; + const mockRouter = { replace: mockReplace, push: vi.fn() }; + + const MockAddCreditsSheet = vi.fn((props: Parameters[0]) => + props.open ?
: null + ); + const MockButton = vi.fn(({ children, ...props }: Parameters[0]) => ); + + const dependencies = { + ...MockComponents(DEPENDENCIES), + useDefaultPaymentMethodQuery: vi.fn(() => ({ data: input.defaultPaymentMethod, isLoading: input.isLoadingDefaultPaymentMethod ?? false })), + useWalletBalance: vi.fn(() => ({ balance: null, isLoading: input.isWalletBalanceLoading ?? false, refetch: vi.fn() })), + useSearchParams: vi.fn(() => mockSearchParams), + useRouter: vi.fn(() => mockRouter), + useServices: vi.fn(() => ({ urlService: { billing: () => "/billing" } })), + Button: MockButton, + AddCreditsSheet: MockAddCreditsSheet + } as unknown as typeof DEPENDENCIES; + + render(); + + return { dependencies, MockAddCreditsSheet, mockReplace }; + } +}); diff --git a/apps/deploy-web/src/components/billing-usage/AddToBalanceButton/AddToBalanceButton.tsx b/apps/deploy-web/src/components/billing-usage/AddToBalanceButton/AddToBalanceButton.tsx new file mode 100644 index 0000000000..f0d23ff4f4 --- /dev/null +++ b/apps/deploy-web/src/components/billing-usage/AddToBalanceButton/AddToBalanceButton.tsx @@ -0,0 +1,70 @@ +"use client"; +import React, { useEffect, useState } from "react"; +import { Button } from "@akashnetwork/ui/components"; +import { Plus } from "iconoir-react"; +import { useRouter, useSearchParams } from "next/navigation"; + +import { AddCreditsSheet } from "@src/components/auth/AddCreditsSheet/AddCreditsSheet"; +import { PaymentSuccessAnimation } from "@src/components/billing-usage/PaymentSuccessAnimation/PaymentSuccessAnimation"; +import { useServices } from "@src/context/ServicesProvider/ServicesProvider"; +import { useWalletBalance } from "@src/hooks/useWalletBalance"; +import { useDefaultPaymentMethodQuery } from "@src/queries"; + +export const DEPENDENCIES = { + useDefaultPaymentMethodQuery, + useWalletBalance, + useSearchParams, + useRouter, + useServices, + AddCreditsSheet, + PaymentSuccessAnimation, + Button +}; + +export const AddToBalanceButton: React.FunctionComponent<{ dependencies?: typeof DEPENDENCIES }> = ({ dependencies: d = DEPENDENCIES }) => { + const [showAddCredits, setShowAddCredits] = useState(false); + const [showPaymentSuccess, setShowPaymentSuccess] = useState<{ amount: string; bonusAmount: string; show: boolean }>({ + amount: "", + bonusAmount: "", + show: false + }); + const { isLoading: isLoadingDefaultPaymentMethod } = d.useDefaultPaymentMethodQuery(); + const { isLoading: isWalletBalanceLoading } = d.useWalletBalance(); + const searchParams = d.useSearchParams(); + const router = d.useRouter(); + const { urlService } = d.useServices(); + + useEffect(() => { + if (!isLoadingDefaultPaymentMethod && searchParams.get("openPayment") === "true") { + setShowAddCredits(true); + router.replace(urlService.billing(), { scroll: false }); + } + }, [isLoadingDefaultPaymentMethod, searchParams, router, urlService]); + + return ( + <> + setShowAddCredits(true)} disabled={isWalletBalanceLoading} size="sm"> + + Add to Balance + + + setShowPaymentSuccess({ amount: "", bonusAmount: "", show: false })} + /> + + { + setShowPaymentSuccess({ amount: String(amount), bonusAmount: String(bonusAmount ?? 0), show: true }); + setShowAddCredits(false); + }} + /> + + ); +}; diff --git a/apps/deploy-web/src/components/billing-usage/AutoTopUpSection/AutoTopUpSection.spec.tsx b/apps/deploy-web/src/components/billing-usage/AutoTopUpSection/AutoTopUpSection.spec.tsx new file mode 100644 index 0000000000..a790eaf2b3 --- /dev/null +++ b/apps/deploy-web/src/components/billing-usage/AutoTopUpSection/AutoTopUpSection.spec.tsx @@ -0,0 +1,190 @@ +import { describe, expect, it, vi } from "vitest"; + +import { AutoTopUpSection, DEPENDENCIES } from "./AutoTopUpSection"; + +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { MockComponents } from "@tests/unit/mocks"; + +describe(AutoTopUpSection.name, () => { + it("shows the weekly recharge estimate when the fixed-threshold flag is off", () => { + setup({ isFixedThresholdEnabled: false, defaultPaymentMethod: { id: "pm_123" }, weeklyCost: 42 }); + + expect(screen.getByText("Auto Recharge")).toBeInTheDocument(); + expect(screen.getByText(/per week/)).toHaveTextContent("42"); + }); + + describe("when the fixed-threshold flag is enabled", () => { + it("renders the Auto Top-Up title", () => { + setup({ isFixedThresholdEnabled: true, defaultPaymentMethod: { id: "pm_123" } }); + + expect(screen.getByText("Auto Top-Up")).toBeInTheDocument(); + }); + + it("shows the threshold summary with stored values when enabled", () => { + setup({ + isFixedThresholdEnabled: true, + defaultPaymentMethod: { id: "pm_123" }, + walletSettings: { autoReloadEnabled: true, autoReloadThreshold: 20, autoReloadAmount: 100 } + }); + + expect(screen.getByText(/Top up/)).toHaveTextContent("Top up 100 when balance ≤ 20"); + }); + + it("shows 'Add funds automatically' when disabled with a payment method", () => { + setup({ isFixedThresholdEnabled: true, defaultPaymentMethod: { id: "pm_123" }, walletSettings: { autoReloadEnabled: false } }); + + expect(screen.getByText("Add funds automatically")).toBeInTheDocument(); + }); + + it("opens the settings dialog in enable mode without mutating when the switch is turned on", () => { + const { dependencies, upsertMutate } = setup({ + isFixedThresholdEnabled: true, + defaultPaymentMethod: { id: "pm_123" }, + walletSettings: { autoReloadEnabled: false } + }); + + fireEvent.click(screen.getByRole("switch")); + + expect(upsertMutate).not.toHaveBeenCalled(); + expect(dependencies.AutoTopUpSettingsPopup).toHaveBeenCalledWith(expect.objectContaining({ open: true, enableOnSave: true }), expect.anything()); + }); + + it("confirms then disables auto top-up when the switch is turned off", async () => { + const upsertMutate = vi.fn(); + setup({ + isFixedThresholdEnabled: true, + defaultPaymentMethod: { id: "pm_123" }, + walletSettings: { autoReloadEnabled: true, autoReloadThreshold: 20, autoReloadAmount: 100 }, + confirmResult: true, + upsertMutate + }); + + fireEvent.click(screen.getByRole("switch")); + + await vi.waitFor(() => { + expect(upsertMutate).toHaveBeenCalledWith(expect.objectContaining({ data: { autoReloadEnabled: false } }), expect.anything()); + }); + }); + + it("opens the settings dialog in edit mode from the edit button", () => { + const { dependencies } = setup({ + isFixedThresholdEnabled: true, + defaultPaymentMethod: { id: "pm_123" }, + walletSettings: { autoReloadEnabled: true, autoReloadThreshold: 20, autoReloadAmount: 100 } + }); + + fireEvent.click(screen.getByRole("button", { name: /edit auto top-up settings/i })); + + expect(dependencies.AutoTopUpSettingsPopup).toHaveBeenCalledWith(expect.objectContaining({ open: true, enableOnSave: false }), expect.anything()); + }); + + it("disables the switch and hides the edit button without a payment method", () => { + setup({ isFixedThresholdEnabled: true, defaultPaymentMethod: undefined }); + + expect(screen.getByRole("switch")).toBeDisabled(); + expect(screen.queryByRole("button", { name: /edit auto top-up settings/i })).not.toBeInTheDocument(); + expect(screen.getByText(/Add a payment method/)).toBeInTheDocument(); + }); + + it("disables the edit button while a settings update is in flight", () => { + setup({ + isFixedThresholdEnabled: true, + defaultPaymentMethod: { id: "pm_123" }, + walletSettings: { autoReloadEnabled: true, autoReloadThreshold: 20, autoReloadAmount: 100 }, + isPending: true + }); + + expect(screen.getByRole("button", { name: /edit auto top-up settings/i })).toBeDisabled(); + }); + + it("does not disable auto top-up when the confirmation is cancelled", async () => { + const upsertMutate = vi.fn(); + setup({ + isFixedThresholdEnabled: true, + defaultPaymentMethod: { id: "pm_123" }, + walletSettings: { autoReloadEnabled: true, autoReloadThreshold: 20, autoReloadAmount: 100 }, + confirmResult: false, + upsertMutate + }); + + await act(async () => { + fireEvent.click(screen.getByRole("switch")); + }); + + expect(upsertMutate).not.toHaveBeenCalled(); + }); + + it("shows a snackbar when disabling auto top-up succeeds", async () => { + const enqueueSnackbar = vi.fn(); + const upsertMutate = vi.fn((_payload, options) => options?.onSuccess?.({ data: { autoReloadEnabled: false } })); + setup({ + isFixedThresholdEnabled: true, + defaultPaymentMethod: { id: "pm_123" }, + walletSettings: { autoReloadEnabled: true, autoReloadThreshold: 20, autoReloadAmount: 100 }, + confirmResult: true, + upsertMutate, + enqueueSnackbar + }); + + await act(async () => { + fireEvent.click(screen.getByRole("switch")); + }); + + expect(enqueueSnackbar).toHaveBeenCalled(); + }); + + it("closes the settings dialog when the popup requests it", () => { + const { dependencies } = setup({ + isFixedThresholdEnabled: true, + defaultPaymentMethod: { id: "pm_123" }, + walletSettings: { autoReloadEnabled: true, autoReloadThreshold: 20, autoReloadAmount: 100 } + }); + + fireEvent.click(screen.getByRole("button", { name: /edit auto top-up settings/i })); + const openedProps = vi.mocked(dependencies.AutoTopUpSettingsPopup).mock.calls.at(-1)![0]; + + act(() => openedProps.onClose()); + + expect(dependencies.AutoTopUpSettingsPopup).toHaveBeenLastCalledWith(expect.objectContaining({ open: false }), expect.anything()); + }); + }); + + function setup(input: { + isFixedThresholdEnabled?: boolean; + defaultPaymentMethod?: { id: string }; + walletSettings?: { autoReloadEnabled: boolean; autoReloadThreshold?: number; autoReloadAmount?: number }; + weeklyCost?: number; + confirmResult?: boolean; + upsertMutate?: ReturnType; + enqueueSnackbar?: ReturnType; + isPending?: boolean; + }) { + const upsertMutate = input.upsertMutate ?? vi.fn(); + const enqueueSnackbar = input.enqueueSnackbar ?? vi.fn(); + + const MockButton = vi.fn(({ children, ...props }: Parameters[0]) => ); + const MockSwitch = vi.fn(({ checked, onCheckedChange, disabled }: Parameters[0]) => ( + onCheckedChange?.(e.target.checked)} /> + )); + const MockFormattedNumber = vi.fn(({ value }: Parameters[0]) => <>{value}); + + const dependencies = { + ...MockComponents(DEPENDENCIES), + useFlag: vi.fn(() => input.isFixedThresholdEnabled ?? false), + useSnackbar: vi.fn(() => ({ enqueueSnackbar })), + useDefaultPaymentMethodQuery: vi.fn(() => ({ data: input.defaultPaymentMethod, isLoading: false })), + useWalletSettingsQuery: vi.fn(() => ({ data: input.walletSettings ?? { autoReloadEnabled: false } })), + useWeeklyDeploymentCostQuery: vi.fn(() => ({ data: input.weeklyCost ?? 5 })), + useWalletSettingsMutations: vi.fn(() => ({ upsertWalletSettings: { mutate: upsertMutate, isPending: input.isPending ?? false } })), + usePopup: vi.fn(() => ({ confirm: vi.fn().mockResolvedValue(input.confirmResult ?? true) })), + useServices: vi.fn(() => ({ urlService: { billing: () => "/billing", paymentMethods: () => "/payment-methods" } })), + Button: MockButton, + Switch: MockSwitch, + FormattedNumber: MockFormattedNumber + } as unknown as typeof DEPENDENCIES; + + render(); + + return { dependencies, upsertMutate, enqueueSnackbar }; + } +}); diff --git a/apps/deploy-web/src/components/billing-usage/AutoTopUpSection/AutoTopUpSection.tsx b/apps/deploy-web/src/components/billing-usage/AutoTopUpSection/AutoTopUpSection.tsx new file mode 100644 index 0000000000..af6bbd151e --- /dev/null +++ b/apps/deploy-web/src/components/billing-usage/AutoTopUpSection/AutoTopUpSection.tsx @@ -0,0 +1,206 @@ +"use client"; +import React, { useCallback, useMemo, useState } from "react"; +import { FormattedNumber } from "react-intl"; +import { Button, Card, CardContent, CardHeader, CustomTooltip, Snackbar, Switch } from "@akashnetwork/ui/components"; +import { usePopup } from "@akashnetwork/ui/context"; +import { LinearProgress } from "@mui/material"; +import { Edit, InfoCircle } from "iconoir-react"; +import Link from "next/link"; +import { useSnackbar } from "notistack"; + +import { + AutoTopUpSettingsPopup, + DEFAULT_AUTO_RELOAD_AMOUNT, + DEFAULT_AUTO_RELOAD_THRESHOLD +} from "@src/components/billing-usage/AutoTopUpSettingsPopup/AutoTopUpSettingsPopup"; +import { useServices } from "@src/context/ServicesProvider/ServicesProvider"; +import { useFlag } from "@src/hooks/useFlag"; +import { useDefaultPaymentMethodQuery, useWalletSettingsMutations, useWalletSettingsQuery, useWeeklyDeploymentCostQuery } from "@src/queries"; + +export const DEPENDENCIES = { + useSnackbar, + useDefaultPaymentMethodQuery, + useWalletSettingsQuery, + useWeeklyDeploymentCostQuery, + useWalletSettingsMutations, + usePopup, + useServices, + useFlag, + AutoTopUpSettingsPopup, + FormattedNumber, + Link, + Button, + Card, + CardContent, + CardHeader, + CustomTooltip, + Snackbar, + Switch, + Edit, + LinearProgress +}; + +export const AutoTopUpSection: React.FunctionComponent<{ dependencies?: typeof DEPENDENCIES }> = ({ dependencies: d = DEPENDENCIES }) => { + const [autoTopUpPopup, setAutoTopUpPopup] = useState<{ open: boolean; enableOnSave: boolean }>({ open: false, enableOnSave: false }); + const isFixedThresholdEnabled = d.useFlag("auto_reload_fixed_threshold"); + const { enqueueSnackbar } = d.useSnackbar(); + const { data: defaultPaymentMethod } = d.useDefaultPaymentMethodQuery(); + const { data: walletSettings } = d.useWalletSettingsQuery(); + const { data: weeklyCost } = d.useWeeklyDeploymentCostQuery({ enabled: !isFixedThresholdEnabled }); + const { upsertWalletSettings } = d.useWalletSettingsMutations(); + const { confirm } = d.usePopup(); + const { urlService } = d.useServices(); + + const toggleAutoReload = useCallback( + async (autoReloadEnabled: boolean) => { + const promptMessage = autoReloadEnabled + ? { + title: "Enable automatic credit reloading?", + message: "Your default payment method will be charged automatically when credits run low, so your deployments keep running." + } + : { + title: "Disable automatic credit reloading?", + message: "Your deployments may stop if your credit balance runs out, and no automatic charges will be made." + }; + const isConfirmed = await confirm(promptMessage); + + if (!isConfirmed) { + return; + } + + const settings = { + data: { + autoReloadEnabled + } + }; + + upsertWalletSettings.mutate(settings, { + onSuccess: response => + enqueueSnackbar(, { + variant: "success", + autoHideDuration: 3000 + }), + onError: () => enqueueSnackbar(, { variant: "error" }) + }); + }, + [confirm, enqueueSnackbar, upsertWalletSettings] + ); + + const disableAutoTopUp = useCallback(async () => { + const isConfirmed = await confirm({ + title: "Disable Auto Top-Up?", + message: "Your deployments may stop if your credit balance runs out, and no automatic charges will be made." + }); + + if (!isConfirmed) { + return; + } + + upsertWalletSettings.mutate( + { data: { autoReloadEnabled: false } }, + { + onSuccess: () => enqueueSnackbar(, { variant: "success", autoHideDuration: 3000 }), + onError: () => enqueueSnackbar(, { variant: "error" }) + } + ); + }, [confirm, enqueueSnackbar, upsertWalletSettings, d]); + + const handleAutoTopUpSwitch = useCallback( + (checked: boolean) => (checked ? setAutoTopUpPopup({ open: true, enableOnSave: true }) : disableAutoTopUp()), + [disableAutoTopUp] + ); + + const hasPaymentMethod = !!defaultPaymentMethod; + const autoReloadThreshold = walletSettings?.autoReloadThreshold ?? DEFAULT_AUTO_RELOAD_THRESHOLD; + const autoReloadAmount = walletSettings?.autoReloadAmount ?? DEFAULT_AUTO_RELOAD_AMOUNT; + + const isReloadChangeDisabled = useMemo(() => { + return !hasPaymentMethod || upsertWalletSettings.isPending; + }, [hasPaymentMethod, upsertWalletSettings.isPending]); + + return ( + <> + + {upsertWalletSettings.isPending && ( +
+ +
+ )} + +

{isFixedThresholdEnabled ? "Auto Top-Up" : "Auto Recharge"}

+ + + +
+ +
+ + {!hasPaymentMethod ? ( +

+ + Add a payment method + {" "} + to enable auto {isFixedThresholdEnabled ? "top-up" : "recharge"} +

+ ) : isFixedThresholdEnabled ? ( + walletSettings?.autoReloadEnabled ? ( +
+

+ Top up{" "} + + + {" "} + when balance ≤{" "} + + + +

+ setAutoTopUpPopup({ open: true, enableOnSave: false })} + > + + +
+ ) : ( +

Add funds automatically

+ ) + ) : ( +

+ Recharge amount is approximately{" "} + + + {" "} + per week +

+ )} +
+
+
+ + {isFixedThresholdEnabled && ( + setAutoTopUpPopup(prev => ({ ...prev, open: false }))} + enableOnSave={autoTopUpPopup.enableOnSave} + threshold={walletSettings?.autoReloadThreshold} + amount={walletSettings?.autoReloadAmount} + /> + )} + + ); +}; diff --git a/apps/deploy-web/src/components/billing-usage/BillingPage.tsx b/apps/deploy-web/src/components/billing-usage/BillingPage.tsx index 57ff73d991..6bccdeefa3 100644 --- a/apps/deploy-web/src/components/billing-usage/BillingPage.tsx +++ b/apps/deploy-web/src/components/billing-usage/BillingPage.tsx @@ -1,12 +1,15 @@ import React, { type FC } from "react"; import { NextSeo } from "next-seo"; +import { AccountBalanceOverview } from "@src/components/billing-usage/AccountBalanceOverview/AccountBalanceOverview"; +import { AddToBalanceButton } from "@src/components/billing-usage/AddToBalanceButton/AddToBalanceButton"; +import { AutoTopUpSection } from "@src/components/billing-usage/AutoTopUpSection/AutoTopUpSection"; import { BillingContainer } from "@src/components/billing-usage/BillingContainer/BillingContainer"; import { BillingUsageLayout, BillingUsageTabs } from "@src/components/billing-usage/BillingUsageLayout"; import { BillingView } from "@src/components/billing-usage/BillingView/BillingView"; import Layout from "@src/components/layout/Layout"; +import { Title } from "@src/components/shared/Title"; import { useFlag } from "@src/hooks/useFlag"; -import { AccountOverview } from "./AccountOverview/AccountOverview"; export const BillingPage: FC = () => { const isAutoCreditReloadEnabled = useFlag("auto_credit_reload"); @@ -15,8 +18,19 @@ export const BillingPage: FC = () => { - {isAutoCreditReloadEnabled && } - {props => } +
+ {isAutoCreditReloadEnabled && ( +
+
+ Your account + +
+ + +
+ )} + {props => } +
); diff --git a/apps/deploy-web/src/components/home/YourAccount/YourAccount.tsx b/apps/deploy-web/src/components/home/YourAccount/YourAccount.tsx index bc31bccb72..760e26d8b0 100644 --- a/apps/deploy-web/src/components/home/YourAccount/YourAccount.tsx +++ b/apps/deploy-web/src/components/home/YourAccount/YourAccount.tsx @@ -3,7 +3,6 @@ import React, { useMemo } from "react"; import { Spinner } from "@akashnetwork/ui/components"; import { useAtom } from "jotai"; -import { UACT_DENOM, UAKT_DENOM } from "@src/config/denom.config"; import { useSettings } from "@src/context/SettingsProvider"; import { useWallet } from "@src/context/WalletProvider"; import { useUsdcDenom } from "@src/hooks/useDenom"; @@ -12,8 +11,7 @@ import type { WalletBalance } from "@src/hooks/useWalletBalance"; import sdlStore from "@src/store/sdlStore"; import type { DeploymentDto, LeaseDto } from "@src/types/deployment"; import type { ApiProviderList } from "@src/types/provider"; -import { udenomToDenom } from "@src/utils/mathHelpers"; -import { getAvgCostPerMonth } from "@src/utils/priceUtils"; +import { getAvgCostPerMonth, getLeasesCostPerBlockUsd } from "@src/utils/priceUtils"; import { isLeaseLive } from "@src/utils/reclamationUtils"; import { bytesToShrink } from "@src/utils/unitUtils"; import { AccountHeader } from "../AccountHeader"; @@ -68,21 +66,7 @@ export const YourAccount: React.FunctionComponent = ({ const costs = useMemo(() => { if (!leases || !price || !isAktPriceLoaded) return null; - const activeLeases = leases.filter(isLeaseLive); - const totalCostPerBlock = activeLeases - .map(x => { - switch (x.price.denom) { - case UAKT_DENOM: - return udenomToDenom(x.price.amount, 10) * price; - case usdcIbcDenom: - case UACT_DENOM: - return udenomToDenom(x.price.amount, 10); - default: - return 0; - } - }) - .reduce((a, b) => a + b, 0); - + const totalCostPerBlock = getLeasesCostPerBlockUsd(leases.filter(isLeaseLive), price, usdcIbcDenom); const monthlyAvg = getAvgCostPerMonth(totalCostPerBlock); return { diff --git a/apps/deploy-web/src/utils/priceUtils.ts b/apps/deploy-web/src/utils/priceUtils.ts index bbe56bf46d..bcf5d6900e 100644 --- a/apps/deploy-web/src/utils/priceUtils.ts +++ b/apps/deploy-web/src/utils/priceUtils.ts @@ -62,6 +62,26 @@ export function getAvgCostPerMonth(pricePerBlock: number) { return (pricePerBlock * averageDaysInMonth * 24 * 60 * 60) / averageBlockTime; } +type PricedLease = { price: { denom: string; amount: string | number } }; + +/** + * Sums the per-block price of the given leases in USD. Pass already-live leases and the current AKT price; + * AKT is converted at `aktPrice`, while USDC and ACT are treated as 1:1 USD. + */ +export function getLeasesCostPerBlockUsd(leases: PricedLease[], aktPrice: number, usdcDenom: string): number { + return leases.reduce((total, { price }) => { + switch (price.denom) { + case UAKT_DENOM: + return total + udenomToDenom(price.amount, 10) * aktPrice; + case usdcDenom: + case UACT_DENOM: + return total + udenomToDenom(price.amount, 10); + default: + return total; + } + }, 0); +} + export function getTimeLeft(pricePerBlock: number, balance: number) { const blocksLeft = balance / pricePerBlock; const timestamp = new Date().getTime(); From 2d0031651894bca5a2a0bc278b197fc3579ab8ed Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Tue, 11 Aug 2026 20:34:58 +0530 Subject: [PATCH 02/28] refactor(billing): replace settings tabs with a left sidebar Introduce a shared SettingsLayout with a left nav (Billing, API Keys, Usage, Alerts), driven by a single useSettingsNavLinks source that the top-nav dropdown now reuses. Fold Payment Methods into the Billing page and Notification Channels into the Alerts page, then remove the per-area tab layouts. The old /payment-methods and /alerts/notification-channels routes now redirect to their new homes. Part of CON-733. --- .../src/components/alerts/AlertsLayout.tsx | 66 ----------------- .../src/components/alerts/AlertsPage.tsx | 32 +++++++-- .../alerts/CreateNotificationChannelPage.tsx | 2 +- .../alerts/EditNotificationChannelPage.tsx | 2 +- .../alerts/NotificationChannelsPage.tsx | 38 ---------- .../api-keys/ApiKeysPage/ApiKeysPage.tsx | 20 +++--- .../components/billing-usage/BillingPage.tsx | 34 ++++----- .../billing-usage/BillingUsageLayout.tsx | 65 ----------------- .../billing-usage/PaymentMethodsPage.tsx | 18 ----- .../components/billing-usage/UsagePage.tsx | 6 +- .../SettingsLayout/SettingsLayout.spec.tsx | 71 +++++++++++++++++++ .../layout/SettingsLayout/SettingsLayout.tsx | 58 +++++++++++++++ .../src/components/layout/TopNav/TopNav.tsx | 12 +--- .../src/hooks/useSettingsNavLinks.spec.ts | 42 +++++++++++ .../src/hooks/useSettingsNavLinks.ts | 37 ++++++++++ .../alerts/notification-channels/index.tsx | 8 +-- .../src/pages/payment-methods/index.tsx | 8 +-- 17 files changed, 280 insertions(+), 239 deletions(-) delete mode 100644 apps/deploy-web/src/components/alerts/AlertsLayout.tsx delete mode 100644 apps/deploy-web/src/components/alerts/NotificationChannelsPage.tsx delete mode 100644 apps/deploy-web/src/components/billing-usage/BillingUsageLayout.tsx delete mode 100644 apps/deploy-web/src/components/billing-usage/PaymentMethodsPage.tsx create mode 100644 apps/deploy-web/src/components/layout/SettingsLayout/SettingsLayout.spec.tsx create mode 100644 apps/deploy-web/src/components/layout/SettingsLayout/SettingsLayout.tsx create mode 100644 apps/deploy-web/src/hooks/useSettingsNavLinks.spec.ts create mode 100644 apps/deploy-web/src/hooks/useSettingsNavLinks.ts diff --git a/apps/deploy-web/src/components/alerts/AlertsLayout.tsx b/apps/deploy-web/src/components/alerts/AlertsLayout.tsx deleted file mode 100644 index 051acca12e..0000000000 --- a/apps/deploy-web/src/components/alerts/AlertsLayout.tsx +++ /dev/null @@ -1,66 +0,0 @@ -"use client"; -import type { ReactNode } from "react"; -import React from "react"; -import { ErrorBoundary } from "react-error-boundary"; -import { ErrorFallback, Tabs, TabsList, TabsTrigger } from "@akashnetwork/ui/components"; -import { cn } from "@akashnetwork/ui/utils"; -import { NavArrowLeft } from "iconoir-react"; -import Link from "next/link"; -import { useRouter } from "next/navigation"; - -import { UrlService } from "@src/utils/urlUtils"; -import { Title } from "../shared/Title"; - -export enum AlertTabs { - ALERTS = "ALERTS", - NOTIFICATION_CHANNELS = "NOTIFICATION_CHANNELS" -} - -type Props = { - page: AlertTabs; - children?: ReactNode; - title: string; - headerActions?: ReactNode; - returnable?: boolean; -}; - -export const AlertsLayout: React.FunctionComponent = ({ children, page, title, headerActions, returnable }) => { - const router = useRouter(); - - const handleTabChange = (newValue: string) => { - switch (newValue as AlertTabs) { - case AlertTabs.ALERTS: - router.push(UrlService.alerts()); - break; - case AlertTabs.NOTIFICATION_CHANNELS: - default: - router.push(UrlService.notificationChannels()); - break; - } - }; - - return ( - - - - Alerts - - - Notification Channels - - - -
- {returnable && ( - - - - )} - {title} - {headerActions} -
- - {children} -
- ); -}; diff --git a/apps/deploy-web/src/components/alerts/AlertsPage.tsx b/apps/deploy-web/src/components/alerts/AlertsPage.tsx index f0accb5951..7ee317e63a 100644 --- a/apps/deploy-web/src/components/alerts/AlertsPage.tsx +++ b/apps/deploy-web/src/components/alerts/AlertsPage.tsx @@ -1,18 +1,40 @@ import React, { type FC } from "react"; +import { buttonVariants } from "@akashnetwork/ui/components"; +import { cn } from "@akashnetwork/ui/utils"; +import { Plus } from "iconoir-react"; +import Link from "next/link"; import { NextSeo } from "next-seo"; -import { AlertsLayout, AlertTabs } from "@src/components/alerts/AlertsLayout"; import { AlertsListContainer } from "@src/components/alerts/AlertsListContainer/AlertsListContainer"; import { AlertsListView } from "@src/components/alerts/AlertsListView/AlertsListView"; +import { NotificationChannelsListContainer } from "@src/components/alerts/NotificationChannelsListContainer/NotificationChannelsListContainer"; +import { NotificationChannelsListView } from "@src/components/alerts/NotificationChannelsListView/NotificationChannelsListView"; import Layout from "@src/components/layout/Layout"; +import { SettingsLayout } from "@src/components/layout/SettingsLayout/SettingsLayout"; +import { Title } from "@src/components/shared/Title"; +import { UrlService } from "@src/utils/urlUtils"; export const AlertsPage: FC = () => { return ( - - - {props => } - + + +
+ Configured Alerts + {props => } +
+ +
+
+ Notification Channels + + + Create + +
+ {props => } +
+
); }; diff --git a/apps/deploy-web/src/components/alerts/CreateNotificationChannelPage.tsx b/apps/deploy-web/src/components/alerts/CreateNotificationChannelPage.tsx index 00bca96c74..ac79dc0ec1 100644 --- a/apps/deploy-web/src/components/alerts/CreateNotificationChannelPage.tsx +++ b/apps/deploy-web/src/components/alerts/CreateNotificationChannelPage.tsx @@ -12,7 +12,7 @@ import { useNavigationGuard } from "@src/hooks/useNavigationGuard/useNavigationG import { UrlService } from "@src/utils/urlUtils"; export const CreateNotificationChannelPage: React.FunctionComponent = () => { - const goBack = useBackNav(UrlService.notificationChannels()); + const goBack = useBackNav(UrlService.alerts()); const navGuard = useNavigationGuard(); return ( diff --git a/apps/deploy-web/src/components/alerts/EditNotificationChannelPage.tsx b/apps/deploy-web/src/components/alerts/EditNotificationChannelPage.tsx index 3c3f54596f..812e2e8a64 100644 --- a/apps/deploy-web/src/components/alerts/EditNotificationChannelPage.tsx +++ b/apps/deploy-web/src/components/alerts/EditNotificationChannelPage.tsx @@ -17,7 +17,7 @@ type Props = { }; export const EditNotificationChannelPage: React.FunctionComponent = ({ notificationChannel }: Props) => { - const goBack = useBackNav(UrlService.notificationChannels()); + const goBack = useBackNav(UrlService.alerts()); const navGuard = useNavigationGuard(); const saveNavStateAndGoBack = useCallback(() => { navGuard.toggle({ hasChanges: false }); diff --git a/apps/deploy-web/src/components/alerts/NotificationChannelsPage.tsx b/apps/deploy-web/src/components/alerts/NotificationChannelsPage.tsx deleted file mode 100644 index e87300f672..0000000000 --- a/apps/deploy-web/src/components/alerts/NotificationChannelsPage.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import React from "react"; -import { buttonVariants } from "@akashnetwork/ui/components"; -import { cn } from "@akashnetwork/ui/utils"; -import { Plus } from "iconoir-react"; -import Link from "next/link"; -import { NextSeo } from "next-seo"; - -import { AlertsLayout, AlertTabs } from "@src/components/alerts/AlertsLayout"; -import { NotificationChannelsListContainer } from "@src/components/alerts/NotificationChannelsListContainer/NotificationChannelsListContainer"; -import { NotificationChannelsListView } from "@src/components/alerts/NotificationChannelsListView/NotificationChannelsListView"; -import Layout from "@src/components/layout/Layout"; - -export const NotificationChannelsPage: React.FunctionComponent = () => { - return ( - - - - - - Create - -
- } - > - {props => } - - - ); -}; diff --git a/apps/deploy-web/src/components/api-keys/ApiKeysPage/ApiKeysPage.tsx b/apps/deploy-web/src/components/api-keys/ApiKeysPage/ApiKeysPage.tsx index e5838ec56e..dff8ee57be 100644 --- a/apps/deploy-web/src/components/api-keys/ApiKeysPage/ApiKeysPage.tsx +++ b/apps/deploy-web/src/components/api-keys/ApiKeysPage/ApiKeysPage.tsx @@ -5,11 +5,13 @@ import { useSnackbar } from "notistack"; import { ApiKeyList } from "@src/components/api-keys/ApiKeyList"; import Layout from "@src/components/layout/Layout"; +import { SettingsLayout } from "@src/components/layout/SettingsLayout/SettingsLayout"; import { useServices } from "@src/context/ServicesProvider"; import { useDeleteApiKey, useUserApiKeys } from "@src/queries/useApiKeysQuery"; export const DEPENDENCIES = { Layout, + SettingsLayout, NextSeo, ApiKeyList, useUserApiKeys, @@ -51,14 +53,16 @@ export function ApiKeysPage({ dependencies: d = DEPENDENCIES }: Props = {}) { - setApiKeyToDelete(apiKey)} - /> + + setApiKeyToDelete(apiKey)} + /> + ); } diff --git a/apps/deploy-web/src/components/billing-usage/BillingPage.tsx b/apps/deploy-web/src/components/billing-usage/BillingPage.tsx index 6bccdeefa3..993ad4a827 100644 --- a/apps/deploy-web/src/components/billing-usage/BillingPage.tsx +++ b/apps/deploy-web/src/components/billing-usage/BillingPage.tsx @@ -5,10 +5,11 @@ import { AccountBalanceOverview } from "@src/components/billing-usage/AccountBal import { AddToBalanceButton } from "@src/components/billing-usage/AddToBalanceButton/AddToBalanceButton"; import { AutoTopUpSection } from "@src/components/billing-usage/AutoTopUpSection/AutoTopUpSection"; import { BillingContainer } from "@src/components/billing-usage/BillingContainer/BillingContainer"; -import { BillingUsageLayout, BillingUsageTabs } from "@src/components/billing-usage/BillingUsageLayout"; import { BillingView } from "@src/components/billing-usage/BillingView/BillingView"; +import { PaymentMethodsContainer } from "@src/components/billing-usage/PaymentMethodsContainer/PaymentMethodsContainer"; +import { PaymentMethodsView } from "@src/components/billing-usage/PaymentMethodsView/PaymentMethodsView"; import Layout from "@src/components/layout/Layout"; -import { Title } from "@src/components/shared/Title"; +import { SettingsLayout } from "@src/components/layout/SettingsLayout/SettingsLayout"; import { useFlag } from "@src/hooks/useFlag"; export const BillingPage: FC = () => { @@ -17,21 +18,20 @@ export const BillingPage: FC = () => { return ( - -
- {isAutoCreditReloadEnabled && ( -
-
- Your account - -
- - -
- )} - {props => } -
-
+ : undefined} + > + {isAutoCreditReloadEnabled && ( + <> + + + {props => } + + )} + {props => } +
); }; diff --git a/apps/deploy-web/src/components/billing-usage/BillingUsageLayout.tsx b/apps/deploy-web/src/components/billing-usage/BillingUsageLayout.tsx deleted file mode 100644 index 4fdef73aa7..0000000000 --- a/apps/deploy-web/src/components/billing-usage/BillingUsageLayout.tsx +++ /dev/null @@ -1,65 +0,0 @@ -"use client"; - -import type { ReactNode } from "react"; -import React from "react"; -import { ErrorBoundary } from "react-error-boundary"; -import { ErrorFallback, Tabs, TabsList, TabsTrigger } from "@akashnetwork/ui/components"; -import { cn } from "@akashnetwork/ui/utils"; -import { useRouter } from "next/navigation"; - -import { Title } from "@src/components/shared/Title"; -import { useFlag } from "@src/hooks/useFlag"; -import { UrlService } from "@src/utils/urlUtils"; - -export enum BillingUsageTabs { - BILLING = "BILLING", - PAYMENT_METHODS = "PAYMENT_METHODS", - USAGE = "USAGE" -} - -type Props = { - page: BillingUsageTabs; - children?: ReactNode; -}; - -export const BillingUsageLayout: React.FunctionComponent = ({ children, page }) => { - const router = useRouter(); - const isAutoCreditReloadEnabled = useFlag("auto_credit_reload"); - - const changeTab = (newValue: string) => { - switch (newValue as BillingUsageTabs) { - case BillingUsageTabs.BILLING: - router.push(UrlService.billing()); - break; - case BillingUsageTabs.PAYMENT_METHODS: - router.push(UrlService.paymentMethods()); - break; - case BillingUsageTabs.USAGE: - default: - router.push(UrlService.usage()); - break; - } - }; - - return ( - - Billing & Usage - - - - Billing - - {isAutoCreditReloadEnabled && ( - - Payment Methods - - )} - - Usage - - - - {children} - - ); -}; diff --git a/apps/deploy-web/src/components/billing-usage/PaymentMethodsPage.tsx b/apps/deploy-web/src/components/billing-usage/PaymentMethodsPage.tsx deleted file mode 100644 index 98ab1064ea..0000000000 --- a/apps/deploy-web/src/components/billing-usage/PaymentMethodsPage.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import React, { type FC } from "react"; -import { NextSeo } from "next-seo"; - -import { BillingUsageLayout, BillingUsageTabs } from "@src/components/billing-usage/BillingUsageLayout"; -import Layout from "@src/components/layout/Layout"; -import { PaymentMethodsContainer } from "./PaymentMethodsContainer/PaymentMethodsContainer"; -import { PaymentMethodsView } from "./PaymentMethodsView/PaymentMethodsView"; - -export const PaymentMethodsPage: FC = () => { - return ( - - - - {props => } - - - ); -}; diff --git a/apps/deploy-web/src/components/billing-usage/UsagePage.tsx b/apps/deploy-web/src/components/billing-usage/UsagePage.tsx index d158c53d1b..5b48362f62 100644 --- a/apps/deploy-web/src/components/billing-usage/UsagePage.tsx +++ b/apps/deploy-web/src/components/billing-usage/UsagePage.tsx @@ -1,18 +1,18 @@ import React, { type FC } from "react"; import { NextSeo } from "next-seo"; -import { BillingUsageLayout, BillingUsageTabs } from "@src/components/billing-usage/BillingUsageLayout"; import { UsageContainer } from "@src/components/billing-usage/UsageContainer/UsageContainer"; import { UsageView } from "@src/components/billing-usage/UsageView/UsageView"; import Layout from "@src/components/layout/Layout"; +import { SettingsLayout } from "@src/components/layout/SettingsLayout/SettingsLayout"; export const UsagePage: FC = () => { return ( - + {props => } - + ); }; diff --git a/apps/deploy-web/src/components/layout/SettingsLayout/SettingsLayout.spec.tsx b/apps/deploy-web/src/components/layout/SettingsLayout/SettingsLayout.spec.tsx new file mode 100644 index 0000000000..f584cff0eb --- /dev/null +++ b/apps/deploy-web/src/components/layout/SettingsLayout/SettingsLayout.spec.tsx @@ -0,0 +1,71 @@ +import type { ReactNode } from "react"; +import { describe, expect, it } from "vitest"; + +import type { SettingsNavLink } from "@src/hooks/useSettingsNavLinks"; +import { DEPENDENCIES, SettingsLayout } from "./SettingsLayout"; + +import { render, screen } from "@testing-library/react"; + +describe(SettingsLayout.name, () => { + it("renders a sidebar link per nav item", () => { + setup({ links: [navLink("Billing", "/billing", true), navLink("API Keys", "/user/api-keys", false)] }); + + expect(screen.getByRole("link", { name: "Billing" })).toHaveAttribute("href", "/billing"); + expect(screen.getByRole("link", { name: "API Keys" })).toHaveAttribute("href", "/user/api-keys"); + }); + + it("marks the active link with aria-current", () => { + setup({ links: [navLink("Billing", "/billing", true), navLink("Usage", "/usage", false)] }); + + expect(screen.getByRole("link", { name: "Billing" })).toHaveAttribute("aria-current", "page"); + expect(screen.getByRole("link", { name: "Usage" })).not.toHaveAttribute("aria-current"); + }); + + it("renders the title, description and header actions", () => { + setup({ title: "Billing", description: "Manage your balance.", headerActions: }); + + expect(screen.getByText("Billing")).toBeInTheDocument(); + expect(screen.getByText("Manage your balance.")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Add to Balance" })).toBeInTheDocument(); + }); + + it("renders children", () => { + setup({ children:
}); + + expect(screen.getByTestId("content")).toBeInTheDocument(); + }); + + it("omits the header when neither title nor actions are provided", () => { + setup({ children:
content
}); + + expect(screen.queryByRole("heading")).not.toBeInTheDocument(); + }); + + function setup(input: { links?: SettingsNavLink[]; title?: string; description?: ReactNode; headerActions?: ReactNode; children?: ReactNode }) { + return render( + input.links ?? [], + Link: ({ href, children, ...props }: { href: string; children: ReactNode }) => ( + + {children} + + ), + Title: ({ children }: { children: ReactNode }) =>

{children}

+ } as unknown as typeof DEPENDENCIES + } + > + {input.children} +
+ ); + } + + function navLink(title: string, url: string, isActive: boolean): SettingsNavLink { + return { title, url, isActive, icon: () => null }; + } +}); diff --git a/apps/deploy-web/src/components/layout/SettingsLayout/SettingsLayout.tsx b/apps/deploy-web/src/components/layout/SettingsLayout/SettingsLayout.tsx new file mode 100644 index 0000000000..0c003395f1 --- /dev/null +++ b/apps/deploy-web/src/components/layout/SettingsLayout/SettingsLayout.tsx @@ -0,0 +1,58 @@ +"use client"; +import React, { type ReactNode } from "react"; +import { ErrorBoundary } from "react-error-boundary"; +import { ErrorFallback } from "@akashnetwork/ui/components"; +import { cn } from "@akashnetwork/ui/utils"; +import Link from "next/link"; + +import { Title } from "@src/components/shared/Title"; +import { useSettingsNavLinks } from "@src/hooks/useSettingsNavLinks"; + +export const DEPENDENCIES = { useSettingsNavLinks, Link, Title }; + +type Props = { + title?: string; + description?: ReactNode; + headerActions?: ReactNode; + children?: ReactNode; + dependencies?: typeof DEPENDENCIES; +}; + +function navLinkClasses(isActive: boolean) { + return cn("block whitespace-nowrap rounded-md px-3 py-2 text-sm transition-colors", { + "bg-accent font-medium text-foreground": isActive, + "text-muted-foreground hover:bg-accent hover:text-foreground": !isActive + }); +} + +export const SettingsLayout: React.FunctionComponent = ({ title, description, headerActions, children, dependencies: d = DEPENDENCIES }) => { + const links = d.useSettingsNavLinks(); + + return ( +
+ + +
+ {(title || headerActions) && ( +
+
+ {title && {title}} + {description &&

{description}

} +
+ {headerActions} +
+ )} + + +
{children}
+
+
+
+ ); +}; diff --git a/apps/deploy-web/src/components/layout/TopNav/TopNav.tsx b/apps/deploy-web/src/components/layout/TopNav/TopNav.tsx index 3e1a8e4e0a..606d454eea 100644 --- a/apps/deploy-web/src/components/layout/TopNav/TopNav.tsx +++ b/apps/deploy-web/src/components/layout/TopNav/TopNav.tsx @@ -13,12 +13,13 @@ import { SheetTrigger } from "@akashnetwork/ui/components"; import { cn, REMOVE_SCROLL_CLASS_NAMES } from "@akashnetwork/ui/utils"; -import { Cloud, CreditCard, Key, Menu, MessageAlert, MultiplePages, NavArrowDown, Server, StatsUpSquare } from "iconoir-react"; +import { Cloud, Menu, MultiplePages, NavArrowDown, Server } from "iconoir-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { SkipOnboardingButton } from "@src/components/onboarding-picker/SkipOnboardingButton/SkipOnboardingButton"; import { useFlag } from "@src/hooks/useFlag"; +import { useSettingsNavLinks } from "@src/hooks/useSettingsNavLinks"; import useCookieTheme from "@src/hooks/useTheme"; import { useUser } from "@src/hooks/useUser"; import { UrlService } from "@src/utils/urlUtils"; @@ -50,8 +51,6 @@ export function TopNav({ dependencies: d = DEPENDENCIES, minimal = false }: Prop const { user } = d.useUser(); const isAuthenticated = !!user?.userId; const pathname = d.usePathname(); - const isBillingUsageEnabled = d.useFlag("billing_usage"); - const isAlertsEnabled = d.useFlag("alerts"); const [isMobileNavOpen, setIsMobileNavOpen] = useState(false); const isRouteActive = (...routePrefixes: string[]) => !!pathname && routePrefixes.some(prefix => pathname === prefix || pathname.startsWith(`${prefix}/`)); @@ -62,12 +61,7 @@ export function TopNav({ dependencies: d = DEPENDENCIES, minimal = false }: Prop { title: "Templates", url: UrlService.templates(), isActive: isRouteActive("/templates"), icon: MultiplePages } ]; - const settingsLinks: TopNavLink[] = [ - ...(isBillingUsageEnabled ? [{ title: "Billing", url: UrlService.billing(), isActive: isRouteActive("/billing"), icon: CreditCard }] : []), - { title: "API Keys", url: UrlService.userApiKeys(), isActive: isRouteActive("/user/api-keys"), icon: Key }, - ...(isBillingUsageEnabled ? [{ title: "Usage", url: UrlService.usage(), isActive: isRouteActive("/usage"), icon: StatsUpSquare }] : []), - ...(isAlertsEnabled ? [{ title: "Alerts", url: UrlService.alerts(), isActive: isRouteActive("/alerts"), icon: MessageAlert }] : []) - ]; + const settingsLinks = useSettingsNavLinks({ dependencies: { useFlag: d.useFlag, usePathname: d.usePathname } }); const isSettingsActive = settingsLinks.some(link => link.isActive); const showNavLinks = isAuthenticated && !minimal; diff --git a/apps/deploy-web/src/hooks/useSettingsNavLinks.spec.ts b/apps/deploy-web/src/hooks/useSettingsNavLinks.spec.ts new file mode 100644 index 0000000000..d0b3e5ef71 --- /dev/null +++ b/apps/deploy-web/src/hooks/useSettingsNavLinks.spec.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; + +import { DEPENDENCIES, useSettingsNavLinks } from "./useSettingsNavLinks"; + +import { renderHook } from "@testing-library/react"; + +describe(useSettingsNavLinks.name, () => { + it("includes every item when billing and alerts flags are on", () => { + const links = setup({ flags: { billing_usage: true, alerts: true } }); + + expect(links.map(link => link.title)).toEqual(["Billing", "API Keys", "Usage", "Alerts"]); + }); + + it("hides flag-gated items when flags are off", () => { + const links = setup({ flags: {} }); + + expect(links.map(link => link.title)).toEqual(["API Keys"]); + }); + + it("marks the item matching the current route as active", () => { + const links = setup({ flags: { billing_usage: true, alerts: true }, pathname: "/usage" }); + + expect(links.find(link => link.title === "Usage")?.isActive).toBe(true); + expect(links.find(link => link.title === "Billing")?.isActive).toBe(false); + }); + + it("treats sub-routes as active", () => { + const links = setup({ flags: { alerts: true }, pathname: "/alerts/notification-channels/new" }); + + expect(links.find(link => link.title === "Alerts")?.isActive).toBe(true); + }); + + function setup(input: { flags?: Record; pathname?: string }) { + const dependencies = { + ...DEPENDENCIES, + useFlag: (flag: string) => input.flags?.[flag] ?? false, + usePathname: () => input.pathname ?? "/" + } as unknown as typeof DEPENDENCIES; + + return renderHook(() => useSettingsNavLinks({ dependencies })).result.current; + } +}); diff --git a/apps/deploy-web/src/hooks/useSettingsNavLinks.ts b/apps/deploy-web/src/hooks/useSettingsNavLinks.ts new file mode 100644 index 0000000000..5e3920713c --- /dev/null +++ b/apps/deploy-web/src/hooks/useSettingsNavLinks.ts @@ -0,0 +1,37 @@ +"use client"; +import type { ComponentType } from "react"; +import { CreditCard, Key, MessageAlert, StatsUpSquare } from "iconoir-react"; +import { usePathname } from "next/navigation"; + +import { useFlag } from "@src/hooks/useFlag"; +import { UrlService } from "@src/utils/urlUtils"; + +export type SettingsNavLink = { + title: string; + url: string; + isActive: boolean; + icon: ComponentType<{ className?: string }>; +}; + +export const DEPENDENCIES = { useFlag, usePathname }; + +/** + * Single source of truth for the settings navigation items, shared by the top-nav Settings dropdown + * and the settings sidebar so both stay in sync (including feature-flag gating and active state). + */ +export function useSettingsNavLinks({ dependencies: d = DEPENDENCIES }: { dependencies?: typeof DEPENDENCIES } = {}): SettingsNavLink[] { + const isBillingUsageEnabled = d.useFlag("billing_usage"); + const isAlertsEnabled = d.useFlag("alerts"); + const pathname = d.usePathname(); + + const isRouteActive = (...prefixes: string[]) => !!pathname && prefixes.some(prefix => pathname === prefix || pathname.startsWith(`${prefix}/`)); + + return [ + ...(isBillingUsageEnabled + ? [{ title: "Billing", url: UrlService.billing(), isActive: isRouteActive("/billing", "/payment-methods"), icon: CreditCard }] + : []), + { title: "API Keys", url: UrlService.userApiKeys(), isActive: isRouteActive("/user/api-keys"), icon: Key }, + ...(isBillingUsageEnabled ? [{ title: "Usage", url: UrlService.usage(), isActive: isRouteActive("/usage"), icon: StatsUpSquare }] : []), + ...(isAlertsEnabled ? [{ title: "Alerts", url: UrlService.alerts(), isActive: isRouteActive("/alerts"), icon: MessageAlert }] : []) + ]; +} diff --git a/apps/deploy-web/src/pages/alerts/notification-channels/index.tsx b/apps/deploy-web/src/pages/alerts/notification-channels/index.tsx index a5591b74c5..fd605ac243 100644 --- a/apps/deploy-web/src/pages/alerts/notification-channels/index.tsx +++ b/apps/deploy-web/src/pages/alerts/notification-channels/index.tsx @@ -1,10 +1,10 @@ -import { NotificationChannelsPage } from "@src/components/alerts/NotificationChannelsPage"; import { defineServerSideProps } from "@src/lib/nextjs/defineServerSideProps/defineServerSideProps"; -import { isFeatureEnabled } from "@src/lib/nextjs/pageGuards/pageGuards"; -export default NotificationChannelsPage; +export default function NotificationChannelsRedirect() { + return null; +} export const getServerSideProps = defineServerSideProps({ route: "/alerts/notification-channels", - if: async ctx => await isFeatureEnabled("alerts", ctx) + if: () => ({ redirect: { destination: "/alerts", permanent: false } }) }); diff --git a/apps/deploy-web/src/pages/payment-methods/index.tsx b/apps/deploy-web/src/pages/payment-methods/index.tsx index ce01a516cf..cf477fd3a1 100644 --- a/apps/deploy-web/src/pages/payment-methods/index.tsx +++ b/apps/deploy-web/src/pages/payment-methods/index.tsx @@ -1,10 +1,10 @@ -import { PaymentMethodsPage } from "@src/components/billing-usage/PaymentMethodsPage"; import { defineServerSideProps } from "@src/lib/nextjs/defineServerSideProps/defineServerSideProps"; -import { isFeatureEnabled } from "@src/lib/nextjs/pageGuards/pageGuards"; -export default PaymentMethodsPage; +export default function PaymentMethodsRedirect() { + return null; +} export const getServerSideProps = defineServerSideProps({ route: "/payment-methods", - if: async ctx => await isFeatureEnabled("auto_credit_reload", ctx) + if: () => ({ redirect: { destination: "/billing", permanent: false } }) }); From 75fed8ae7e5d643fb3f6b450afb7580d45cb2195 Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Wed, 12 Aug 2026 01:30:50 +0530 Subject: [PATCH 03/28] feat(billing): redesign account balance overview and rework billing settings --- .../src/components/alerts/AlertsPage.tsx | 38 +- .../api-keys/ApiKeysPage/ApiKeysPage.tsx | 2 +- .../AccountBalanceOverview.spec.tsx | 57 +- .../AccountBalanceOverview.tsx | 134 +++-- .../BalanceBreakdownBar.spec.tsx | 44 +- .../BalanceBreakdownBar.tsx | 122 ++++- .../useAccountBalanceOverview.spec.ts | 47 +- .../useAccountBalanceOverview.ts | 30 +- .../AutoTopUpSection.spec.tsx | 31 +- .../AutoTopUpSection/AutoTopUpSection.tsx | 163 +++--- .../BillingActionsProvider.spec.tsx | 92 ++++ .../BillingActionsProvider.tsx | 64 +++ .../BillingContainer.spec.tsx | 3 + .../BillingContainer/BillingContainer.tsx | 18 +- .../components/billing-usage/BillingPage.tsx | 15 +- .../BillingView/BillingView.spec.tsx | 29 +- .../billing-usage/BillingView/BillingView.tsx | 324 ++++++------ .../PaymentMethodsContainer.spec.tsx | 96 +--- .../PaymentMethodsContainer.tsx | 25 +- .../PaymentMethodsRow.spec.tsx | 34 +- .../PaymentMethodsView/PaymentMethodsRow.tsx | 30 +- .../PaymentMethodsView.spec.tsx | 500 ++++-------------- .../PaymentMethodsView/PaymentMethodsView.tsx | 125 ++--- .../components/billing-usage/UsagePage.tsx | 2 +- .../useBillingBackgroundLoading.ts | 33 ++ .../layout/SettingsLayout/SettingsLayout.tsx | 4 +- 26 files changed, 1056 insertions(+), 1006 deletions(-) create mode 100644 apps/deploy-web/src/components/billing-usage/BillingActionsProvider/BillingActionsProvider.spec.tsx create mode 100644 apps/deploy-web/src/components/billing-usage/BillingActionsProvider/BillingActionsProvider.tsx create mode 100644 apps/deploy-web/src/components/billing-usage/useBillingBackgroundLoading.ts diff --git a/apps/deploy-web/src/components/alerts/AlertsPage.tsx b/apps/deploy-web/src/components/alerts/AlertsPage.tsx index 7ee317e63a..4ff68eaa29 100644 --- a/apps/deploy-web/src/components/alerts/AlertsPage.tsx +++ b/apps/deploy-web/src/components/alerts/AlertsPage.tsx @@ -1,5 +1,5 @@ import React, { type FC } from "react"; -import { buttonVariants } from "@akashnetwork/ui/components"; +import { buttonVariants, Tabs, TabsContent, TabsList, TabsTrigger } from "@akashnetwork/ui/components"; import { cn } from "@akashnetwork/ui/utils"; import { Plus } from "iconoir-react"; import Link from "next/link"; @@ -11,29 +11,33 @@ import { NotificationChannelsListContainer } from "@src/components/alerts/Notifi import { NotificationChannelsListView } from "@src/components/alerts/NotificationChannelsListView/NotificationChannelsListView"; import Layout from "@src/components/layout/Layout"; import { SettingsLayout } from "@src/components/layout/SettingsLayout/SettingsLayout"; -import { Title } from "@src/components/shared/Title"; import { UrlService } from "@src/utils/urlUtils"; export const AlertsPage: FC = () => { return ( - + -
- Configured Alerts - {props => } -
+ + + Alerts + Notification Channels + -
-
- Notification Channels - - - Create - -
- {props => } -
+ + {props => } + + + +
+ + + Create + +
+ {props => } +
+
); diff --git a/apps/deploy-web/src/components/api-keys/ApiKeysPage/ApiKeysPage.tsx b/apps/deploy-web/src/components/api-keys/ApiKeysPage/ApiKeysPage.tsx index dff8ee57be..770de3bfe4 100644 --- a/apps/deploy-web/src/components/api-keys/ApiKeysPage/ApiKeysPage.tsx +++ b/apps/deploy-web/src/components/api-keys/ApiKeysPage/ApiKeysPage.tsx @@ -50,7 +50,7 @@ export function ApiKeysPage({ dependencies: d = DEPENDENCIES }: Props = {}) { }; return ( - + diff --git a/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/AccountBalanceOverview.spec.tsx b/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/AccountBalanceOverview.spec.tsx index cf96aed4fa..e7d3631c1a 100644 --- a/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/AccountBalanceOverview.spec.tsx +++ b/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/AccountBalanceOverview.spec.tsx @@ -1,9 +1,11 @@ +import type { ReactNode } from "react"; import { describe, expect, it, vi } from "vitest"; +import { UrlService } from "@src/utils/urlUtils"; import { AccountBalanceOverview, DEPENDENCIES } from "./AccountBalanceOverview"; import type { AccountBalanceOverview as AccountBalanceOverviewData } from "./useAccountBalanceOverview"; -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import { MockComponents } from "@tests/unit/mocks"; describe(AccountBalanceOverview.name, () => { @@ -27,25 +29,48 @@ describe(AccountBalanceOverview.name, () => { expect(screen.queryByText(/lasts until/)).not.toBeInTheDocument(); }); - it("explains reserved funds with the running deployment count", () => { + it("shows the reserved and available balances with their descriptors", () => { setup({ reserved: 1338, available: 1873.2, activeDeploymentCount: 7 }); - expect(screen.getByText(/is reserved to keep your 7 running deployments online/)).toBeInTheDocument(); - expect(screen.getByText(/is available for new deployments/)).toBeInTheDocument(); + expect(screen.getByLabelText("Reserved balance")).toHaveTextContent("1338"); + expect(screen.getByLabelText("Available balance")).toHaveTextContent("1873.2"); + expect(screen.getByText("Held to keep your 7 deployments running")).toBeInTheDocument(); + expect(screen.getByText("Free to spend on something new")).toBeInTheDocument(); }); - it("omits the reserved sentence when nothing is reserved", () => { - setup({ reserved: 0, available: 500 }); + it("uses singular wording when a single deployment is running", () => { + setup({ reserved: 100, activeDeploymentCount: 1 }); - expect(screen.queryByText(/is reserved to keep/)).not.toBeInTheDocument(); - expect(screen.getByText(/is available for new deployments/)).toBeInTheDocument(); + expect(screen.getByText("Held to keep your 1 deployment running")).toBeInTheDocument(); }); - it("lists each deployment and the available balance in the legend", () => { - setup({ deployments: [{ dseq: "1", name: "llama-chat", reservedUsd: 508.8 }], available: 1873.2 }); + it("reveals the deployment breakdown only after the collapsible is opened", () => { + setup({ deployments: [{ dseq: "1", name: "llama-chat", reservedUsd: 508.8, perHourUsd: 4.24 }], available: 1873.2 }); + + expect(screen.queryByText("llama-chat")).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: /What is reserved/ })); expect(screen.getByText("llama-chat")).toBeInTheDocument(); - expect(screen.getByText("Available")).toBeInTheDocument(); + expect(screen.getByText(/\/hr/)).toBeInTheDocument(); + }); + + it("toggles the breakdown label between closed and open", () => { + setup({ deployments: [{ dseq: "1", name: "llama-chat", reservedUsd: 508.8, perHourUsd: 4.24 }] }); + + expect(screen.getByRole("button", { name: /What is reserved \(1\)/ })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: /What is reserved/ })); + + expect(screen.getByRole("button", { name: /Hide breakdown/ })).toBeInTheDocument(); + }); + + it("links each deployment badge to its detail page", () => { + setup({ deployments: [{ dseq: "42", name: "llama-chat", reservedUsd: 508.8, perHourUsd: 4.24 }] }); + + fireEvent.click(screen.getByRole("button", { name: /What is reserved/ })); + + expect(screen.getByRole("link", { name: /llama-chat/ })).toHaveAttribute("href", UrlService.deploymentDetails("42")); }); it("reassures when auto recharge is on", () => { @@ -60,6 +85,12 @@ describe(AccountBalanceOverview.name, () => { expect(screen.queryByText(/Auto Recharge is on/)).not.toBeInTheDocument(); }); + it("hides the auto recharge line when the bar already surfaces the top-up threshold", () => { + setup({ autoReloadEnabled: true, autoReloadThreshold: 275 }); + + expect(screen.queryByText(/Auto Recharge is on/)).not.toBeInTheDocument(); + }); + it("renders a skeleton instead of balance while loading", () => { setup({ isLoading: true }); @@ -78,6 +109,7 @@ describe(AccountBalanceOverview.name, () => { lastsUntil: null, runwayDays: null, autoReloadEnabled: false, + autoReloadThreshold: null, isLoading: false, ...overview }; @@ -89,7 +121,8 @@ describe(AccountBalanceOverview.name, () => { { ...MockComponents(DEPENDENCIES), useAccountBalanceOverview: () => data, - FormattedNumber: MockFormattedNumber + FormattedNumber: MockFormattedNumber, + Link: ({ href, children }: { href: string; children: ReactNode }) => {children} } as unknown as typeof DEPENDENCIES } /> diff --git a/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/AccountBalanceOverview.tsx b/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/AccountBalanceOverview.tsx index 9f8ecb5704..7ae1a08361 100644 --- a/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/AccountBalanceOverview.tsx +++ b/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/AccountBalanceOverview.tsx @@ -1,10 +1,12 @@ "use client"; -import React from "react"; +import React, { useState } from "react"; import { FormattedNumber } from "react-intl"; -import { Card, CardContent, CardHeader, CustomTooltip, Skeleton } from "@akashnetwork/ui/components"; +import { Card, CardContent, CardHeader, CustomNoDivTooltip, Skeleton } from "@akashnetwork/ui/components"; import format from "date-fns/format"; -import { InfoCircle, Wallet } from "iconoir-react"; +import { InfoCircle, NavArrowDown, NavArrowRight, Wallet } from "iconoir-react"; +import Link from "next/link"; +import { UrlService } from "@src/utils/urlUtils"; import { BalanceBreakdownBar, buildBalanceSegments } from "./BalanceBreakdownBar"; import { useAccountBalanceOverview } from "./useAccountBalanceOverview"; @@ -13,16 +15,28 @@ export const DEPENDENCIES = { Card, CardContent, CardHeader, - CustomTooltip, + CustomNoDivTooltip, Skeleton, FormattedNumber, BalanceBreakdownBar, Wallet, - InfoCircle + InfoCircle, + NavArrowRight, + NavArrowDown, + Link }; +/** + * Approx. hours of running cost each deployment's escrow is kept funded to (the auto-funding target). + * ~48h in steady state, governed by the backend auto-top-up config (`AUTO_TOP_UP_AMOUNT_IN_H` sizing + + * `AUTO_TOP_UP_LOOK_AHEAD_WINDOW_IN_H` trigger, each ~24h). Update if that target changes. + */ +const RESERVE_WINDOW_HOURS = 48; + export const AccountBalanceOverview: React.FunctionComponent<{ dependencies?: typeof DEPENDENCIES }> = ({ dependencies: d = DEPENDENCIES }) => { const overview = d.useAccountBalanceOverview(); + const [hoveredKey, setHoveredKey] = useState(null); + const [isBreakdownOpen, setIsBreakdownOpen] = useState(false); const usd = (value: number) => ; if (overview.isLoading) { @@ -47,7 +61,7 @@ export const AccountBalanceOverview: React.FunctionComponent<{ dependencies?: ty return ( -

Account balance

+

Account balance

@@ -59,49 +73,93 @@ export const AccountBalanceOverview: React.FunctionComponent<{ dependencies?: ty {hasRunway && ( {`${overview.runwayDays} days of runway`} )} - - {usd(overview.available)} - {" available"} -
{hasRunway && (

- Spending {usd(overview.perHour)}/hr · lasts until {format(overview.lastsUntil!, "MMM d")} + Spending {usd(overview.perHour)}/hr · lasts until{" "} + {format(overview.lastsUntil!, "MMM d, yyyy")}

)}
- + - {segments.length > 0 && ( -
    - {segments.map(segment => ( -
  • - - - {segment.label} +
    +
    +
    + + Reserved + + + - {usd(segment.amountUsd)} -
  • - ))} -
- )} + +
+
+ {usd(overview.reserved)} +
+

+ {`Held to keep your ${overview.activeDeploymentCount} deployment${overview.activeDeploymentCount === 1 ? "" : "s"} running`} +

+ +
+
+ + Available +
+
+ {usd(overview.available)} +
+

Free to spend on something new

+
+ -

- {overview.reserved > 0 && ( - <> - {usd(overview.reserved)} - {` is reserved to keep your ${overview.activeDeploymentCount} running deployment${overview.activeDeploymentCount === 1 ? "" : "s"} online. `} - - )} - {usd(overview.available)} - {" is available for new deployments."} - - - -

+ {overview.deployments.length > 0 && ( +
+ + {isBreakdownOpen && ( +
+
    + {segments + .filter(segment => segment.key !== "available") + .map(segment => ( +
  • setHoveredKey(segment.key)} + onMouseLeave={() => setHoveredKey(null)} + > + + + {segment.label} + {segment.perHourUsd !== undefined && {usd(segment.perHourUsd)}/hr} + {usd(segment.amountUsd)} + +
  • + ))} +
+

{`Each running deployment keeps a minimum of ${RESERVE_WINDOW_HOURS} hours of its cost in reserve.`}

+
+ )} +
+ )} - {overview.autoReloadEnabled &&

Auto Recharge is on — your deployments stay funded.

} + {overview.autoReloadThreshold == null && overview.autoReloadEnabled && ( +

Auto Recharge is on. Your deployments stay funded.

+ )} ); diff --git a/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/BalanceBreakdownBar.spec.tsx b/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/BalanceBreakdownBar.spec.tsx index 5308d7e0f8..0a1490e915 100644 --- a/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/BalanceBreakdownBar.spec.tsx +++ b/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/BalanceBreakdownBar.spec.tsx @@ -31,8 +31,15 @@ describe(buildBalanceSegments.name, () => { expect(buildBalanceSegments(deployments([0]), 0)).toEqual([]); }); + it("carries each deployment's hourly rate onto its reserved segment but not the available one", () => { + const segments = buildBalanceSegments(deployments([120]), 200); + + expect(segments[0].perHourUsd).toBe(1); + expect(segments[1].perHourUsd).toBeUndefined(); + }); + function deployments(amounts: number[]): ReservedDeployment[] { - return amounts.map((reservedUsd, index) => ({ dseq: `dseq-${index}`, name: `deployment-${index}`, reservedUsd })); + return amounts.map((reservedUsd, index) => ({ dseq: `dseq-${index}`, name: `deployment-${index}`, reservedUsd, perHourUsd: reservedUsd / 120 })); } }); @@ -55,7 +62,38 @@ describe(BalanceBreakdownBar.name, () => { expect(screen.getByRole("img").getAttribute("aria-label")).toContain("llama"); }); - function setup(segments: Parameters[0]["segments"]) { - return render(); + it("marks the auto top-up threshold when one is provided", () => { + setup( + [ + { key: "d1", label: "llama", amountUsd: 1000, color: "hsl(var(--primary))" }, + { key: "available", label: "Available", amountUsd: 1000, color: "hsl(var(--success))" } + ], + 250 + ); + + expect(screen.getByText(/Tops up at/)).toHaveTextContent("$250"); + }); + + it("omits the threshold marker when no threshold is provided", () => { + setup([{ key: "available", label: "Available", amountUsd: 1000, color: "hsl(var(--success))" }]); + + expect(screen.queryByText(/Tops up at/)).not.toBeInTheDocument(); + }); + + it("positions the marker as the far-right slice of the bar", () => { + setup( + [ + { key: "d1", label: "llama", amountUsd: 1000, color: "hsl(var(--primary))" }, + { key: "available", label: "Available", amountUsd: 1000, color: "hsl(var(--success))" } + ], + 250 + ); + + expect(screen.getByTestId("balance-threshold-hatch").style.width).toBe("12.5%"); + expect(screen.getByTestId("balance-threshold-line").style.left).toBe("87.5%"); + }); + + function setup(segments: Parameters[0]["segments"], threshold?: number | null) { + return render(); } }); diff --git a/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/BalanceBreakdownBar.tsx b/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/BalanceBreakdownBar.tsx index eeee7174b9..5ea464bd0b 100644 --- a/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/BalanceBreakdownBar.tsx +++ b/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/BalanceBreakdownBar.tsx @@ -1,5 +1,6 @@ "use client"; import React from "react"; +import { Flash } from "iconoir-react"; import type { ReservedDeployment } from "./useAccountBalanceOverview"; @@ -8,6 +9,12 @@ export type BalanceSegment = { label: string; amountUsd: number; color: string; + /** Hourly burn rate for this deployment; omitted for the Available segment. */ + perHourUsd?: number; + /** Tinted pill background for the legend chip, following this segment's shade. */ + badgeBackground?: string; + /** Readable solid text color for the legend chip. */ + badgeColor?: string; }; const usdFormatter = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }); @@ -18,36 +25,111 @@ function reservedAlpha(index: number, count: number): number { return Number((0.9 - (index / (count - 1)) * 0.55).toFixed(3)); } +/** Legend chip background follows the segment's shade at a low, readable alpha (floored so faint steps stay visible). */ +function badgeBackground(hue: string, alpha: number): string { + return `hsl(${hue} / ${Number(Math.max(0.08, alpha * 0.2).toFixed(3))})`; +} + +/** Diagonal hatch overlaid on the available bar to flag the auto-top-up trigger zone; theme-aware via the foreground token. */ +const HATCH_BACKGROUND = + "repeating-linear-gradient(45deg, transparent 0, transparent 3px, hsl(var(--foreground) / 0.28) 3px, hsl(var(--foreground) / 0.28) 6px)"; + +/** Positions the auto-top-up marker as the far-right slice of the bar: the dotted line is its left edge, the hatch is the slice itself. */ +function buildThresholdMarker(threshold: number, available: number, total: number) { + const markerAmount = Math.min(threshold, available); + return { + threshold, + hatchWidthPct: (markerAmount / total) * 100, + linePositionPct: ((total - markerAmount) / total) * 100 + }; +} + /** * Builds the ordered segments for the balance bar and its legend so both share identical colors. * Reserved deployments use a single-hue ramp (sorted largest-first); Available is the success green. */ export function buildBalanceSegments(deployments: ReservedDeployment[], available: number): BalanceSegment[] { - const reservedSegments = deployments.map((deployment, index) => ({ - key: deployment.dseq, - label: deployment.name, - amountUsd: deployment.reservedUsd, - color: `hsl(var(--primary) / ${reservedAlpha(index, deployments.length)})` - })); - - return [...reservedSegments, { key: "available", label: "Available", amountUsd: available, color: "hsl(var(--success))" }].filter( - segment => segment.amountUsd > 0 - ); + const reservedSegments = deployments.map((deployment, index) => { + const alpha = reservedAlpha(index, deployments.length); + return { + key: deployment.dseq, + label: deployment.name, + amountUsd: deployment.reservedUsd, + perHourUsd: deployment.perHourUsd, + color: `hsl(var(--primary) / ${alpha})`, + badgeBackground: badgeBackground("var(--primary)", alpha), + badgeColor: "hsl(var(--primary))" + }; + }); + + const availableSegment = { + key: "available", + label: "Available", + amountUsd: available, + color: "hsl(var(--success))", + badgeBackground: "hsl(var(--success) / 0.14)", + badgeColor: "hsl(var(--success))" + }; + + return [...reservedSegments, availableSegment].filter(segment => segment.amountUsd > 0); } -export const BalanceBreakdownBar: React.FunctionComponent<{ segments: BalanceSegment[] }> = ({ segments }) => { +export const BalanceBreakdownBar: React.FunctionComponent<{ + segments: BalanceSegment[]; + hoveredKey?: string | null; + onHover?: (key: string | null) => void; + threshold?: number | null; +}> = ({ segments, hoveredKey = null, onHover, threshold = null }) => { const label = segments.map(segment => `${segment.label} ${usdFormatter.format(segment.amountUsd)}`).join(", "); + const total = segments.reduce((sum, segment) => sum + segment.amountUsd, 0); + const available = segments.find(segment => segment.key === "available")?.amountUsd ?? 0; + const marker = threshold !== null && threshold > 0 && available > 0 && total > 0 ? buildThresholdMarker(threshold, available, total) : null; return ( -
- {segments.map(segment => ( -
- ))} +
+
+
+ {segments.map(segment => ( +
onHover?.(segment.key)} + onMouseLeave={() => onHover?.(null)} + /> + ))} + {marker && ( +
+ )} +
+ {marker && ( +
+ )} +
+ {marker && ( +

+ + + Tops up at {usdFormatter.format(marker.threshold)} + +

+ )}
); }; diff --git a/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/useAccountBalanceOverview.spec.ts b/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/useAccountBalanceOverview.spec.ts index 75166a0a30..f65134d4b7 100644 --- a/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/useAccountBalanceOverview.spec.ts +++ b/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/useAccountBalanceOverview.spec.ts @@ -30,8 +30,8 @@ describe(useAccountBalanceOverview.name, () => { }); expect(result.current.deployments).toEqual([ - { dseq: "2", name: "llama-chat", reservedUsd: 100 }, - { dseq: "1", name: "Deployment 1", reservedUsd: 50 } + { dseq: "2", name: "llama-chat", reservedUsd: 100, perHourUsd: 0 }, + { dseq: "1", name: "Deployment 1", reservedUsd: 50, perHourUsd: 0 } ]); expect(result.current.activeDeploymentCount).toBe(2); }); @@ -51,12 +51,43 @@ describe(useAccountBalanceOverview.name, () => { expect(result.current.lastsUntil).toBeNull(); }); + it("computes each deployment's hourly burn rate from its live leases", () => { + const { result } = setup({ + deployments: [ + { dseq: "1", fundsUsd: 200 }, + { dseq: "2", fundsUsd: 100 } + ], + leases: [{ dseq: "1", amountUakt: "1000000" }] + }); + + expect(result.current.deployments[0].perHourUsd).toBeGreaterThan(0); + expect(result.current.deployments[1].perHourUsd).toBe(0); + }); + it("passes through the auto reload setting", () => { const { result } = setup({ autoReloadEnabled: true }); expect(result.current.autoReloadEnabled).toBe(true); }); + it("exposes the auto reload threshold when the fixed-threshold flag and auto reload are both on", () => { + const { result } = setup({ fixedThresholdEnabled: true, autoReloadEnabled: true, autoReloadThreshold: 275 }); + + expect(result.current.autoReloadThreshold).toBe(275); + }); + + it("hides the auto reload threshold when the fixed-threshold flag is off", () => { + const { result } = setup({ fixedThresholdEnabled: false, autoReloadEnabled: true, autoReloadThreshold: 275 }); + + expect(result.current.autoReloadThreshold).toBeNull(); + }); + + it("hides the auto reload threshold when auto reload is off", () => { + const { result } = setup({ fixedThresholdEnabled: true, autoReloadEnabled: false, autoReloadThreshold: 275 }); + + expect(result.current.autoReloadThreshold).toBeNull(); + }); + it("is loading until the wallet balance resolves", () => { const { result } = setup({ walletBalanceMissing: true }); @@ -68,8 +99,11 @@ describe(useAccountBalanceOverview.name, () => { reservedUsd?: number; deployments?: Array<{ dseq: string; fundsUsd: number }>; names?: Record; + leases?: Array<{ dseq: string; amountUakt?: string }>; hasLiveLease?: boolean; autoReloadEnabled?: boolean; + autoReloadThreshold?: number; + fixedThresholdEnabled?: boolean; walletBalanceMissing?: boolean; }) { const activeDeployments = (input.deployments ?? []).map(deployment => ({ @@ -77,13 +111,18 @@ describe(useAccountBalanceOverview.name, () => { escrowAccount: { state: { funds: [{ denom: UAKT_DENOM, amount: String(deployment.fundsUsd) }] } } })); - const leases = input.hasLiveLease ? [{ state: "active", price: { denom: UAKT_DENOM, amount: "1000000" } }] : []; + const leases = input.leases + ? input.leases.map(lease => ({ dseq: lease.dseq, state: "active", price: { denom: UAKT_DENOM, amount: lease.amountUakt ?? "1000000" } })) + : input.hasLiveLease + ? [{ state: "active", price: { denom: UAKT_DENOM, amount: "1000000" } }] + : []; const dependencies = { ...DEPENDENCIES, useWallet: () => ({ address: "akash1abc" }), usePricing: () => ({ price: 1, isLoaded: true, udenomToUsd: (amount: string | number) => Number(amount) }), useUsdcDenom: () => "ibc/usdc", + useFlag: () => input.fixedThresholdEnabled ?? false, useWalletBalance: () => ({ balance: input.walletBalanceMissing ? null : { totalUsd: input.totalUsd ?? 0, totalDeploymentEscrowUSD: input.reservedUsd ?? 0 }, isLoading: false, @@ -91,7 +130,7 @@ describe(useAccountBalanceOverview.name, () => { }), useBalances: () => ({ data: { activeDeployments } }), useAllLeases: () => ({ data: leases }), - useWalletSettingsQuery: () => ({ data: { autoReloadEnabled: input.autoReloadEnabled ?? false } }), + useWalletSettingsQuery: () => ({ data: { autoReloadEnabled: input.autoReloadEnabled ?? false, autoReloadThreshold: input.autoReloadThreshold } }), useLocalNotes: () => ({ getDeploymentName: (dseq: string | number | null) => input.names?.[String(dseq)] ?? null }) } as unknown as typeof DEPENDENCIES; diff --git a/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/useAccountBalanceOverview.ts b/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/useAccountBalanceOverview.ts index bc74ab3576..5e1d5a891d 100644 --- a/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/useAccountBalanceOverview.ts +++ b/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/useAccountBalanceOverview.ts @@ -4,6 +4,7 @@ import { useMemo } from "react"; import { useLocalNotes } from "@src/components/LocalNoteManager/useLocalNotes"; import { useWallet } from "@src/context/WalletProvider"; import { useUsdcDenom } from "@src/hooks/useDenom"; +import { useFlag } from "@src/hooks/useFlag"; import { usePricing } from "@src/hooks/usePricing/usePricing"; import { useWalletBalance } from "@src/hooks/useWalletBalance"; import { useWalletSettingsQuery } from "@src/queries"; @@ -16,6 +17,7 @@ export const DEPENDENCIES = { useWallet, usePricing, useUsdcDenom, + useFlag, useWalletBalance, useBalances, useAllLeases, @@ -27,6 +29,7 @@ export type ReservedDeployment = { dseq: string; name: string; reservedUsd: number; + perHourUsd: number; }; export type AccountBalanceOverview = { @@ -41,6 +44,8 @@ export type AccountBalanceOverview = { lastsUntil: Date | null; runwayDays: number | null; autoReloadEnabled: boolean; + /** The auto-top-up trigger balance to mark on the bar, or null when no marker should render (flag off, auto-reload off, or unset). */ + autoReloadThreshold: number | null; isLoading: boolean; }; @@ -50,11 +55,22 @@ export function useAccountBalanceOverview({ dependencies: d = DEPENDENCIES }: { const { address } = d.useWallet(); const { price, isLoaded: isPriceLoaded, udenomToUsd } = d.usePricing(); const usdcDenom = d.useUsdcDenom(); - const { balance: walletBalance, isLoading: isWalletBalanceLoading } = d.useWalletBalance(); + const { balance: walletBalance } = d.useWalletBalance(); const { data: balances } = d.useBalances(address); const { data: leases } = d.useAllLeases(address); const { data: walletSettings } = d.useWalletSettingsQuery(); const { getDeploymentName } = d.useLocalNotes(); + const isFixedThresholdEnabled = d.useFlag("auto_reload_fixed_threshold"); + + const perHourByDseq = useMemo(() => { + const map = new Map(); + if (!leases || !isPriceLoaded) return map; + for (const lease of leases.filter(isLeaseLive)) { + const perBlockUsd = getLeasesCostPerBlockUsd([lease], price ?? 0, usdcDenom); + map.set(lease.dseq, (map.get(lease.dseq) ?? 0) + perBlockToHourly(perBlockUsd)); + } + return map; + }, [leases, isPriceLoaded, price, usdcDenom]); const deployments = useMemo(() => { if (!balances) return []; @@ -62,10 +78,11 @@ export function useAccountBalanceOverview({ dependencies: d = DEPENDENCIES }: { .map(deployment => ({ dseq: deployment.dseq, name: getDeploymentName(deployment.dseq) ?? `Deployment ${deployment.dseq}`, - reservedUsd: deployment.escrowAccount.state.funds.reduce((sum, fund) => sum + udenomToUsd(fund.amount, fund.denom), 0) + reservedUsd: deployment.escrowAccount.state.funds.reduce((sum, fund) => sum + udenomToUsd(fund.amount, fund.denom), 0), + perHourUsd: perHourByDseq.get(deployment.dseq) ?? 0 })) .sort((a, b) => b.reservedUsd - a.reservedUsd); - }, [balances, getDeploymentName, udenomToUsd]); + }, [balances, getDeploymentName, udenomToUsd, perHourByDseq]); const spend = useMemo(() => { if (!leases || !isPriceLoaded) return { perBlockUsd: 0, perHour: 0, perMonth: 0 }; @@ -77,6 +94,8 @@ export function useAccountBalanceOverview({ dependencies: d = DEPENDENCIES }: { const reserved = walletBalance?.totalDeploymentEscrowUSD ?? 0; const available = Math.max(0, totalUsd - reserved); const hasSpend = spend.perBlockUsd > 0; + const autoReloadEnabled = walletSettings?.autoReloadEnabled ?? false; + const autoReloadThreshold = isFixedThresholdEnabled && autoReloadEnabled ? walletSettings?.autoReloadThreshold ?? null : null; return { totalUsd, @@ -88,7 +107,8 @@ export function useAccountBalanceOverview({ dependencies: d = DEPENDENCIES }: { perMonth: spend.perMonth, lastsUntil: hasSpend ? getTimeLeft(spend.perBlockUsd, totalUsd) : null, runwayDays: hasSpend ? Math.floor(totalUsd / spend.perHour / HOURS_PER_DAY) : null, - autoReloadEnabled: walletSettings?.autoReloadEnabled ?? false, - isLoading: isWalletBalanceLoading || !walletBalance + autoReloadEnabled, + autoReloadThreshold, + isLoading: !walletBalance }; } diff --git a/apps/deploy-web/src/components/billing-usage/AutoTopUpSection/AutoTopUpSection.spec.tsx b/apps/deploy-web/src/components/billing-usage/AutoTopUpSection/AutoTopUpSection.spec.tsx index a790eaf2b3..35d73cc241 100644 --- a/apps/deploy-web/src/components/billing-usage/AutoTopUpSection/AutoTopUpSection.spec.tsx +++ b/apps/deploy-web/src/components/billing-usage/AutoTopUpSection/AutoTopUpSection.spec.tsx @@ -20,20 +20,23 @@ describe(AutoTopUpSection.name, () => { expect(screen.getByText("Auto Top-Up")).toBeInTheDocument(); }); - it("shows the threshold summary with stored values when enabled", () => { + it("shows the threshold and top-up amounts when enabled", () => { setup({ isFixedThresholdEnabled: true, defaultPaymentMethod: { id: "pm_123" }, walletSettings: { autoReloadEnabled: true, autoReloadThreshold: 20, autoReloadAmount: 100 } }); - expect(screen.getByText(/Top up/)).toHaveTextContent("Top up 100 when balance ≤ 20"); + expect(screen.getByText("Threshold")).toBeInTheDocument(); + expect(screen.getByText("Top up")).toBeInTheDocument(); + expect(screen.getByText("20")).toBeInTheDocument(); + expect(screen.getByText("100")).toBeInTheDocument(); }); - it("shows 'Add funds automatically' when disabled with a payment method", () => { + it("prompts to turn on auto top-up when disabled with a payment method", () => { setup({ isFixedThresholdEnabled: true, defaultPaymentMethod: { id: "pm_123" }, walletSettings: { autoReloadEnabled: false } }); - expect(screen.getByText("Add funds automatically")).toBeInTheDocument(); + expect(screen.getByText(/Turn on Auto Top-Up to add funds automatically/)).toBeInTheDocument(); }); it("opens the settings dialog in enable mode without mutating when the switch is turned on", () => { @@ -86,6 +89,15 @@ describe(AutoTopUpSection.name, () => { expect(screen.getByText(/Add a payment method/)).toBeInTheDocument(); }); + it("opens the add-payment-method flow from the prompt when there is no payment method", () => { + const openAddPaymentMethod = vi.fn(); + setup({ isFixedThresholdEnabled: true, defaultPaymentMethod: undefined, openAddPaymentMethod }); + + fireEvent.click(screen.getByText("Add a payment method")); + + expect(openAddPaymentMethod).toHaveBeenCalledTimes(1); + }); + it("disables the edit button while a settings update is in flight", () => { setup({ isFixedThresholdEnabled: true, @@ -158,9 +170,13 @@ describe(AutoTopUpSection.name, () => { upsertMutate?: ReturnType; enqueueSnackbar?: ReturnType; isPending?: boolean; + perHour?: number; + available?: number; + openAddPaymentMethod?: ReturnType; }) { const upsertMutate = input.upsertMutate ?? vi.fn(); const enqueueSnackbar = input.enqueueSnackbar ?? vi.fn(); + const openAddPaymentMethod = input.openAddPaymentMethod ?? vi.fn(); const MockButton = vi.fn(({ children, ...props }: Parameters[0]) => ); const MockSwitch = vi.fn(({ checked, onCheckedChange, disabled }: Parameters[0]) => ( @@ -173,11 +189,12 @@ describe(AutoTopUpSection.name, () => { useFlag: vi.fn(() => input.isFixedThresholdEnabled ?? false), useSnackbar: vi.fn(() => ({ enqueueSnackbar })), useDefaultPaymentMethodQuery: vi.fn(() => ({ data: input.defaultPaymentMethod, isLoading: false })), - useWalletSettingsQuery: vi.fn(() => ({ data: input.walletSettings ?? { autoReloadEnabled: false } })), + useWalletSettingsQuery: vi.fn(() => ({ data: input.walletSettings ?? { autoReloadEnabled: false }, isLoading: false })), useWeeklyDeploymentCostQuery: vi.fn(() => ({ data: input.weeklyCost ?? 5 })), useWalletSettingsMutations: vi.fn(() => ({ upsertWalletSettings: { mutate: upsertMutate, isPending: input.isPending ?? false } })), + useAccountBalanceOverview: vi.fn(() => ({ perHour: input.perHour ?? 0, available: input.available ?? 0 })), + useBillingActions: vi.fn(() => ({ openAddPaymentMethod })), usePopup: vi.fn(() => ({ confirm: vi.fn().mockResolvedValue(input.confirmResult ?? true) })), - useServices: vi.fn(() => ({ urlService: { billing: () => "/billing", paymentMethods: () => "/payment-methods" } })), Button: MockButton, Switch: MockSwitch, FormattedNumber: MockFormattedNumber @@ -185,6 +202,6 @@ describe(AutoTopUpSection.name, () => { render(); - return { dependencies, upsertMutate, enqueueSnackbar }; + return { dependencies, upsertMutate, enqueueSnackbar, openAddPaymentMethod }; } }); diff --git a/apps/deploy-web/src/components/billing-usage/AutoTopUpSection/AutoTopUpSection.tsx b/apps/deploy-web/src/components/billing-usage/AutoTopUpSection/AutoTopUpSection.tsx index af6bbd151e..3e69176844 100644 --- a/apps/deploy-web/src/components/billing-usage/AutoTopUpSection/AutoTopUpSection.tsx +++ b/apps/deploy-web/src/components/billing-usage/AutoTopUpSection/AutoTopUpSection.tsx @@ -1,21 +1,25 @@ "use client"; import React, { useCallback, useMemo, useState } from "react"; import { FormattedNumber } from "react-intl"; -import { Button, Card, CardContent, CardHeader, CustomTooltip, Snackbar, Switch } from "@akashnetwork/ui/components"; +import type { PaymentMethod } from "@akashnetwork/http-sdk"; +import { Button, Card, CardContent, CardHeader, Skeleton, Snackbar, Switch } from "@akashnetwork/ui/components"; import { usePopup } from "@akashnetwork/ui/context"; import { LinearProgress } from "@mui/material"; -import { Edit, InfoCircle } from "iconoir-react"; -import Link from "next/link"; +import { Edit } from "iconoir-react"; import { useSnackbar } from "notistack"; +import { useAccountBalanceOverview } from "@src/components/billing-usage/AccountBalanceOverview/useAccountBalanceOverview"; import { AutoTopUpSettingsPopup, DEFAULT_AUTO_RELOAD_AMOUNT, DEFAULT_AUTO_RELOAD_THRESHOLD } from "@src/components/billing-usage/AutoTopUpSettingsPopup/AutoTopUpSettingsPopup"; -import { useServices } from "@src/context/ServicesProvider/ServicesProvider"; +import { useBillingActions } from "@src/components/billing-usage/BillingActionsProvider/BillingActionsProvider"; import { useFlag } from "@src/hooks/useFlag"; import { useDefaultPaymentMethodQuery, useWalletSettingsMutations, useWalletSettingsQuery, useWeeklyDeploymentCostQuery } from "@src/queries"; +import { capitalizeFirstLetter } from "@src/utils/stringUtils"; + +const HOURS_PER_DAY = 24; export const DEPENDENCIES = { useSnackbar, @@ -23,17 +27,17 @@ export const DEPENDENCIES = { useWalletSettingsQuery, useWeeklyDeploymentCostQuery, useWalletSettingsMutations, + useAccountBalanceOverview, usePopup, - useServices, + useBillingActions, useFlag, AutoTopUpSettingsPopup, FormattedNumber, - Link, Button, Card, CardContent, CardHeader, - CustomTooltip, + Skeleton, Snackbar, Switch, Edit, @@ -45,11 +49,12 @@ export const AutoTopUpSection: React.FunctionComponent<{ dependencies?: typeof D const isFixedThresholdEnabled = d.useFlag("auto_reload_fixed_threshold"); const { enqueueSnackbar } = d.useSnackbar(); const { data: defaultPaymentMethod } = d.useDefaultPaymentMethodQuery(); - const { data: walletSettings } = d.useWalletSettingsQuery(); + const { data: walletSettings, isLoading: isWalletSettingsLoading } = d.useWalletSettingsQuery(); const { data: weeklyCost } = d.useWeeklyDeploymentCostQuery({ enabled: !isFixedThresholdEnabled }); const { upsertWalletSettings } = d.useWalletSettingsMutations(); + const { openAddPaymentMethod } = d.useBillingActions(); + const overview = d.useAccountBalanceOverview(); const { confirm } = d.usePopup(); - const { urlService } = d.useServices(); const toggleAutoReload = useCallback( async (autoReloadEnabled: boolean) => { @@ -113,11 +118,27 @@ export const AutoTopUpSection: React.FunctionComponent<{ dependencies?: typeof D const hasPaymentMethod = !!defaultPaymentMethod; const autoReloadThreshold = walletSettings?.autoReloadThreshold ?? DEFAULT_AUTO_RELOAD_THRESHOLD; const autoReloadAmount = walletSettings?.autoReloadAmount ?? DEFAULT_AUTO_RELOAD_AMOUNT; + const autoReloadEnabled = walletSettings?.autoReloadEnabled ?? false; + const isFirstLoad = isWalletSettingsLoading && !walletSettings; const isReloadChangeDisabled = useMemo(() => { return !hasPaymentMethod || upsertWalletSettings.isPending; }, [hasPaymentMethod, upsertWalletSettings.isPending]); + const defaultCardLabel = useMemo(() => { + const card = (defaultPaymentMethod as PaymentMethod | null | undefined)?.card; + if (!card) return null; + return `${capitalizeFirstLetter(card.brand || "card")} •••• ${card.last4 || ""}`.trim(); + }, [defaultPaymentMethod]); + + const nextTopUpDays = useMemo(() => { + const dailySpend = overview.perHour * HOURS_PER_DAY; + if (dailySpend <= 0 || overview.available <= autoReloadThreshold) return null; + return Math.max(1, Math.round((overview.available - autoReloadThreshold) / dailySpend)); + }, [overview.perHour, overview.available, autoReloadThreshold]); + + const usd = (value: number) => ; + return ( <> @@ -126,69 +147,89 @@ export const AutoTopUpSection: React.FunctionComponent<{ dependencies?: typeof D
)} - -

{isFixedThresholdEnabled ? "Auto Top-Up" : "Auto Recharge"}

- - - -
- -
- - {!hasPaymentMethod ? ( + +
+

{isFixedThresholdEnabled ? "Auto Top-Up" : "Auto Recharge"}

+ {isFixedThresholdEnabled ? (

- - Add a payment method - {" "} - to enable auto {isFixedThresholdEnabled ? "top-up" : "recharge"} + Tops up when your available balance runs low.

- ) : isFixedThresholdEnabled ? ( - walletSettings?.autoReloadEnabled ? ( -
-

- Top up{" "} - - - {" "} - when balance ≤{" "} - - - -

+ ) : ( +

Automatically adds credits to keep your deployments running.

+ )} +
+ + + + {isFirstLoad ? ( +
+ + +
+ ) : !hasPaymentMethod ? ( +

+ {" "} + to enable auto {isFixedThresholdEnabled ? "top-up" : "recharge"} +

+ ) : isFixedThresholdEnabled ? ( + autoReloadEnabled ? ( +
+
+
+
+

Threshold

+

{usd(autoReloadThreshold)}

+

when available drops below

+
+
+

Top up

+

{usd(autoReloadAmount)}

+

added each time

+
+
setAutoTopUpPopup({ open: true, enableOnSave: false })} > + Edit
- ) : ( -

Add funds automatically

- ) + {defaultCardLabel && ( +

+ Charges {defaultCardLabel}, at most once per hour. + {nextTopUpDays !== null && ( + <> + {" "} + Next top-up in about {`${nextTopUpDays} day${nextTopUpDays === 1 ? "" : "s"}`}{" "} + based on your current spend rate. + + )} +

+ )} +
) : ( -

- Recharge amount is approximately{" "} - - - {" "} - per week -

- )} -
+

Turn on Auto Top-Up to add funds automatically when your balance runs low.

+ ) + ) : ( +

+ Recharge amount is approximately{" "} + + + {" "} + per week +

+ )} diff --git a/apps/deploy-web/src/components/billing-usage/BillingActionsProvider/BillingActionsProvider.spec.tsx b/apps/deploy-web/src/components/billing-usage/BillingActionsProvider/BillingActionsProvider.spec.tsx new file mode 100644 index 0000000000..8eef730a07 --- /dev/null +++ b/apps/deploy-web/src/components/billing-usage/BillingActionsProvider/BillingActionsProvider.spec.tsx @@ -0,0 +1,92 @@ +import React from "react"; +import { describe, expect, it, vi } from "vitest"; + +import { BillingActionsProvider, type DEPENDENCIES, useBillingActions } from "./BillingActionsProvider"; + +import { act, fireEvent, render, screen } from "@testing-library/react"; + +const Consumer = () => { + const { openAddPaymentMethod } = useBillingActions(); + return ( + + ); +}; + +const MockPopup = ({ open, onClose, clientSecret, isDarkMode, onSuccess }: any) => + open ? ( +
+ {clientSecret} + {isDarkMode ? "dark" : "light"} + + +
+ ) : null; + +describe("BillingActionsProvider", () => { + it("creates a setup intent and opens the popup when the add flow is triggered", () => { + const { createSetupIntent, resetSetupIntent } = setup(); + + fireEvent.click(screen.getByText("open")); + + expect(resetSetupIntent).toHaveBeenCalled(); + expect(createSetupIntent).toHaveBeenCalled(); + expect(screen.getByTestId("popup")).toBeInTheDocument(); + }); + + it("passes the setup-intent client secret and dark mode to the popup", () => { + setup({ setupIntent: { clientSecret: "cs_123" }, theme: "dark" }); + + fireEvent.click(screen.getByText("open")); + + expect(screen.getByTestId("secret")).toHaveTextContent("cs_123"); + expect(screen.getByTestId("dark")).toHaveTextContent("dark"); + }); + + it("closes the popup and refreshes payment methods on success", async () => { + const { refresh } = setup(); + + fireEvent.click(screen.getByText("open")); + await act(async () => { + fireEvent.click(screen.getByText("success")); + }); + + expect(refresh).toHaveBeenCalled(); + expect(screen.queryByTestId("popup")).not.toBeInTheDocument(); + }); + + it("closes the popup when the popup requests it", () => { + setup(); + + fireEvent.click(screen.getByText("open")); + fireEvent.click(screen.getByText("close")); + + expect(screen.queryByTestId("popup")).not.toBeInTheDocument(); + }); + + function setup(input: { setupIntent?: { clientSecret: string }; theme?: string } = {}) { + const createSetupIntent = vi.fn(); + const resetSetupIntent = vi.fn(); + const refresh = vi.fn().mockResolvedValue(undefined); + + const dependencies = { + useTheme: () => ({ resolvedTheme: input.theme ?? "light" }), + useSetupIntentMutation: () => ({ data: input.setupIntent, mutate: createSetupIntent, reset: resetSetupIntent }), + useRefreshPaymentMethods: () => refresh, + AddPaymentMethodPopup: MockPopup + } as unknown as typeof DEPENDENCIES; + + render( + + + + ); + + return { createSetupIntent, resetSetupIntent, refresh }; + } +}); diff --git a/apps/deploy-web/src/components/billing-usage/BillingActionsProvider/BillingActionsProvider.tsx b/apps/deploy-web/src/components/billing-usage/BillingActionsProvider/BillingActionsProvider.tsx new file mode 100644 index 0000000000..0c1e616792 --- /dev/null +++ b/apps/deploy-web/src/components/billing-usage/BillingActionsProvider/BillingActionsProvider.tsx @@ -0,0 +1,64 @@ +"use client"; +import type { ReactNode } from "react"; +import React, { createContext, useCallback, useContext, useMemo, useState } from "react"; +import { useTheme } from "next-themes"; + +import { AddPaymentMethodPopup } from "@src/components/billing-usage/AddPaymentMethodPopup/AddPaymentMethodPopup"; +import { useRefreshPaymentMethods, useSetupIntentMutation } from "@src/queries"; + +export const DEPENDENCIES = { + useTheme, + useSetupIntentMutation, + useRefreshPaymentMethods, + AddPaymentMethodPopup +}; + +type BillingActions = { + openAddPaymentMethod: () => void; +}; + +const BillingActionsContext = createContext(null); + +export const BillingActionsProvider: React.FunctionComponent<{ children: ReactNode; dependencies?: typeof DEPENDENCIES }> = ({ + children, + dependencies: d = DEPENDENCIES +}) => { + const { resolvedTheme } = d.useTheme(); + const { data: setupIntent, mutate: createSetupIntent, reset: resetSetupIntent } = d.useSetupIntentMutation(); + const refreshPaymentMethods = d.useRefreshPaymentMethods(); + const [isOpen, setIsOpen] = useState(false); + + const openAddPaymentMethod = useCallback(() => { + resetSetupIntent(); + createSetupIntent(); + setIsOpen(true); + }, [createSetupIntent, resetSetupIntent]); + + const onAddCardSuccess = useCallback(async () => { + setIsOpen(false); + await refreshPaymentMethods(); + }, [refreshPaymentMethods]); + + const value = useMemo(() => ({ openAddPaymentMethod }), [openAddPaymentMethod]); + + return ( + + {children} + setIsOpen(false)} + clientSecret={setupIntent?.clientSecret} + isDarkMode={resolvedTheme === "dark"} + onSuccess={onAddCardSuccess} + /> + + ); +}; + +export function useBillingActions(): BillingActions { + const context = useContext(BillingActionsContext); + if (!context) { + throw new Error("useBillingActions must be used within a BillingActionsProvider"); + } + return context; +} diff --git a/apps/deploy-web/src/components/billing-usage/BillingContainer/BillingContainer.spec.tsx b/apps/deploy-web/src/components/billing-usage/BillingContainer/BillingContainer.spec.tsx index a62e0310ad..03e92ae3cf 100644 --- a/apps/deploy-web/src/components/billing-usage/BillingContainer/BillingContainer.spec.tsx +++ b/apps/deploy-web/src/components/billing-usage/BillingContainer/BillingContainer.spec.tsx @@ -62,6 +62,7 @@ describe(BillingContainer.name, () => { async function setup( overrides: Partial<{ data: { transactions: BillingTransaction[]; hasMore: boolean; totalCount: number }; + isLoading: boolean; isFetching: boolean; isError: boolean; queryError: Error; @@ -76,6 +77,7 @@ describe(BillingContainer.name, () => { } : overrides.data; + const isLoading = overrides.isLoading ?? false; const isFetching = overrides.isFetching ?? false; const isError = overrides.isError ?? false; const queryError = overrides.queryError ?? null; @@ -86,6 +88,7 @@ describe(BillingContainer.name, () => { const mockedUsePaymentTransactionsQuery = vi.fn(() => ({ data, + isLoading, isFetching, isError, error: queryError diff --git a/apps/deploy-web/src/components/billing-usage/BillingContainer/BillingContainer.tsx b/apps/deploy-web/src/components/billing-usage/BillingContainer/BillingContainer.tsx index e746f54789..7a643ac428 100644 --- a/apps/deploy-web/src/components/billing-usage/BillingContainer/BillingContainer.tsx +++ b/apps/deploy-web/src/components/billing-usage/BillingContainer/BillingContainer.tsx @@ -3,6 +3,7 @@ import { ApiError, extractApiErrorMessage } from "@akashnetwork/openapi-sdk"; import { useToast } from "@akashnetwork/ui/hooks"; import type { PaginationState } from "@tanstack/react-table"; import axios from "axios"; +import { endOfToday, subYears } from "date-fns"; import { useServices } from "@src/context/ServicesProvider"; import type { BillingTransaction } from "@src/queries"; @@ -18,6 +19,7 @@ export type ChildrenProps = { data: BillingTransaction[]; hasMore: boolean; hasPrevious: boolean; + isLoading: boolean; isFetching: boolean; isError: boolean; errorMessage: string; @@ -25,7 +27,7 @@ export type ChildrenProps = { onPaginationChange: (state: PaginationState) => void; pagination: PaginationState; totalCount: number; - dateRange: { from: Date; to: Date }; + dateRange: { from: Date; to: Date } | null; onDateRangeChange: (range: { from: Date; to: Date }) => void; }; @@ -37,23 +39,22 @@ type BillingContainerProps = { export const BillingContainer: React.FC = ({ children, dependencies: D = DEPENDENCIES }) => { const { toast } = useToast(); const { stripe } = useServices(); - const [dateRange, setDateRange] = React.useState<{ from: Date; to: Date }>(() => createDateRange()); + const [dateRange, setDateRange] = React.useState<{ from: Date; to: Date } | null>(null); const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 }); const [errorMessage, setErrorMessage] = React.useState(""); - const { from: startDate, to: endDate } = dateRange; - const { data, + isLoading, isFetching, isError, error: queryError } = D.usePaymentTransactionsQuery({ limit: pagination.pageSize, offset: pagination.pageIndex * pagination.pageSize, - startDate, - endDate + startDate: dateRange?.from, + endDate: dateRange?.to }); React.useEffect(() => { @@ -73,9 +74,7 @@ export const BillingContainer: React.FC = ({ children, de }; const exportCsv = async () => { - if (!startDate || !endDate) { - return; - } + const { from: startDate, to: endDate } = dateRange ?? { from: subYears(new Date(), 1), to: endOfToday() }; try { const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; @@ -112,6 +111,7 @@ export const BillingContainer: React.FC = ({ children, de dateRange, onDateRangeChange: changeDateRange, pagination, + isLoading, isFetching, isError, errorMessage diff --git a/apps/deploy-web/src/components/billing-usage/BillingPage.tsx b/apps/deploy-web/src/components/billing-usage/BillingPage.tsx index 993ad4a827..a2b62a4477 100644 --- a/apps/deploy-web/src/components/billing-usage/BillingPage.tsx +++ b/apps/deploy-web/src/components/billing-usage/BillingPage.tsx @@ -4,33 +4,38 @@ import { NextSeo } from "next-seo"; import { AccountBalanceOverview } from "@src/components/billing-usage/AccountBalanceOverview/AccountBalanceOverview"; import { AddToBalanceButton } from "@src/components/billing-usage/AddToBalanceButton/AddToBalanceButton"; import { AutoTopUpSection } from "@src/components/billing-usage/AutoTopUpSection/AutoTopUpSection"; +import { BillingActionsProvider } from "@src/components/billing-usage/BillingActionsProvider/BillingActionsProvider"; import { BillingContainer } from "@src/components/billing-usage/BillingContainer/BillingContainer"; import { BillingView } from "@src/components/billing-usage/BillingView/BillingView"; import { PaymentMethodsContainer } from "@src/components/billing-usage/PaymentMethodsContainer/PaymentMethodsContainer"; import { PaymentMethodsView } from "@src/components/billing-usage/PaymentMethodsView/PaymentMethodsView"; +import { useBillingBackgroundLoading } from "@src/components/billing-usage/useBillingBackgroundLoading"; import Layout from "@src/components/layout/Layout"; import { SettingsLayout } from "@src/components/layout/SettingsLayout/SettingsLayout"; import { useFlag } from "@src/hooks/useFlag"; export const BillingPage: FC = () => { const isAutoCreditReloadEnabled = useFlag("auto_credit_reload"); + const isBackgroundLoading = useBillingBackgroundLoading(); return ( - + : undefined} > - {isAutoCreditReloadEnabled && ( - <> + {isAutoCreditReloadEnabled ? ( + {props => } - + {props => } + + ) : ( + {props => } )} - {props => } ); diff --git a/apps/deploy-web/src/components/billing-usage/BillingView/BillingView.spec.tsx b/apps/deploy-web/src/components/billing-usage/BillingView/BillingView.spec.tsx index 817512524c..0a0ea958f3 100644 --- a/apps/deploy-web/src/components/billing-usage/BillingView/BillingView.spec.tsx +++ b/apps/deploy-web/src/components/billing-usage/BillingView/BillingView.spec.tsx @@ -10,9 +10,9 @@ import { fireEvent, render, screen } from "@testing-library/react"; import { createMockItems, createMockTransaction } from "@tests/seeders/payment"; describe(BillingView.name, () => { - it("shows spinner when fetching", () => { - setup({ isFetching: true }); - expect(screen.getByRole("status")).toBeInTheDocument(); + it("shows a skeleton on first load", () => { + setup({ isLoading: true, data: [] }); + expect(screen.getByTestId("billing-history-skeleton")).toBeInTheDocument(); }); it("shows error alert when error", () => { @@ -30,43 +30,31 @@ describe(BillingView.name, () => { setup(); expect(screen.getByText("History")).toBeInTheDocument(); expect(screen.getByText("Date")).toBeInTheDocument(); - expect(screen.getByText("Type")).toBeInTheDocument(); - expect(screen.getByText("Description")).toBeInTheDocument(); expect(screen.getByText("Amount")).toBeInTheDocument(); + expect(screen.getByText("Account source")).toBeInTheDocument(); expect(screen.getByText("Status")).toBeInTheDocument(); expect(screen.getByText("Receipt")).toBeInTheDocument(); }); - it("renders the type badge label for each transaction type", () => { + it("falls back to the transaction type as the account source when there is no card", () => { setup({ data: [ - createMockTransaction({ type: "payment_intent" }), - createMockTransaction({ type: "coupon_claim" }), - createMockTransaction({ type: "manual_credit" }) + createMockTransaction({ type: "coupon_claim", cardBrand: null, cardLast4: null }), + createMockTransaction({ type: "manual_credit", cardBrand: null, cardLast4: null }) ] }); - expect(screen.getByText("Card Payment")).toBeInTheDocument(); expect(screen.getByText("Coupon")).toBeInTheDocument(); expect(screen.getByText("Manual Credit")).toBeInTheDocument(); }); - it("shows the card brand and last4 under a card payment", () => { + it("shows the card brand and last4 as the account source for a card payment", () => { setup({ data: [createMockTransaction({ type: "payment_intent", cardBrand: "visa", cardLast4: "4242" })] }); expect(screen.getByText(/Visa/)).toBeInTheDocument(); expect(screen.getByText(/4242/)).toBeInTheDocument(); }); - it("renders the description, falling back to N/A when missing", () => { - setup({ - data: [createMockTransaction({ description: "Wallet top-up" }), createMockTransaction({ description: null })] - }); - - expect(screen.getByText("Wallet top-up")).toBeInTheDocument(); - expect(screen.getByText("N/A")).toBeInTheDocument(); - }); - it("renders the transaction amount", () => { const { data } = setup({ data: [createMockTransaction({ amount: 25000, status: "succeeded" })] }); expect(screen.getByText((data[0].amount / 100).toFixed(2))).toBeInTheDocument(); @@ -221,6 +209,7 @@ describe(BillingView.name, () => { data: props.data ?? defaultData, hasMore: false, hasPrevious: false, + isLoading: false, isFetching: false, isError: false, errorMessage: "", diff --git a/apps/deploy-web/src/components/billing-usage/BillingView/BillingView.tsx b/apps/deploy-web/src/components/billing-usage/BillingView/BillingView.tsx index 943079d812..c904538eee 100644 --- a/apps/deploy-web/src/components/billing-usage/BillingView/BillingView.tsx +++ b/apps/deploy-web/src/components/billing-usage/BillingView/BillingView.tsx @@ -5,6 +5,9 @@ import { AlertDescription, AlertTitle, Button, + Card, + CardContent, + CardHeader, DateRangePicker, Label, Pagination, @@ -15,7 +18,7 @@ import { PaginationNext, PaginationPrevious, PaginationSizeSelector, - Spinner, + Skeleton, Table, TableBody, TableCell, @@ -30,7 +33,6 @@ import { endOfToday, startOfDay, subYears } from "date-fns"; import { Download, Page } from "iconoir-react"; import Link from "next/link"; -import { Title } from "@src/components/shared/Title"; import type { BillingTransaction } from "@src/queries"; import { capitalizeFirstLetter } from "@src/utils/stringUtils"; @@ -46,14 +48,6 @@ const TRANSACTION_TYPE_LABELS: Record = { manual_credit: "Manual Credit" }; -const TRANSACTION_TYPE_BADGE_CLASSES: Record = { - coupon_claim: "bg-blue-100 text-blue-800", - manual_credit: "bg-gray-100 text-gray-800", - payment_intent: "bg-gray-100 text-gray-800" -}; - -const DEFAULT_TRANSACTION_TYPE_BADGE_CLASS = "bg-gray-100 text-gray-800"; - const STATUS_BADGE_CLASSES: Record = { succeeded: "bg-green-100 text-green-800", pending: "bg-yellow-100 text-yellow-800", @@ -66,10 +60,13 @@ const DEFAULT_STATUS_BADGE_CLASS = "bg-gray-100 text-gray-800"; /** Coupon claims and manual credits top up the wallet, so their amount reads as money in (green +). */ const isCreditTransaction = (type: BillingTransaction["type"]) => type === "coupon_claim" || type === "manual_credit"; +const COLUMN_CLASSES = ["w-32 px-4 py-2", "w-32 px-4 py-2", "w-48 px-4 py-2", "w-28 px-4 py-2", "w-16 px-4 py-2"]; + export type BillingViewProps = { data: BillingTransaction[]; hasMore: boolean; hasPrevious: boolean; + isLoading: boolean; isFetching: boolean; isError: boolean; errorMessage: string | null; @@ -77,7 +74,7 @@ export type BillingViewProps = { onPaginationChange: (state: PaginationState) => void; pagination: PaginationState; totalCount: number; - dateRange: { from: Date; to: Date }; + dateRange: { from: Date; to: Date } | null; onDateRangeChange: (range: { from: Date; to: Date }) => void; components?: typeof COMPONENTS; }; @@ -86,6 +83,7 @@ export const BillingView: React.FC = ({ data, hasMore, hasPrevious, + isLoading, isFetching, errorMessage, isError, @@ -104,30 +102,6 @@ export const BillingView: React.FC = ({ header: "Date", cell: info => new Date(info.getValue() * 1000).toLocaleDateString() }), - columnHelper.accessor("type", { - header: "Type", - cell: info => { - const { cardBrand, cardLast4 } = info.row.original; - const type = info.getValue(); - return ( -
- - {TRANSACTION_TYPE_LABELS[type] ?? capitalizeFirstLetter(type)} - - {cardLast4 && ( -
- {cardBrand ? `${capitalizeFirstLetter(cardBrand)} ` : ""}**** {cardLast4} -
- )} -
- ); - } - }), columnHelper.accessor("amount", { header: "Amount", cell: info => { @@ -153,9 +127,20 @@ export const BillingView: React.FC = ({ ); } }), - columnHelper.accessor("description", { - header: "Description", - cell: info => info.getValue() || "N/A" + columnHelper.display({ + id: "accountSource", + header: "Account source", + cell: info => { + const { cardBrand, cardLast4, type } = info.row.original; + if (cardLast4) { + return ( + + {cardBrand ? `${capitalizeFirstLetter(cardBrand)} ` : ""}**** {cardLast4} + + ); + } + return {TRANSACTION_TYPE_LABELS[type] ?? capitalizeFirstLetter(type)}; + } }), columnHelper.accessor("status", { header: "Status", @@ -204,14 +189,6 @@ export const BillingView: React.FC = ({ } }); - if (isFetching) { - return ( -
- -
- ); - } - if (isError) { return ( @@ -221,158 +198,181 @@ export const BillingView: React.FC = ({ ); } - const columnClasses = ["w-28 px-4 py-2", "w-40 px-4 py-2", "w-32 px-4 py-2", "w-48 px-4 py-2", "w-28 px-4 py-2", "w-16 px-4 py-2"]; - return ( -
- History - -
-
- - -
- - -
+ + +

History

+

All payments to add credits will be made using your default card.

+
+ +
+
+ + +
- {!data.length && ( -
-

No billing history found for the selected date range.

+
- )} - - {!!data.length && ( -
- - - {table.getHeaderGroups().map(headerGroup => ( - - {headerGroup.headers.map((header, index) => ( - - {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} - - ))} - - ))} - -
-
+ {isLoading ? ( + + ) : !data.length ? ( +
+

{dateRange ? "No billing history found for the selected date range." : "No billing history found."}

+
+ ) : ( +
- - {table.getRowModel().rows.map(row => ( - - {row.getVisibleCells().map((cell, index) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - + + {table.getHeaderGroups().map(headerGroup => ( + + {headerGroup.headers.map((header, index) => ( + + {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} + ))} ))} - +
-
- - { - onPaginationChange({ - pageIndex: 0, - pageSize - }); - }} - /> +
+ + + {table.getRowModel().rows.map(row => ( + + {row.getVisibleCells().map((cell, index) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + ))} + +
+
- - - - onPaginationChange({ - pageIndex: Math.max(0, pagination.pageIndex - 1), - pageSize: pagination.pageSize - }) - } - disabled={!hasPrevious || isFetching} - className="h-8 px-2 text-sm text-neutral-500 hover:text-neutral-700 dark:text-neutral-400 dark:hover:text-neutral-300 [&_span]:hidden sm:[&_span]:inline-block" - /> - + + { + onPaginationChange({ + pageIndex: 0, + pageSize + }); + }} + /> - {hasPrevious && ( - - + + onPaginationChange({ - pageIndex: pagination.pageIndex - 1, + pageIndex: Math.max(0, pagination.pageIndex - 1), pageSize: pagination.pageSize }) } - > - {pagination.pageIndex} - + disabled={!hasPrevious || isFetching} + className="h-8 px-2 text-sm text-neutral-500 hover:text-neutral-700 dark:text-neutral-400 dark:hover:text-neutral-300 [&_span]:hidden sm:[&_span]:inline-block" + /> - )} - - - {pagination.pageIndex + 1} - - + {hasPrevious && ( + + + onPaginationChange({ + pageIndex: pagination.pageIndex - 1, + pageSize: pagination.pageSize + }) + } + > + {pagination.pageIndex} + + + )} - {hasMore && ( - - onPaginationChange({ - pageIndex: pagination.pageIndex + 1, - pageSize: pagination.pageSize - }) - } - > - {pagination.pageIndex + 2} + + {pagination.pageIndex + 1} - )} - {pagination.pageIndex === 0 && hasMore && ( - <> + {hasMore && ( onPaginationChange({ - pageIndex: 2, + pageIndex: pagination.pageIndex + 1, pageSize: pagination.pageSize }) } > - 3 + {pagination.pageIndex + 2} - - - )} + )} - - onPaginationChange({ pageIndex: pagination.pageIndex + 1, pageSize: pagination.pageSize })} - disabled={!hasMore || isFetching} - className="h-8 px-2 text-sm text-neutral-500 hover:text-neutral-700 dark:text-neutral-400 dark:hover:text-neutral-300 [&_span]:hidden sm:[&_span]:inline-block" - /> - - -
-
- )} -
+ {pagination.pageIndex === 0 && hasMore && ( + <> + + + onPaginationChange({ + pageIndex: 2, + pageSize: pagination.pageSize + }) + } + > + 3 + + + + + )} + + + onPaginationChange({ pageIndex: pagination.pageIndex + 1, pageSize: pagination.pageSize })} + disabled={!hasMore || isFetching} + className="h-8 px-2 text-sm text-neutral-500 hover:text-neutral-700 dark:text-neutral-400 dark:hover:text-neutral-300 [&_span]:hidden sm:[&_span]:inline-block" + /> + + + +
+ )} +
+
); }; + +const BillingTableSkeleton: React.FC = () => ( +
+ {Array.from({ length: 5 }).map((_, index) => ( +
+ {COLUMN_CLASSES.map((columnClass, columnIndex) => ( +
+ +
+ ))} +
+ ))} +
+); diff --git a/apps/deploy-web/src/components/billing-usage/PaymentMethodsContainer/PaymentMethodsContainer.spec.tsx b/apps/deploy-web/src/components/billing-usage/PaymentMethodsContainer/PaymentMethodsContainer.spec.tsx index 55ace4681a..557fe13cb8 100644 --- a/apps/deploy-web/src/components/billing-usage/PaymentMethodsContainer/PaymentMethodsContainer.spec.tsx +++ b/apps/deploy-web/src/components/billing-usage/PaymentMethodsContainer/PaymentMethodsContainer.spec.tsx @@ -1,15 +1,15 @@ import React from "react"; -import type { PaymentMethod, SetupIntentResponse } from "@akashnetwork/http-sdk"; +import type { PaymentMethod } from "@akashnetwork/http-sdk"; import type { usePopup } from "@akashnetwork/ui/context"; import { describe, expect, it, type MockedFunction, vi } from "vitest"; import { mock } from "vitest-mock-extended"; -import type { usePaymentMethodsQuery, usePaymentMutations, useRefreshPaymentMethods, useSetupIntentMutation, useWalletSettingsQuery } from "@src/queries"; +import type { usePaymentMethodsQuery, usePaymentMutations, useWalletSettingsQuery } from "@src/queries"; import type { PaymentMethodsViewProps } from "../PaymentMethodsView/PaymentMethodsView"; import { PaymentMethodsContainer } from "./PaymentMethodsContainer"; -import { act, render } from "@testing-library/react"; -import { createMockPaymentMethod, createMockSetupIntentResponse } from "@tests/seeders/payment"; +import { render } from "@testing-library/react"; +import { createMockPaymentMethod } from "@tests/seeders/payment"; import { createContainerTestingChildCapturer } from "@tests/unit/container-testing-child-capturer"; describe(PaymentMethodsContainer.name, () => { @@ -56,76 +56,6 @@ describe(PaymentMethodsContainer.name, () => { expect(mockRemovePaymentMethod.mutate).not.toHaveBeenCalled(); }); - it("initializes showAddPaymentMethod as false", async () => { - const { child } = await setup(); - expect(child.showAddPaymentMethod).toBe(false); - }); - - it("calls createSetupIntent and sets showAddPaymentMethod to true when onAddPaymentMethod is invoked", async () => { - const { childCapturer, mockCreateSetupIntent, mockResetSetupIntent } = await setup(); - - let child = await childCapturer.awaitChild(() => true); - - await act(async () => { - child.onAddPaymentMethod(); - }); - - child = await childCapturer.awaitChild(c => c.showAddPaymentMethod === true); - - expect(mockResetSetupIntent).toHaveBeenCalled(); - expect(mockCreateSetupIntent).toHaveBeenCalled(); - expect(child.showAddPaymentMethod).toBe(true); - }); - - it("passes setupIntent data to children", async () => { - const setupIntent = createMockSetupIntentResponse(); - const { child } = await setup({ setupIntent }); - expect(child.setupIntent).toEqual(setupIntent); - }); - - it("sets showAddPaymentMethod to false and refreshes payment methods when onAddCardSuccess is called", async () => { - const { childCapturer, mockRefreshPaymentMethods } = await setup(); - - let child = await childCapturer.awaitChild(() => true); - - await act(async () => { - child.onAddPaymentMethod(); - }); - - child = await childCapturer.awaitChild(c => c.showAddPaymentMethod === true); - expect(child.showAddPaymentMethod).toBe(true); - - await act(async () => { - await child.onAddCardSuccess(); - }); - - child = await childCapturer.awaitChild(c => c.showAddPaymentMethod === false); - - expect(child.showAddPaymentMethod).toBe(false); - expect(mockRefreshPaymentMethods).toHaveBeenCalled(); - }); - - it("allows setShowAddPaymentMethod to update state", async () => { - const { childCapturer } = await setup(); - - let child = await childCapturer.awaitChild(() => true); - expect(child.showAddPaymentMethod).toBe(false); - - await act(async () => { - child.setShowAddPaymentMethod(true); - }); - - child = await childCapturer.awaitChild(c => c.showAddPaymentMethod === true); - expect(child.showAddPaymentMethod).toBe(true); - - await act(async () => { - child.setShowAddPaymentMethod(false); - }); - - child = await childCapturer.awaitChild(c => c.showAddPaymentMethod === false); - expect(child.showAddPaymentMethod).toBe(false); - }); - it("sets isInProgress to false when no operations are in progress", async () => { const { child } = await setup(); expect(child.isInProgress).toBe(false); @@ -212,7 +142,6 @@ describe(PaymentMethodsContainer.name, () => { isRefetchingPaymentMethods: boolean; isSetPaymentMethodAsDefaultPending: boolean; isRemovePaymentMethodPending: boolean; - setupIntent: SetupIntentResponse; autoReloadEnabled: boolean; isWalletSettingsLoading: boolean; confirmResult: boolean; @@ -224,9 +153,7 @@ describe(PaymentMethodsContainer.name, () => { const isRefetchingPaymentMethods = overrides.isRefetchingPaymentMethods ?? false; const isSetPaymentMethodAsDefaultPending = overrides.isSetPaymentMethodAsDefaultPending ?? false; const isRemovePaymentMethodPending = overrides.isRemovePaymentMethodPending ?? false; - const setupIntent = overrides.setupIntent; - const mockRefreshPaymentMethods = vi.fn().mockResolvedValue(undefined); const mockSetPaymentMethodAsDefault = { mutate: vi.fn(), isPending: isSetPaymentMethodAsDefaultPending @@ -235,8 +162,6 @@ describe(PaymentMethodsContainer.name, () => { mutate: vi.fn(), isPending: isRemovePaymentMethodPending }; - const mockCreateSetupIntent = vi.fn(); - const mockResetSetupIntent = vi.fn(); const mockedUsePaymentMethodsQuery = vi.fn(() => ({ data: paymentMethods, @@ -249,14 +174,6 @@ describe(PaymentMethodsContainer.name, () => { removePaymentMethod: mockRemovePaymentMethod })) as unknown as MockedFunction; - const mockedUseRefreshPaymentMethods = vi.fn(() => mockRefreshPaymentMethods) as unknown as MockedFunction; - - const mockedUseSetupIntentMutation = vi.fn(() => ({ - data: setupIntent, - mutate: mockCreateSetupIntent, - reset: mockResetSetupIntent - })) as unknown as MockedFunction; - const walletSettingsData = Object.prototype.hasOwnProperty.call(overrides, "autoReloadEnabled") ? { autoReloadEnabled: overrides.autoReloadEnabled! } : undefined; @@ -272,8 +189,6 @@ describe(PaymentMethodsContainer.name, () => { const dependencies = { usePaymentMethodsQuery: mockedUsePaymentMethodsQuery, usePaymentMutations: mockedUsePaymentMutations, - useRefreshPaymentMethods: mockedUseRefreshPaymentMethods, - useSetupIntentMutation: mockedUseSetupIntentMutation, useWalletSettingsQuery: mockedUseWalletSettingsQuery, usePopup: mockedUsePopup }; @@ -288,11 +203,8 @@ describe(PaymentMethodsContainer.name, () => { paymentMethods, child, childCapturer, - mockRefreshPaymentMethods, mockSetPaymentMethodAsDefault, mockRemovePaymentMethod, - mockCreateSetupIntent, - mockResetSetupIntent, mockConfirm }; } diff --git a/apps/deploy-web/src/components/billing-usage/PaymentMethodsContainer/PaymentMethodsContainer.tsx b/apps/deploy-web/src/components/billing-usage/PaymentMethodsContainer/PaymentMethodsContainer.tsx index b46b0caefb..f6abd365fa 100644 --- a/apps/deploy-web/src/components/billing-usage/PaymentMethodsContainer/PaymentMethodsContainer.tsx +++ b/apps/deploy-web/src/components/billing-usage/PaymentMethodsContainer/PaymentMethodsContainer.tsx @@ -1,14 +1,12 @@ -import React, { useCallback, useState } from "react"; +import React, { useCallback } from "react"; import { usePopup } from "@akashnetwork/ui/context"; -import { usePaymentMethodsQuery, usePaymentMutations, useRefreshPaymentMethods, useSetupIntentMutation, useWalletSettingsQuery } from "@src/queries"; +import { usePaymentMethodsQuery, usePaymentMutations, useWalletSettingsQuery } from "@src/queries"; import type { PaymentMethodsViewProps } from "../PaymentMethodsView/PaymentMethodsView"; const DEPENDENCIES = { usePaymentMethodsQuery, usePaymentMutations, - useRefreshPaymentMethods, - useSetupIntentMutation, useWalletSettingsQuery, usePopup }; @@ -23,10 +21,7 @@ export const PaymentMethodsContainer: React.FC = ( const { data: walletSettings, isLoading: isWalletSettingsLoading } = d.useWalletSettingsQuery(); const isAutoReloadEnabled = walletSettings?.autoReloadEnabled ?? isWalletSettingsLoading; const paymentMutations = d.usePaymentMutations(); - const refreshPaymentMethods = d.useRefreshPaymentMethods(); const { confirm } = d.usePopup(); - const { data: setupIntent, mutate: createSetupIntent, reset: resetSetupIntent } = d.useSetupIntentMutation(); - const [showAddPaymentMethod, setShowAddPaymentMethod] = useState(false); const onSetPaymentMethodAsDefault = useCallback( (id: string) => { @@ -62,17 +57,6 @@ export const PaymentMethodsContainer: React.FC = ( [confirm, paymentMethods, isAutoReloadEnabled, paymentMutations.removePaymentMethod] ); - const onAddCardSuccess = async () => { - setShowAddPaymentMethod(false); - await refreshPaymentMethods(); - }; - - const onAddPaymentMethod = useCallback(() => { - resetSetupIntent(); - createSetupIntent(); - setShowAddPaymentMethod(true); - }, [createSetupIntent, resetSetupIntent]); - const isInProgress = isLoadingPaymentMethods || isRefetchingPaymentMethods || @@ -85,12 +69,7 @@ export const PaymentMethodsContainer: React.FC = ( data: paymentMethods || [], onSetPaymentMethodAsDefault, onRemovePaymentMethod, - onAddPaymentMethod, isLoadingPaymentMethods, - showAddPaymentMethod, - setShowAddPaymentMethod, - setupIntent, - onAddCardSuccess, isInProgress })} diff --git a/apps/deploy-web/src/components/billing-usage/PaymentMethodsView/PaymentMethodsRow.spec.tsx b/apps/deploy-web/src/components/billing-usage/PaymentMethodsView/PaymentMethodsRow.spec.tsx index 1d01d9b980..4b8618cd6f 100644 --- a/apps/deploy-web/src/components/billing-usage/PaymentMethodsView/PaymentMethodsRow.spec.tsx +++ b/apps/deploy-web/src/components/billing-usage/PaymentMethodsView/PaymentMethodsRow.spec.tsx @@ -9,8 +9,7 @@ import userEvent from "@testing-library/user-event"; import { createMockLinkPaymentMethod, createMockPaymentMethod } from "@tests/seeders/payment"; // Mock implementations for dependencies -const MockTableRow = ({ children, className }: any) => {children}; -const MockTableCell = ({ children, className }: any) => {children}; +const MockCreditCard = ({ className }: any) => ; let isDropdownOpen = false; @@ -60,8 +59,7 @@ const MockCustomDropdownLinkItem = React.forwardRef(({ children, onClick, icon, )); const mockDependencies: any = { - TableRow: MockTableRow, - TableCell: MockTableCell, + CreditCard: MockCreditCard, DropdownMenu: MockDropdownMenu, DropdownMenuTrigger: MockDropdownMenuTrigger, Button: MockButton, @@ -426,18 +424,14 @@ describe(PaymentMethodsRow.name, () => { render(
- - - - -
+
Outside element
); @@ -484,13 +478,7 @@ function setup( onRemovePaymentMethod: mockOnRemovePaymentMethod }; - const renderResult = render( - - - - -
- ); + const renderResult = render(); return { ...renderResult, diff --git a/apps/deploy-web/src/components/billing-usage/PaymentMethodsView/PaymentMethodsRow.tsx b/apps/deploy-web/src/components/billing-usage/PaymentMethodsView/PaymentMethodsRow.tsx index d0e236ee30..c0de71f73c 100644 --- a/apps/deploy-web/src/components/billing-usage/PaymentMethodsView/PaymentMethodsRow.tsx +++ b/apps/deploy-web/src/components/billing-usage/PaymentMethodsView/PaymentMethodsRow.tsx @@ -1,21 +1,21 @@ import React, { useCallback, useMemo, useState } from "react"; import type { PaymentMethod } from "@akashnetwork/http-sdk"; -import { Badge, Button, DropdownMenu, DropdownMenuContent, DropdownMenuTrigger, TableCell, TableRow } from "@akashnetwork/ui/components"; +import { Badge, Button, DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from "@akashnetwork/ui/components"; import { ClickAwayListener } from "@mui/material"; import { BadgeCheck, CheckCircle, MoreHoriz, Trash } from "iconoir-react"; +import { CreditCard } from "lucide-react"; import { CustomDropdownLinkItem } from "@src/components/shared/CustomDropdownLinkItem"; import { capitalizeFirstLetter } from "@src/utils/stringUtils"; export const DEPENDENCIES = { - TableRow, - TableCell, DropdownMenu, DropdownMenuTrigger, Button, DropdownMenuContent, ClickAwayListener, - CustomDropdownLinkItem + CustomDropdownLinkItem, + CreditCard }; export type PaymentMethodsRowProps = { @@ -77,7 +77,7 @@ export const PaymentMethodsRow: React.FC = ({ } return ( - + Default @@ -97,15 +97,17 @@ export const PaymentMethodsRow: React.FC = ({ const canSetAsDefault = !paymentMethod.isDefault; return ( - - - {paymentMethodLabel} {defaultBadge} - - {validUntilContent && Valid until {validUntilContent}} - +
+ +
+ {paymentMethodLabel} + {defaultBadge} +
+
+ {validUntilContent && Valid until {validUntilContent}} - + @@ -130,7 +132,7 @@ export const PaymentMethodsRow: React.FC = ({ - - +
+
); }; diff --git a/apps/deploy-web/src/components/billing-usage/PaymentMethodsView/PaymentMethodsView.spec.tsx b/apps/deploy-web/src/components/billing-usage/PaymentMethodsView/PaymentMethodsView.spec.tsx index 16d0f2c6e9..d9febf0127 100644 --- a/apps/deploy-web/src/components/billing-usage/PaymentMethodsView/PaymentMethodsView.spec.tsx +++ b/apps/deploy-web/src/components/billing-usage/PaymentMethodsView/PaymentMethodsView.spec.tsx @@ -1,451 +1,137 @@ import React from "react"; -import type { PaymentMethod, SetupIntentResponse } from "@akashnetwork/http-sdk"; +import type { PaymentMethod } from "@akashnetwork/http-sdk"; import { describe, expect, it, type Mock, vi } from "vitest"; import type { PaymentMethodsRowProps } from "./PaymentMethodsRow"; -import type { DEPENDENCIES } from "./PaymentMethodsView"; import { PaymentMethodsView } from "./PaymentMethodsView"; import { fireEvent, render, screen } from "@testing-library/react"; import { createMockPaymentMethod } from "@tests/seeders/payment"; -// Mock implementations for dependencies -const mockUseTheme = vi.fn(() => ({ - resolvedTheme: "light" as "light" | "dark" | undefined, - theme: "light", - setTheme: vi.fn(), - themes: ["light", "dark", "system"], - systemTheme: "light" as "light" | "dark" | undefined -})); - const MockPaymentMethodsRow = ({ paymentMethod, onSetPaymentMethodAsDefault, onRemovePaymentMethod }: PaymentMethodsRowProps) => ( - - - {paymentMethod.card?.last4} - - - - +
+ {paymentMethod.card?.last4} + + +
); -const MockAddPaymentMethodPopup = ({ open, onClose, clientSecret, isDarkMode, onSuccess }: any) => - open ? ( -
- - - {isDarkMode ? "dark" : "light"} - {clientSecret} -
- ) : null; - -const MockCard = ({ children }: any) =>
{children}
; -const MockCardHeader = ({ children }: any) =>
{children}
; -const MockCardContent = ({ children, className }: any) =>
{children}
; -const MockCardFooter = ({ children, className }: any) =>
{children}
; -const MockSpinner = () =>
Loading...
; -const MockTable = ({ children }: any) => {children}
; -const MockTableBody = ({ children }: any) => {children}; -const MockButton = ({ children, onClick, className }: any) => ( - ); -const MockCircularProgress = ({ color }: any) => ( -
- Loading... -
-); - -const mockDependencies: any = { - useTheme: mockUseTheme, - PaymentMethodsRow: MockPaymentMethodsRow, - AddPaymentMethodPopup: MockAddPaymentMethodPopup, - Card: MockCard, - CardHeader: MockCardHeader, - CardContent: MockCardContent, - CardFooter: MockCardFooter, - Spinner: MockSpinner, - Table: MockTable, - TableBody: MockTableBody, - Button: MockButton, - CircularProgress: MockCircularProgress -}; - describe(PaymentMethodsView.name, () => { - describe("Loading State", () => { - it("renders loading spinner when isLoadingPaymentMethods is true", () => { - setup({ isLoadingPaymentMethods: true }); - - expect(screen.getByTestId("spinner")).toBeInTheDocument(); - }); + it("renders the header title and description", () => { + setup(); - it("does not render payment methods content when loading", () => { - setup({ isLoadingPaymentMethods: true }); - - expect(screen.queryByText("Payment Methods")).not.toBeInTheDocument(); - }); + expect(screen.getByText("Payment Method")).toBeInTheDocument(); + expect(screen.getByText("All transactions will be made using your default card.")).toBeInTheDocument(); }); - describe("Empty State", () => { - it("renders empty state message when no payment methods exist", () => { - setup({ data: [] }); - - expect(screen.getByText("No payment methods added yet.")).toBeInTheDocument(); - }); - - it("renders card header and footer when no payment methods exist", () => { - setup({ data: [] }); - - expect(screen.getByText("Payment Methods")).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Add Payment Method" })).toBeInTheDocument(); - }); + it("renders a row for each payment method", () => { + setup({ data: createMockPaymentMethods() }); - it("renders add payment method button when no payment methods exist", () => { - setup({ data: [] }); - - expect(screen.getByRole("button", { name: "Add Payment Method" })).toBeInTheDocument(); - }); + expect(screen.getByTestId("payment-method-row-pm_123")).toBeInTheDocument(); + expect(screen.getByTestId("payment-method-row-pm_456")).toBeInTheDocument(); }); - describe("Payment Methods Display", () => { - it("renders payment methods header and description", () => { - setup(); - - expect(screen.getByText("Payment Methods")).toBeInTheDocument(); - expect(screen.getByText("All payments to add credits will be made using your default card.")).toBeInTheDocument(); - }); - - it("renders all payment methods", () => { - const paymentMethods = createMockPaymentMethods(); - setup({ data: paymentMethods }); - - expect(screen.getByTestId("payment-method-row-pm_123")).toBeInTheDocument(); - expect(screen.getByTestId("payment-method-row-pm_456")).toBeInTheDocument(); - expect(screen.getByText("4242")).toBeInTheDocument(); - expect(screen.getByText("5555")).toBeInTheDocument(); - }); + it("shows the empty state when there are no payment methods", () => { + setup({ data: [] }); - it("renders add payment method button", () => { - setup(); - - expect(screen.getByRole("button", { name: "Add Payment Method" })).toBeInTheDocument(); - }); - - it("passes correct canBeRemoved prop when multiple payment methods exist", () => { - const paymentMethods = createMockPaymentMethods(); - setup({ data: paymentMethods }); - - // When there are 2 payment methods, both should be removable - const row1 = screen.getByTestId("payment-method-row-pm_123"); - const row2 = screen.getByTestId("payment-method-row-pm_456"); - - // Both rows should have Remove buttons when there are multiple payment methods - expect(row1.textContent).toContain("Remove"); - expect(row2.textContent).toContain("Remove"); - }); - - it("shows Remove for only payment method", () => { - const paymentMethods = [createMockPaymentMethods()[0]]; - setup({ data: paymentMethods }); - - const row = screen.getByTestId("payment-method-row-pm_123"); - expect(row.textContent).toContain("Remove"); - }); + expect(screen.getByText("No payment methods added yet.")).toBeInTheDocument(); + expect(screen.queryByTestId(/payment-method-row-/)).not.toBeInTheDocument(); }); - describe("Payment Method Interactions", () => { - it("calls onAddPaymentMethod when add button is clicked", () => { - const { mockOnAddPaymentMethod } = setup(); - - const addButton = screen.getByRole("button", { name: "Add Payment Method" }); - fireEvent.click(addButton); - - expect(mockOnAddPaymentMethod).toHaveBeenCalledTimes(1); - }); + it("opens the add-payment-method flow when the add button is clicked", () => { + const { openAddPaymentMethod } = setup(); - it("calls onSetPaymentMethodAsDefault with correct id", () => { - const { mockOnSetPaymentMethodAsDefault } = setup(); + fireEvent.click(screen.getByRole("button", { name: "Add Payment Method" })); - const setDefaultButtons = screen.getAllByText("Set as Default"); - fireEvent.click(setDefaultButtons[0]); - - expect(mockOnSetPaymentMethodAsDefault).toHaveBeenCalledWith("pm_123"); - }); - - it("calls onRemovePaymentMethod with correct id", () => { - const { mockOnRemovePaymentMethod } = setup(); - - const removeButtons = screen.getAllByText("Remove"); - fireEvent.click(removeButtons[0]); - - expect(mockOnRemovePaymentMethod).toHaveBeenCalledWith("pm_123"); - }); + expect(openAddPaymentMethod).toHaveBeenCalledTimes(1); }); - describe("AddPaymentMethodPopup Integration", () => { - it("does not show popup when showAddPaymentMethod is false", () => { - setup({ showAddPaymentMethod: false }); - - expect(screen.queryByTestId("add-payment-method-popup")).not.toBeInTheDocument(); - }); - - it("shows popup when showAddPaymentMethod is true", () => { - setup({ showAddPaymentMethod: true }); - - expect(screen.getByTestId("add-payment-method-popup")).toBeInTheDocument(); - }); - - it("passes correct clientSecret to popup", () => { - const setupIntent: SetupIntentResponse = { - clientSecret: "test_secret_123" - }; - setup({ showAddPaymentMethod: true, setupIntent }); + it("disables the add button while an operation is in progress", () => { + setup({ isInProgress: true }); - expect(screen.getByTestId("client-secret")).toHaveTextContent("test_secret_123"); - }); - - it("passes undefined clientSecret when setupIntent is not provided", () => { - setup({ showAddPaymentMethod: true, setupIntent: undefined }); - - expect(screen.getByTestId("client-secret")).toBeEmptyDOMElement(); - }); - - it("calls setShowAddPaymentMethod(false) when popup is closed", () => { - const { mockSetShowAddPaymentMethod } = setup({ showAddPaymentMethod: true }); - - const closeButton = screen.getByText("Close"); - fireEvent.click(closeButton); - - expect(mockSetShowAddPaymentMethod).toHaveBeenCalledWith(false); - }); - - it("calls onAddCardSuccess when popup success is triggered", () => { - const { mockOnAddCardSuccess } = setup({ showAddPaymentMethod: true }); - - const successButton = screen.getByText("Success"); - fireEvent.click(successButton); - - expect(mockOnAddCardSuccess).toHaveBeenCalledTimes(1); - }); - - it("passes isDarkMode as false when theme is light", () => { - const lightThemeDeps: any = { - ...mockDependencies, - useTheme: vi.fn(() => ({ - resolvedTheme: "light" as "light" | "dark" | undefined, - theme: "light", - setTheme: vi.fn(), - themes: ["light", "dark", "system"], - systemTheme: "light" as "light" | "dark" | undefined - })) - }; - - setup({ showAddPaymentMethod: true, dependencies: lightThemeDeps }); - - expect(screen.getByTestId("dark-mode")).toHaveTextContent("light"); - }); - - it("passes isDarkMode as true when theme is dark", () => { - const darkThemeDeps: any = { - ...mockDependencies, - useTheme: vi.fn(() => ({ - resolvedTheme: "dark" as "light" | "dark" | undefined, - theme: "dark", - setTheme: vi.fn(), - themes: ["light", "dark", "system"], - systemTheme: "dark" as "light" | "dark" | undefined - })) - }; - - setup({ showAddPaymentMethod: true, dependencies: darkThemeDeps }); - - expect(screen.getByTestId("dark-mode")).toHaveTextContent("dark"); - }); + expect(screen.getByRole("button", { name: "Add Payment Method" })).toBeDisabled(); }); - describe("Progress Overlay", () => { - it("does not show progress overlay when isInProgress is false", () => { - setup({ isInProgress: false }); - - expect(screen.queryByTestId("circular-progress")).not.toBeInTheDocument(); - }); - - it("shows progress overlay when isInProgress is true", () => { - setup({ isInProgress: true }); + it("shows skeletons and no rows on first load", () => { + setup({ isLoadingPaymentMethods: true, data: [] }); - expect(screen.queryByTestId("circular-progress")).toBeInTheDocument(); - }); + expect(screen.getAllByTestId("skeleton").length).toBeGreaterThan(0); + expect(screen.queryByText("No payment methods added yet.")).not.toBeInTheDocument(); + expect(screen.queryByTestId(/payment-method-row-/)).not.toBeInTheDocument(); }); - describe("Edge Cases", () => { - it("handles empty payment methods array", () => { - setup({ data: [] }); + it("calls onSetPaymentMethodAsDefault with the payment method id", () => { + const { onSetPaymentMethodAsDefault } = setup({ data: createMockPaymentMethods() }); - expect(screen.getByText("No payment methods added yet.")).toBeInTheDocument(); - expect(screen.queryByTestId(/payment-method-row-/)).not.toBeInTheDocument(); - }); + fireEvent.click(screen.getAllByText("Set as Default")[0]); - it("handles single payment method", () => { - const singlePaymentMethod = [createMockPaymentMethods()[0]]; - setup({ data: singlePaymentMethod }); - - expect(screen.getByTestId("payment-method-row-pm_123")).toBeInTheDocument(); - expect(screen.queryByTestId("payment-method-row-pm_456")).not.toBeInTheDocument(); - }); - - it("handles three payment methods (maximum)", () => { - const threePaymentMethods = [ - ...createMockPaymentMethods(), - createMockPaymentMethod({ - id: "pm_789", - card: { - last4: "6789", - brand: "amex", - exp_month: 6, - exp_year: 2027, - funding: "credit" - }, - isDefault: false - }) - ]; - setup({ data: threePaymentMethods }); - - expect(screen.getByTestId("payment-method-row-pm_123")).toBeInTheDocument(); - expect(screen.getByTestId("payment-method-row-pm_456")).toBeInTheDocument(); - expect(screen.getByTestId("payment-method-row-pm_789")).toBeInTheDocument(); - }); - - it("handles payment method without card details", () => { - const paymentMethodsWithoutCard = [ - { - id: "pm_999", - isDefault: true - } as PaymentMethod - ]; - setup({ data: paymentMethodsWithoutCard }); - - expect(screen.getByTestId("payment-method-row-pm_999")).toBeInTheDocument(); - }); - - it("handles mixed theme values", () => { - const undefinedThemeDeps: any = { - ...mockDependencies, - useTheme: vi.fn(() => ({ - resolvedTheme: undefined, - theme: undefined, - setTheme: vi.fn(), - themes: ["light", "dark", "system"], - systemTheme: undefined - })) - }; - - setup({ showAddPaymentMethod: true, dependencies: undefinedThemeDeps }); - - // When theme is undefined, it should default to light (not dark) - expect(screen.getByTestId("dark-mode")).toHaveTextContent("light"); - }); + expect(onSetPaymentMethodAsDefault).toHaveBeenCalledWith("pm_123"); }); - describe("Component Structure", () => { - it("renders main container with correct spacing", () => { - const { container } = setup(); + it("calls onRemovePaymentMethod with the payment method id", () => { + const { onRemovePaymentMethod } = setup({ data: createMockPaymentMethods() }); - const mainDiv = container.querySelector(".space-y-2"); - expect(mainDiv).toBeInTheDocument(); - }); + fireEvent.click(screen.getAllByText("Remove")[0]); - it("renders Card components correctly", () => { - setup(); - - // Card should contain header, content, and footer - expect(screen.getByText("Payment Methods")).toBeInTheDocument(); - expect(screen.getByText("All payments to add credits will be made using your default card.")).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Add Payment Method" })).toBeInTheDocument(); - }); + expect(onRemovePaymentMethod).toHaveBeenCalledWith("pm_123"); }); -}); - -function createMockPaymentMethods(): PaymentMethod[] { - return [ - createMockPaymentMethod({ - id: "pm_123", - card: { - last4: "4242", - brand: "visa", - exp_month: 12, - exp_year: 2025, - funding: "credit" - }, - isDefault: true - }), - createMockPaymentMethod({ - id: "pm_456", - card: { - last4: "5555", - brand: "mastercard", - exp_month: 3, - exp_year: 2026, - funding: "debit" - }, - isDefault: false - }) - ]; -} - -function setup( - input: { - data?: PaymentMethod[]; - onSetPaymentMethodAsDefault?: Mock; - onRemovePaymentMethod?: Mock; - onAddPaymentMethod?: Mock; - isLoadingPaymentMethods?: boolean; - showAddPaymentMethod?: boolean; - setShowAddPaymentMethod?: Mock; - setupIntent?: SetupIntentResponse; - onAddCardSuccess?: Mock; - isInProgress?: boolean; - dependencies?: typeof DEPENDENCIES; - } = {} -) { - const defaultProps = { - data: createMockPaymentMethods(), - onSetPaymentMethodAsDefault: vi.fn(), - onRemovePaymentMethod: vi.fn(), - onAddPaymentMethod: vi.fn(), - isLoadingPaymentMethods: false, - showAddPaymentMethod: false, - setShowAddPaymentMethod: vi.fn(), - setupIntent: undefined, - onAddCardSuccess: vi.fn(), - isInProgress: false, - dependencies: mockDependencies - }; - const mockOnSetPaymentMethodAsDefault = input.onSetPaymentMethodAsDefault || vi.fn(); - const mockOnRemovePaymentMethod = input.onRemovePaymentMethod || vi.fn(); - const mockOnAddPaymentMethod = input.onAddPaymentMethod || vi.fn(); - const mockSetShowAddPaymentMethod = input.setShowAddPaymentMethod || vi.fn(); - const mockOnAddCardSuccess = input.onAddCardSuccess || vi.fn(); - - const props = { - ...defaultProps, - ...input, - onSetPaymentMethodAsDefault: mockOnSetPaymentMethodAsDefault, - onRemovePaymentMethod: mockOnRemovePaymentMethod, - onAddPaymentMethod: mockOnAddPaymentMethod, - setShowAddPaymentMethod: mockSetShowAddPaymentMethod, - onAddCardSuccess: mockOnAddCardSuccess - }; - - const renderResult = render(); - - return { - ...renderResult, - mockOnSetPaymentMethodAsDefault, - mockOnRemovePaymentMethod, - mockOnAddPaymentMethod, - mockSetShowAddPaymentMethod, - mockOnAddCardSuccess - }; -} + function createMockPaymentMethods(count = 2): PaymentMethod[] { + const cards = [ + { id: "pm_123", last4: "4242", brand: "visa" }, + { id: "pm_456", last4: "5555", brand: "mastercard" }, + { id: "pm_789", last4: "6789", brand: "amex" } + ]; + return cards + .slice(0, count) + .map(({ id, last4, brand }) => + createMockPaymentMethod({ id, isDefault: id === "pm_123", card: { last4, brand, exp_month: 12, exp_year: 2030, funding: "credit" } }) + ); + } + + function setup( + input: { + data?: PaymentMethod[]; + isLoadingPaymentMethods?: boolean; + isInProgress?: boolean; + onSetPaymentMethodAsDefault?: Mock; + onRemovePaymentMethod?: Mock; + openAddPaymentMethod?: Mock; + } = {} + ) { + const openAddPaymentMethod = input.openAddPaymentMethod ?? vi.fn(); + const onSetPaymentMethodAsDefault = input.onSetPaymentMethodAsDefault ?? vi.fn(); + const onRemovePaymentMethod = input.onRemovePaymentMethod ?? vi.fn(); + + const dependencies: any = { + useBillingActions: () => ({ openAddPaymentMethod }), + PaymentMethodsRow: MockPaymentMethodsRow, + Card: Passthrough, + CardHeader: Passthrough, + CardContent: Passthrough, + Skeleton: MockSkeleton, + Button: MockButton + }; + + const renderResult = render( + + ); + + return { ...renderResult, openAddPaymentMethod, onSetPaymentMethodAsDefault, onRemovePaymentMethod }; + } +}); diff --git a/apps/deploy-web/src/components/billing-usage/PaymentMethodsView/PaymentMethodsView.tsx b/apps/deploy-web/src/components/billing-usage/PaymentMethodsView/PaymentMethodsView.tsx index aa95d7b95f..303f2282b8 100644 --- a/apps/deploy-web/src/components/billing-usage/PaymentMethodsView/PaymentMethodsView.tsx +++ b/apps/deploy-web/src/components/billing-usage/PaymentMethodsView/PaymentMethodsView.tsx @@ -1,38 +1,26 @@ import React from "react"; -import type { PaymentMethod, SetupIntentResponse } from "@akashnetwork/http-sdk"; -import { Button, Card, CardContent, CardFooter, CardHeader, Spinner, Table, TableBody } from "@akashnetwork/ui/components"; -import { CircularProgress } from "@mui/material"; +import type { PaymentMethod } from "@akashnetwork/http-sdk"; +import { Button, Card, CardContent, CardHeader, Skeleton } from "@akashnetwork/ui/components"; import { Plus } from "lucide-react"; -import { useTheme } from "next-themes"; -import { AddPaymentMethodPopup } from "@src/components/billing-usage/AddPaymentMethodPopup/AddPaymentMethodPopup"; +import { useBillingActions } from "@src/components/billing-usage/BillingActionsProvider/BillingActionsProvider"; import { PaymentMethodsRow } from "./PaymentMethodsRow"; export const DEPENDENCIES = { - useTheme, + useBillingActions, PaymentMethodsRow, - AddPaymentMethodPopup, Card, CardHeader, CardContent, - CardFooter, - Spinner, - Table, - TableBody, - Button, - CircularProgress + Skeleton, + Button }; export type PaymentMethodsViewProps = { data: PaymentMethod[]; onSetPaymentMethodAsDefault: (id: string) => void; onRemovePaymentMethod: (id: string) => Promise | void; - onAddPaymentMethod: () => void; isLoadingPaymentMethods: boolean; - showAddPaymentMethod: boolean; - setShowAddPaymentMethod: (value: boolean) => void; - setupIntent: SetupIntentResponse | undefined; - onAddCardSuccess: () => void; isInProgress: boolean; dependencies?: typeof DEPENDENCIES; }; @@ -41,73 +29,50 @@ export const PaymentMethodsView: React.FC = ({ data, onSetPaymentMethodAsDefault, onRemovePaymentMethod, - onAddPaymentMethod, isLoadingPaymentMethods, - showAddPaymentMethod, - setShowAddPaymentMethod, - setupIntent, - onAddCardSuccess, isInProgress, dependencies: d = DEPENDENCIES }) => { - const { resolvedTheme } = d.useTheme(); - const isDarkMode = resolvedTheme === "dark"; - - if (isLoadingPaymentMethods) { - return ( -
- -
- ); - } + const { openAddPaymentMethod } = d.useBillingActions(); return ( -
- - -
Payment Methods
-
All payments to add credits will be made using your default card.
-
- - {data.length === 0 ? ( -

No payment methods added yet.

- ) : ( - <> - - - {data.map(paymentMethod => ( - - ))} - - - {isInProgress && ( -
- -
- )} - - )} -
- - - - Add Payment Method - - -
- - setShowAddPaymentMethod(false)} - clientSecret={setupIntent?.clientSecret} - isDarkMode={isDarkMode} - onSuccess={onAddCardSuccess} - /> -
+ + +
+

Payment Method

+

All transactions will be made using your default card.

+
+ + + Add Payment Method + +
+ + {isLoadingPaymentMethods ? ( +
+ {Array.from({ length: 2 }).map((_, index) => ( +
+ + + +
+ ))} +
+ ) : data.length === 0 ? ( +

No payment methods added yet.

+ ) : ( +
+ {data.map(paymentMethod => ( + + ))} +
+ )} +
+
); }; diff --git a/apps/deploy-web/src/components/billing-usage/UsagePage.tsx b/apps/deploy-web/src/components/billing-usage/UsagePage.tsx index 5b48362f62..b0f8f79e0b 100644 --- a/apps/deploy-web/src/components/billing-usage/UsagePage.tsx +++ b/apps/deploy-web/src/components/billing-usage/UsagePage.tsx @@ -8,7 +8,7 @@ import { SettingsLayout } from "@src/components/layout/SettingsLayout/SettingsLa export const UsagePage: FC = () => { return ( - + {props => } diff --git a/apps/deploy-web/src/components/billing-usage/useBillingBackgroundLoading.ts b/apps/deploy-web/src/components/billing-usage/useBillingBackgroundLoading.ts new file mode 100644 index 0000000000..56fe2adddf --- /dev/null +++ b/apps/deploy-web/src/components/billing-usage/useBillingBackgroundLoading.ts @@ -0,0 +1,33 @@ +"use client"; +import { useIsFetching } from "@tanstack/react-query"; + +import { useServices } from "@src/context/ServicesProvider"; +import { useWallet } from "@src/context/WalletProvider"; +import { QueryKeys } from "@src/queries/queryKeys"; + +const startsWithPrefix = (key: readonly unknown[], prefix: readonly unknown[]) => prefix.length > 0 && prefix.every((part, index) => key[index] === part); + +/** + * True only when a billing query is refetching *while it already has data* (a background refresh), + * so the page can show an unobtrusive top bar instead of swapping content for a skeleton and jumping. + */ +export function useBillingBackgroundLoading(): boolean { + const { api } = useServices(); + const { address } = useWallet(); + + const prefixes = [ + QueryKeys.getBalancesKey(address), + QueryKeys.getAllLeasesKey(address), + QueryKeys.getPaymentMethodsKey(), + QueryKeys.getWeeklyDeploymentCostKey(), + api.v1.getWalletSettings.getKey(), + api.v1.getDefaultPaymentMethod.getKey(), + api.v1.listStripeTransactions.getKey() + ].filter(prefix => prefix.length > 0); + + const backgroundFetchingCount = useIsFetching({ + predicate: query => query.state.data !== undefined && prefixes.some(prefix => startsWithPrefix(query.queryKey as unknown[], prefix)) + }); + + return backgroundFetchingCount > 0; +} diff --git a/apps/deploy-web/src/components/layout/SettingsLayout/SettingsLayout.tsx b/apps/deploy-web/src/components/layout/SettingsLayout/SettingsLayout.tsx index 0c003395f1..9b0515ef40 100644 --- a/apps/deploy-web/src/components/layout/SettingsLayout/SettingsLayout.tsx +++ b/apps/deploy-web/src/components/layout/SettingsLayout/SettingsLayout.tsx @@ -29,7 +29,7 @@ export const SettingsLayout: React.FunctionComponent = ({ title, descript const links = d.useSettingsNavLinks(); return ( -
+
)}
diff --git a/apps/deploy-web/src/components/billing-usage/AutoTopUpSection/AutoTopUpSection.tsx b/apps/deploy-web/src/components/billing-usage/AutoTopUpSection/AutoTopUpSection.tsx index fa984a120a..2d65b83fa9 100644 --- a/apps/deploy-web/src/components/billing-usage/AutoTopUpSection/AutoTopUpSection.tsx +++ b/apps/deploy-web/src/components/billing-usage/AutoTopUpSection/AutoTopUpSection.tsx @@ -137,7 +137,7 @@ export const AutoTopUpSection: React.FunctionComponent<{ dependencies?: typeof D return Math.max(1, Math.round((overview.available - autoReloadThreshold) / dailySpend)); }, [overview.perHour, overview.available, autoReloadThreshold]); - const usd = (value: number) => ; + const usd = (value: number) => ; return ( <> From c6222a0ba3ad86b6d6efaf77630c8a51785e848b Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:16:53 +0530 Subject: [PATCH 19/28] fix(billing): keep previous transactions page while the next one loads placeholderData: keepPreviousData stops pagination clicks from swapping the history table for a skeleton; the controls disable via isFetching as intended --- apps/deploy-web/src/queries/usePaymentQueries.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/deploy-web/src/queries/usePaymentQueries.ts b/apps/deploy-web/src/queries/usePaymentQueries.ts index be8823623f..ba32eba33b 100644 --- a/apps/deploy-web/src/queries/usePaymentQueries.ts +++ b/apps/deploy-web/src/queries/usePaymentQueries.ts @@ -10,7 +10,7 @@ import type { } from "@akashnetwork/http-sdk"; import { ApiError } from "@akashnetwork/openapi-sdk"; import type { UseQueryOptions, UseQueryResult } from "@tanstack/react-query"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useServices } from "@src/context/ServicesProvider"; import { walletProvisioningRetry } from "@src/utils/walletProvisioning"; @@ -64,7 +64,7 @@ export const usePaymentTransactionsQuery = ({ limit, offset, startDate, endDate startDate: startDate?.toISOString(), endDate: endDate?.toISOString() }, - { select: response => response.data } + { select: response => response.data, placeholderData: keepPreviousData } ); }; From 3637431282e34e5dfc84c1c971cb0028cf5d0f2a Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:47:15 +0530 Subject: [PATCH 20/28] fix(billing): dim the history table while a refetch is in flight keepPreviousData shows the prior page or date range until fresh rows arrive; the opacity cue marks that data as stale in the meantime --- .../src/components/billing-usage/BillingView/BillingView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/deploy-web/src/components/billing-usage/BillingView/BillingView.tsx b/apps/deploy-web/src/components/billing-usage/BillingView/BillingView.tsx index cbc4de33c9..5a4fbe9b35 100644 --- a/apps/deploy-web/src/components/billing-usage/BillingView/BillingView.tsx +++ b/apps/deploy-web/src/components/billing-usage/BillingView/BillingView.tsx @@ -252,7 +252,7 @@ export const BillingView: React.FC = ({

{dateRange ? "No billing history found for the selected date range." : "No billing history found."}

) : ( -
+
{table.getHeaderGroups().map(headerGroup => ( From 96125c4cfdc17d0d6c9c9227c5ec184694c65115 Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Wed, 12 Aug 2026 17:02:28 +0530 Subject: [PATCH 21/28] fix(deployment): gate redesigned detail page alerts on user after flag retirement --- .../DeploymentDetail/DeploymentDetail.spec.tsx | 16 ++++++++-------- .../DeploymentDetail/DeploymentDetail.tsx | 4 +--- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/apps/deploy-web/src/components/deployments/DeploymentDetail/DeploymentDetail.spec.tsx b/apps/deploy-web/src/components/deployments/DeploymentDetail/DeploymentDetail.spec.tsx index 1b39227d15..7b24e0bf0f 100644 --- a/apps/deploy-web/src/components/deployments/DeploymentDetail/DeploymentDetail.spec.tsx +++ b/apps/deploy-web/src/components/deployments/DeploymentDetail/DeploymentDetail.spec.tsx @@ -41,14 +41,14 @@ describe("DeploymentDetail", () => { expect(screen.queryByText("shell")).not.toBeInTheDocument(); }); - it("renders alerts on the Billing tab when alerts are enabled", () => { - setup({ tab: "BILLING", alertsEnabled: true }); + it("renders alerts on the Billing tab when the user is signed in", () => { + setup({ tab: "BILLING" }); expect(screen.getByText("alerts")).toBeInTheDocument(); }); - it("shows a coming-soon note on the Billing tab when alerts are disabled", () => { - setup({ tab: "BILLING", alertsEnabled: false }); + it("shows a coming-soon note on the Billing tab when the user is not signed in", () => { + setup({ tab: "BILLING", isSignedIn: false }); expect(screen.getByText("Billing & notifications are coming soon.")).toBeInTheDocument(); }); @@ -72,7 +72,7 @@ describe("DeploymentDetail", () => { isLeasesLoaded?: boolean; error?: Error | null; tab?: string; - alertsEnabled?: boolean; + isSignedIn?: boolean; leaseState?: string; }) { const deployment = input && "deployment" in input ? input.deployment : mock({ dseq: "1786440078202", state: "active", groups: [] }); @@ -91,8 +91,9 @@ describe("DeploymentDetail", () => { const useWallet: typeof DEPENDENCIES.useWallet = () => mock>({ address: "akash1test" }); const useSettings: typeof DEPENDENCIES.useSettings = () => mock>({ isSettingsInit: true }); const useUser: typeof DEPENDENCIES.useUser = () => - mock>({ user: mock["user"]>>({ userId: "u1" }) }); - const useFlag: typeof DEPENDENCIES.useFlag = () => input?.alertsEnabled ?? false; + mock>({ + user: input?.isSignedIn === false ? undefined : mock["user"]>>({ userId: "u1" }) + }); const useRouter: typeof DEPENDENCIES.useRouter = () => router; const searchParams = new URLSearchParams(input?.tab ? `tab=${input.tab}` : ""); const useSearchParams: typeof DEPENDENCIES.useSearchParams = () => searchParams as unknown as ReturnType; @@ -117,7 +118,6 @@ describe("DeploymentDetail", () => { useWallet, useSettings, useUser, - useFlag, useRouter, useSearchParams, useDeploymentDetail, diff --git a/apps/deploy-web/src/components/deployments/DeploymentDetail/DeploymentDetail.tsx b/apps/deploy-web/src/components/deployments/DeploymentDetail/DeploymentDetail.tsx index ddf58a27e5..7502685004 100644 --- a/apps/deploy-web/src/components/deployments/DeploymentDetail/DeploymentDetail.tsx +++ b/apps/deploy-web/src/components/deployments/DeploymentDetail/DeploymentDetail.tsx @@ -13,7 +13,6 @@ import { DeploymentAlerts } from "@src/components/deployments/DeploymentAlerts/D import { useServices } from "@src/context/ServicesProvider"; import { useSettings } from "@src/context/SettingsProvider"; import { useWallet } from "@src/context/WalletProvider"; -import { useFlag } from "@src/hooks/useFlag"; import { useUser } from "@src/hooks/useUser"; import { useDeploymentDetail } from "@src/queries/useDeploymentQuery"; import { useDeploymentLeaseList } from "@src/queries/useLeaseQuery"; @@ -35,7 +34,6 @@ export const DEPENDENCIES = { useWallet, useSettings, useUser, - useFlag, useRouter, useSearchParams, useDeploymentDetail, @@ -76,7 +74,7 @@ export const DeploymentDetail: FC = ({ dseq, dependencies const { address } = d.useWallet(); const { isSettingsInit } = d.useSettings(); const { user } = d.useUser(); - const isAlertsEnabled = d.useFlag("alerts") && !!user?.userId; + const isAlertsEnabled = !!user?.userId; const [activeTab, setActiveTab] = useState("DETAILS"); const [editedManifest, setEditedManifest] = useState(null); From f84106249d7c8b3e516b4db8467b01ad1ce36445 Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Wed, 12 Aug 2026 17:22:11 +0530 Subject: [PATCH 22/28] fix(billing): show an error state when the balance overview cannot load balances --- .../AccountBalanceOverview.spec.tsx | 9 ++++++++ .../AccountBalanceOverview.tsx | 14 ++++++++++++ .../useAccountBalanceOverview.spec.ts | 22 ++++++++++++++++++- .../useAccountBalanceOverview.ts | 8 +++++-- 4 files changed, 50 insertions(+), 3 deletions(-) diff --git a/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/AccountBalanceOverview.spec.tsx b/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/AccountBalanceOverview.spec.tsx index 67f97bf4bc..32921c88ed 100644 --- a/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/AccountBalanceOverview.spec.tsx +++ b/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/AccountBalanceOverview.spec.tsx @@ -139,6 +139,14 @@ describe(AccountBalanceOverview.name, () => { expect(screen.queryByLabelText("Total account balance")).not.toBeInTheDocument(); }); + it("keeps the card title and explains when the balance can't be loaded", () => { + setup({ isError: true }); + + expect(screen.getByText("Account balance")).toBeInTheDocument(); + expect(screen.getByText(/couldn't be loaded/)).toBeInTheDocument(); + expect(screen.queryByLabelText("Total account balance")).not.toBeInTheDocument(); + }); + function setup(overview: Partial) { const MockFormattedNumber = vi.fn(({ value }: { value: number }) => <>{value}); @@ -154,6 +162,7 @@ describe(AccountBalanceOverview.name, () => { autoReloadEnabled: false, autoReloadThreshold: null, isLoading: false, + isError: false, ...partial }; diff --git a/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/AccountBalanceOverview.tsx b/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/AccountBalanceOverview.tsx index 2dd6bd6843..f909d72ad7 100644 --- a/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/AccountBalanceOverview.tsx +++ b/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/AccountBalanceOverview.tsx @@ -40,6 +40,20 @@ export const AccountBalanceOverview: React.FunctionComponent<{ dependencies?: ty const segments = useMemo(() => buildBalanceSegments(overview.deployments, overview.available), [overview.deployments, overview.available]); const usd = (value: number) => ; + if (overview.isError) { + return ( + + +

Account balance

+ +
+ +

Your balance couldn't be loaded. It will refresh automatically once the connection recovers.

+
+
+ ); + } + if (overview.isLoading) { return ( diff --git a/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/useAccountBalanceOverview.spec.ts b/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/useAccountBalanceOverview.spec.ts index e57719bd33..7d9fedd629 100644 --- a/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/useAccountBalanceOverview.spec.ts +++ b/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/useAccountBalanceOverview.spec.ts @@ -107,6 +107,20 @@ describe(useAccountBalanceOverview.name, () => { expect(result.current.isLoading).toBe(true); }); + it("reports an error instead of loading forever when the balances query fails", () => { + const { result } = setup({ balancesMissing: true, balancesError: true }); + + expect(result.current.isError).toBe(true); + expect(result.current.isLoading).toBe(false); + }); + + it("reports an error instead of loading forever when the chain API fallback disables the balances query", () => { + const { result } = setup({ balancesMissing: true, balancesIdle: true }); + + expect(result.current.isError).toBe(true); + expect(result.current.isLoading).toBe(false); + }); + it("still resolves balances when the AKT market price is unavailable", () => { const { result } = setup({ totalUsd: 500, reservedUsd: 150, priceUnavailable: true }); @@ -126,6 +140,8 @@ describe(useAccountBalanceOverview.name, () => { autoReloadThreshold?: number; fixedThresholdEnabled?: boolean; balancesMissing?: boolean; + balancesError?: boolean; + balancesIdle?: boolean; priceUnavailable?: boolean; }) { const reservedDeployments = input.deployments ?? (input.reservedUsd ? [{ dseq: "reserved", fundsUsd: input.reservedUsd }] : []); @@ -164,7 +180,11 @@ describe(useAccountBalanceOverview.name, () => { udenomToUsd: (amount: string | number) => Number(amount) }), useFlag: () => input.fixedThresholdEnabled ?? false, - useBalances: () => ({ data: input.balancesMissing ? undefined : balances }), + useBalances: () => ({ + data: input.balancesMissing ? undefined : balances, + isError: input.balancesError ?? false, + fetchStatus: input.balancesIdle ? "idle" : "fetching" + }), useAllLeases: () => ({ data: leases }), useWalletSettingsQuery: () => ({ data: { autoReloadEnabled: input.autoReloadEnabled ?? false, autoReloadThreshold: input.autoReloadThreshold } }), useLocalNotes: () => ({ getDeploymentName: (dseq: string | number | null) => input.names?.[String(dseq)] ?? null }) diff --git a/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/useAccountBalanceOverview.ts b/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/useAccountBalanceOverview.ts index 7da1a828a6..58be8ec403 100644 --- a/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/useAccountBalanceOverview.ts +++ b/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/useAccountBalanceOverview.ts @@ -43,12 +43,14 @@ export type AccountBalanceOverview = { /** The auto-top-up trigger balance to mark on the bar, or null when no marker should render (flag off, auto-reload off, or unset). */ autoReloadThreshold: number | null; isLoading: boolean; + /** True when balances can't be loaded: the query errored out or the chain API fallback disabled it. */ + isError: boolean; }; export function useAccountBalanceOverview({ dependencies: d = DEPENDENCIES }: { dependencies?: typeof DEPENDENCIES } = {}): AccountBalanceOverview { const { address } = d.useWallet(); const { price, udenomToUsd } = d.usePricing(); - const { data: balances } = d.useBalances(address); + const { data: balances, isError: isBalancesError, fetchStatus: balancesFetchStatus } = d.useBalances(address); const { data: leases } = d.useAllLeases(address); const { data: walletSettings } = d.useWalletSettingsQuery(); const { getDeploymentName } = d.useLocalNotes(); @@ -89,6 +91,7 @@ export function useAccountBalanceOverview({ dependencies: d = DEPENDENCIES }: { const hasSpend = spend.perBlockUsd > 0; const lastsUntil = hasSpend ? getTimeLeft(spend.perBlockUsd, totalUsd) : null; const autoReloadEnabled = walletSettings?.autoReloadEnabled ?? false; + const isBalanceUnavailable = isBalancesError || (!!address && !balances && balancesFetchStatus === "idle"); const autoReloadThreshold = isFixedThresholdEnabled && autoReloadEnabled ? walletSettings?.autoReloadThreshold ?? null : null; return { @@ -101,6 +104,7 @@ export function useAccountBalanceOverview({ dependencies: d = DEPENDENCIES }: { runwayDays: lastsUntil ? differenceInCalendarDays(lastsUntil, new Date()) : null, autoReloadEnabled, autoReloadThreshold, - isLoading: !walletBalance + isLoading: !walletBalance && !isBalanceUnavailable, + isError: isBalanceUnavailable }; } From 410865228185bcc902e9e74e1a2d5cdb1d0feca6 Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Wed, 12 Aug 2026 17:41:03 +0530 Subject: [PATCH 23/28] refactor(billing): restore isFeatureEnabled page guard for future flag-gated pages --- apps/deploy-web/src/lib/nextjs/pageGuards/pageGuards.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/deploy-web/src/lib/nextjs/pageGuards/pageGuards.ts b/apps/deploy-web/src/lib/nextjs/pageGuards/pageGuards.ts index 40c62269c1..bad380e1ce 100644 --- a/apps/deploy-web/src/lib/nextjs/pageGuards/pageGuards.ts +++ b/apps/deploy-web/src/lib/nextjs/pageGuards/pageGuards.ts @@ -3,6 +3,12 @@ import type { Redirect } from "next"; import { isAccessTokenExpired } from "@src/lib/auth0/isAccessTokenExpired/isAccessTokenExpired"; import type { AppTypedContext } from "../defineServerSideProps/defineServerSideProps"; +/** Currently unused page guard, kept for gating future flag-protected pages. @public */ +export async function isFeatureEnabled(featureName: string, context: AppTypedContext): Promise { + const session = await context.getCurrentSession(); + return await context.services.featureFlagService.isEnabledForCtx(featureName, context, { userId: session?.user?.id }); +} + export async function isAuthenticated(context: AppTypedContext): Promise { const session = await context.getCurrentSession(); if (!session?.user) return false; From 879e59115fff83d36d7daa1b298fa65a71e95404 Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Wed, 12 Aug 2026 17:56:23 +0530 Subject: [PATCH 24/28] fix(deployment): restore the page URL after detail tab navigation specs --- .../deployments/DeploymentDetail/DeploymentDetail.spec.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/deploy-web/src/components/deployments/DeploymentDetail/DeploymentDetail.spec.tsx b/apps/deploy-web/src/components/deployments/DeploymentDetail/DeploymentDetail.spec.tsx index 7b24e0bf0f..d7e6ad3505 100644 --- a/apps/deploy-web/src/components/deployments/DeploymentDetail/DeploymentDetail.spec.tsx +++ b/apps/deploy-web/src/components/deployments/DeploymentDetail/DeploymentDetail.spec.tsx @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { mock } from "vitest-mock-extended"; import type { DeploymentDto, LeaseDto } from "@src/types/deployment"; @@ -10,6 +10,10 @@ import userEvent from "@testing-library/user-event"; import { MockComponents } from "@tests/unit/mocks"; describe("DeploymentDetail", () => { + afterEach(function restoreUrlMutatedByTabNavigation() { + window.history.replaceState(window.history.state, "", "/"); + }); + it("renders the tab bar and lease rows when the deployment and leases are loaded", () => { setup(); From 708fe096643d6e44ca019fcf82a1853b5b28bc71 Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Wed, 12 Aug 2026 17:56:29 +0530 Subject: [PATCH 25/28] refactor(billing): share one UsdValue component between balance overview and auto top-up --- .../AccountBalanceOverview.spec.tsx | 4 ++-- .../AccountBalanceOverview.tsx | 6 +++--- .../AutoTopUpSection/AutoTopUpSection.spec.tsx | 4 ++-- .../AutoTopUpSection/AutoTopUpSection.tsx | 12 ++++-------- .../components/billing-usage/UsdValue/UsdValue.tsx | 6 ++++++ 5 files changed, 17 insertions(+), 15 deletions(-) create mode 100644 apps/deploy-web/src/components/billing-usage/UsdValue/UsdValue.tsx diff --git a/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/AccountBalanceOverview.spec.tsx b/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/AccountBalanceOverview.spec.tsx index 32921c88ed..5f04087a5a 100644 --- a/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/AccountBalanceOverview.spec.tsx +++ b/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/AccountBalanceOverview.spec.tsx @@ -148,7 +148,7 @@ describe(AccountBalanceOverview.name, () => { }); function setup(overview: Partial) { - const MockFormattedNumber = vi.fn(({ value }: { value: number }) => <>{value}); + const MockUsdValue = vi.fn(({ value }: { value: number }) => <>{value}); const renderView = (partial: Partial) => { const data: AccountBalanceOverviewData = { @@ -172,7 +172,7 @@ describe(AccountBalanceOverview.name, () => { { ...MockComponents(DEPENDENCIES), useAccountBalanceOverview: () => data, - FormattedNumber: MockFormattedNumber, + UsdValue: MockUsdValue, Link: ({ href, children }: { href: string; children: ReactNode }) => {children} } as unknown as typeof DEPENDENCIES } diff --git a/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/AccountBalanceOverview.tsx b/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/AccountBalanceOverview.tsx index f909d72ad7..e3621c2b32 100644 --- a/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/AccountBalanceOverview.tsx +++ b/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/AccountBalanceOverview.tsx @@ -1,11 +1,11 @@ "use client"; import React, { useMemo, useState } from "react"; -import { FormattedNumber } from "react-intl"; import { Card, CardContent, CardHeader, CustomNoDivTooltip, Skeleton } from "@akashnetwork/ui/components"; import format from "date-fns/format"; import { InfoCircle, NavArrowDown, NavArrowRight, Wallet } from "iconoir-react"; import Link from "next/link"; +import { UsdValue } from "@src/components/billing-usage/UsdValue/UsdValue"; import { UrlService } from "@src/utils/urlUtils"; import { BalanceBreakdownBar, buildBalanceSegments } from "./BalanceBreakdownBar"; import { useAccountBalanceOverview } from "./useAccountBalanceOverview"; @@ -17,7 +17,7 @@ export const DEPENDENCIES = { CardHeader, CustomNoDivTooltip, Skeleton, - FormattedNumber, + UsdValue, BalanceBreakdownBar, Wallet, InfoCircle, @@ -38,7 +38,7 @@ export const AccountBalanceOverview: React.FunctionComponent<{ dependencies?: ty const [hoveredKey, setHoveredKey] = useState(null); const [isBreakdownOpen, setIsBreakdownOpen] = useState(false); const segments = useMemo(() => buildBalanceSegments(overview.deployments, overview.available), [overview.deployments, overview.available]); - const usd = (value: number) => ; + const usd = (value: number) => ; if (overview.isError) { return ( diff --git a/apps/deploy-web/src/components/billing-usage/AutoTopUpSection/AutoTopUpSection.spec.tsx b/apps/deploy-web/src/components/billing-usage/AutoTopUpSection/AutoTopUpSection.spec.tsx index 7f417bcc5a..edd7c99bb7 100644 --- a/apps/deploy-web/src/components/billing-usage/AutoTopUpSection/AutoTopUpSection.spec.tsx +++ b/apps/deploy-web/src/components/billing-usage/AutoTopUpSection/AutoTopUpSection.spec.tsx @@ -212,7 +212,7 @@ describe(AutoTopUpSection.name, () => { const MockSwitch = vi.fn(({ checked, onCheckedChange, disabled }: Parameters[0]) => ( onCheckedChange?.(e.target.checked)} /> )); - const MockFormattedNumber = vi.fn(({ value }: Parameters[0]) => <>{value}); + const MockUsdValue = vi.fn(({ value }: Parameters[0]) => <>{value}); const dependencies = { ...MockComponents(DEPENDENCIES), @@ -227,7 +227,7 @@ describe(AutoTopUpSection.name, () => { usePopup: vi.fn(() => ({ confirm: vi.fn().mockResolvedValue(input.confirmResult ?? true) })), Button: MockButton, Switch: MockSwitch, - FormattedNumber: MockFormattedNumber + UsdValue: MockUsdValue } as unknown as typeof DEPENDENCIES; render(); diff --git a/apps/deploy-web/src/components/billing-usage/AutoTopUpSection/AutoTopUpSection.tsx b/apps/deploy-web/src/components/billing-usage/AutoTopUpSection/AutoTopUpSection.tsx index 2d65b83fa9..239bf79732 100644 --- a/apps/deploy-web/src/components/billing-usage/AutoTopUpSection/AutoTopUpSection.tsx +++ b/apps/deploy-web/src/components/billing-usage/AutoTopUpSection/AutoTopUpSection.tsx @@ -1,6 +1,5 @@ "use client"; import React, { useCallback, useMemo, useState } from "react"; -import { FormattedNumber } from "react-intl"; import type { PaymentMethod } from "@akashnetwork/http-sdk"; import { Button, Card, CardContent, CardHeader, Skeleton, Snackbar, Switch } from "@akashnetwork/ui/components"; import { usePopup } from "@akashnetwork/ui/context"; @@ -15,6 +14,7 @@ import { DEFAULT_AUTO_RELOAD_THRESHOLD } from "@src/components/billing-usage/AutoTopUpSettingsPopup/AutoTopUpSettingsPopup"; import { useBillingActions } from "@src/components/billing-usage/BillingActionsProvider/BillingActionsProvider"; +import { UsdValue } from "@src/components/billing-usage/UsdValue/UsdValue"; import { useFlag } from "@src/hooks/useFlag"; import { useDefaultPaymentMethodQuery, useWalletSettingsMutations, useWalletSettingsQuery, useWeeklyDeploymentCostQuery } from "@src/queries"; import { capitalizeFirstLetter } from "@src/utils/stringUtils"; @@ -32,7 +32,7 @@ export const DEPENDENCIES = { useBillingActions, useFlag, AutoTopUpSettingsPopup, - FormattedNumber, + UsdValue, Button, Card, CardContent, @@ -137,7 +137,7 @@ export const AutoTopUpSection: React.FunctionComponent<{ dependencies?: typeof D return Math.max(1, Math.round((overview.available - autoReloadThreshold) / dailySpend)); }, [overview.perHour, overview.available, autoReloadThreshold]); - const usd = (value: number) => ; + const usd = (value: number) => ; return ( <> @@ -221,11 +221,7 @@ export const AutoTopUpSection: React.FunctionComponent<{ dependencies?: typeof D ) ) : (

- Recharge amount is approximately{" "} - - - {" "} - per week + Recharge amount is approximately {usd(weeklyCost ?? 0)} per week

)} diff --git a/apps/deploy-web/src/components/billing-usage/UsdValue/UsdValue.tsx b/apps/deploy-web/src/components/billing-usage/UsdValue/UsdValue.tsx new file mode 100644 index 0000000000..3ccbec77ce --- /dev/null +++ b/apps/deploy-web/src/components/billing-usage/UsdValue/UsdValue.tsx @@ -0,0 +1,6 @@ +"use client"; +import type React from "react"; +import { FormattedNumber } from "react-intl"; + +/** Single source of the USD display format shared by the billing components, so the copies can't drift apart. */ +export const UsdValue: React.FunctionComponent<{ value: number }> = ({ value }) => ; From bc8c3840b97b4c7a484ebb4c60f4638443f1d8c4 Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Wed, 12 Aug 2026 18:04:35 +0530 Subject: [PATCH 26/28] fix(billing): ignore drained deployments when computing balance bar shades --- .../AccountBalanceOverview/BalanceBreakdownBar.spec.tsx | 7 +++++++ .../AccountBalanceOverview/BalanceBreakdownBar.tsx | 5 +++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/BalanceBreakdownBar.spec.tsx b/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/BalanceBreakdownBar.spec.tsx index 5b97af9221..baf6230f19 100644 --- a/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/BalanceBreakdownBar.spec.tsx +++ b/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/BalanceBreakdownBar.spec.tsx @@ -31,6 +31,13 @@ describe(buildBalanceSegments.name, () => { expect(buildBalanceSegments(deployments([0]), 0)).toEqual([]); }); + it("shades visible segments the same regardless of drained deployments in the list", () => { + const withDrained = buildBalanceSegments(deployments([100, 50, 0]), 0); + const withoutDrained = buildBalanceSegments(deployments([100, 50]), 0); + + expect(withDrained.map(s => s.color)).toEqual(withoutDrained.map(s => s.color)); + }); + it("carries each deployment's hourly rate onto its reserved segment but not the available one", () => { const segments = buildBalanceSegments(deployments([120]), 200); diff --git a/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/BalanceBreakdownBar.tsx b/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/BalanceBreakdownBar.tsx index 2cee8c4976..32d463a2c4 100644 --- a/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/BalanceBreakdownBar.tsx +++ b/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/BalanceBreakdownBar.tsx @@ -48,8 +48,9 @@ function buildThresholdMarker(threshold: number, available: number, total: numbe * Reserved deployments use a single-hue ramp (sorted largest-first); Available is the success green. */ export function buildBalanceSegments(deployments: ReservedDeployment[], available: number): BalanceSegment[] { - const reservedSegments = deployments.map((deployment, index) => { - const alpha = reservedAlpha(index, deployments.length); + const fundedDeployments = deployments.filter(deployment => deployment.reservedUsd > 0); + const reservedSegments = fundedDeployments.map((deployment, index) => { + const alpha = reservedAlpha(index, fundedDeployments.length); return { key: deployment.dseq, label: deployment.name, From fe8b3519cac314e8d49afc01b91210e0ba88140f Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Wed, 12 Aug 2026 18:28:24 +0530 Subject: [PATCH 27/28] fix(billing): keep showing cached balances when a background refetch fails --- .../useAccountBalanceOverview.spec.ts | 8 ++++++++ .../AccountBalanceOverview/useAccountBalanceOverview.ts | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/useAccountBalanceOverview.spec.ts b/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/useAccountBalanceOverview.spec.ts index 7d9fedd629..959e87a15a 100644 --- a/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/useAccountBalanceOverview.spec.ts +++ b/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/useAccountBalanceOverview.spec.ts @@ -121,6 +121,14 @@ describe(useAccountBalanceOverview.name, () => { expect(result.current.isLoading).toBe(false); }); + it("keeps showing cached balances when a background refetch fails", () => { + const { result } = setup({ totalUsd: 500, balancesError: true }); + + expect(result.current.isError).toBe(false); + expect(result.current.isLoading).toBe(false); + expect(result.current.totalUsd).toBe(500); + }); + it("still resolves balances when the AKT market price is unavailable", () => { const { result } = setup({ totalUsd: 500, reservedUsd: 150, priceUnavailable: true }); diff --git a/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/useAccountBalanceOverview.ts b/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/useAccountBalanceOverview.ts index 58be8ec403..ef997a5cd3 100644 --- a/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/useAccountBalanceOverview.ts +++ b/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/useAccountBalanceOverview.ts @@ -91,7 +91,7 @@ export function useAccountBalanceOverview({ dependencies: d = DEPENDENCIES }: { const hasSpend = spend.perBlockUsd > 0; const lastsUntil = hasSpend ? getTimeLeft(spend.perBlockUsd, totalUsd) : null; const autoReloadEnabled = walletSettings?.autoReloadEnabled ?? false; - const isBalanceUnavailable = isBalancesError || (!!address && !balances && balancesFetchStatus === "idle"); + const isBalanceUnavailable = !balances && (isBalancesError || (!!address && balancesFetchStatus === "idle")); const autoReloadThreshold = isFixedThresholdEnabled && autoReloadEnabled ? walletSettings?.autoReloadThreshold ?? null : null; return { From 116c5c6b1a4719a406d05d1e40841f3f6b393391 Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Wed, 12 Aug 2026 18:51:30 +0530 Subject: [PATCH 28/28] fix(billing): format balance bar amounts with the app locale like the rest of the card --- .../AccountBalanceOverview/BalanceBreakdownBar.spec.tsx | 7 ++++++- .../AccountBalanceOverview/BalanceBreakdownBar.tsx | 5 +++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/BalanceBreakdownBar.spec.tsx b/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/BalanceBreakdownBar.spec.tsx index baf6230f19..0d5652374c 100644 --- a/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/BalanceBreakdownBar.spec.tsx +++ b/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/BalanceBreakdownBar.spec.tsx @@ -1,3 +1,4 @@ +import { IntlProvider } from "react-intl"; import { describe, expect, it } from "vitest"; import { BalanceBreakdownBar, buildBalanceSegments } from "./BalanceBreakdownBar"; @@ -103,6 +104,10 @@ describe(BalanceBreakdownBar.name, () => { }); function setup(input: { segments: Parameters[0]["segments"]; threshold?: number | null }) { - return render(); + return render( + + + + ); } }); diff --git a/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/BalanceBreakdownBar.tsx b/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/BalanceBreakdownBar.tsx index 32d463a2c4..3d25e8f361 100644 --- a/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/BalanceBreakdownBar.tsx +++ b/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/BalanceBreakdownBar.tsx @@ -1,8 +1,8 @@ "use client"; import React from "react"; +import { useIntl } from "react-intl"; import { Flash } from "iconoir-react"; -import { useCurrencyFormatter } from "@src/hooks/useCurrencyFormatter/useCurrencyFormatter"; import type { ReservedDeployment } from "./useAccountBalanceOverview"; export type BalanceSegment = { @@ -80,7 +80,8 @@ export const BalanceBreakdownBar: React.FunctionComponent<{ onHover?: (key: string | null) => void; threshold?: number | null; }> = ({ segments, hoveredKey = null, onHover, threshold = null }) => { - const formatUsd = useCurrencyFormatter(); + const intl = useIntl(); + const formatUsd = (value: number) => intl.formatNumber(value, { style: "currency", currency: "USD" }); const label = segments.map(segment => `${segment.label} ${formatUsd(segment.amountUsd)}`).join(", "); const total = segments.reduce((sum, segment) => sum + segment.amountUsd, 0); const available = segments.find(segment => segment.key === "available")?.amountUsd ?? 0;