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..4ff68eaa29 100644 --- a/apps/deploy-web/src/components/alerts/AlertsPage.tsx +++ b/apps/deploy-web/src/components/alerts/AlertsPage.tsx @@ -1,18 +1,44 @@ import React, { type FC } from "react"; +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"; 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 { UrlService } from "@src/utils/urlUtils"; export const AlertsPage: FC = () => { return ( - - - - {props => } - + + + + + + Alerts + Notification Channels + + + + {props => } + + + +
+ + + 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..770de3bfe4 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, @@ -48,17 +50,19 @@ export function ApiKeysPage({ dependencies: d = DEPENDENCIES }: Props = {}) { }; return ( - + - setApiKeyToDelete(apiKey)} - /> + + setApiKeyToDelete(apiKey)} + /> + ); } 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..5f04087a5a --- /dev/null +++ b/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/AccountBalanceOverview.spec.tsx @@ -0,0 +1,187 @@ +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 { fireEvent, 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("shows the reserved and available balances with their descriptors", () => { + setup({ + reserved: 1338, + available: 1873.2, + deployments: [ + { dseq: "1", name: "app-a", reservedUsd: 1000, perHourUsd: 1 }, + { dseq: "2", name: "app-b", reservedUsd: 338, perHourUsd: 1 } + ] + }); + + expect(screen.getByLabelText("Reserved balance")).toHaveTextContent("1338"); + expect(screen.getByLabelText("Available balance")).toHaveTextContent("1873.2"); + expect(screen.getByText("Held to keep your 2 deployments running")).toBeInTheDocument(); + expect(screen.getByText("Free to spend on something new")).toBeInTheDocument(); + }); + + it("uses singular wording when a single deployment holds reserved funds", () => { + setup({ reserved: 100, deployments: [{ dseq: "1", name: "app-a", reservedUsd: 100, perHourUsd: 1 }] }); + + expect(screen.getByText("Held to keep your 1 deployment running")).toBeInTheDocument(); + }); + + 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(/\/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("counts only deployments that still hold reserved funds so the labels match the badges", () => { + setup({ + deployments: [ + { dseq: "1", name: "llama-chat", reservedUsd: 508.8, perHourUsd: 4.24 }, + { dseq: "2", name: "drained-app", reservedUsd: 0, perHourUsd: 0 } + ] + }); + + expect(screen.getByRole("button", { name: /What is reserved \(1\)/ })).toBeInTheDocument(); + expect(screen.getByText("Held to keep your 1 deployment running")).toBeInTheDocument(); + }); + + it("hides the breakdown toggle when no deployment holds reserved funds", () => { + setup({ deployments: [{ dseq: "1", name: "drained-app", reservedUsd: 0, perHourUsd: 0 }] }); + + expect(screen.queryByRole("button", { name: /What is reserved/ })).not.toBeInTheDocument(); + }); + + it("clears hover dimming when the hovered deployment disappears mid-hover", () => { + const deployments = [ + { dseq: "1", name: "llama-chat", reservedUsd: 100, perHourUsd: 1 }, + { dseq: "2", name: "side-api", reservedUsd: 50, perHourUsd: 1 } + ]; + const { rerenderWith } = setup({ deployments, available: 100 }); + + fireEvent.click(screen.getByRole("button", { name: /What is reserved/ })); + fireEvent.mouseEnter(screen.getByRole("link", { name: /llama-chat/ }).closest("li")!); + + expect(screen.getByRole("link", { name: /side-api/ }).closest("li")).toHaveStyle({ opacity: "0.4" }); + + rerenderWith({ deployments: deployments.slice(1), available: 100 }); + + expect(screen.getByRole("link", { name: /side-api/ }).closest("li")).toHaveStyle({ opacity: "1" }); + }); + + 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", () => { + 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("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 }); + + 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 MockUsdValue = vi.fn(({ value }: { value: number }) => <>{value}); + + const renderView = (partial: Partial) => { + const data: AccountBalanceOverviewData = { + totalUsd: 0, + reserved: 0, + available: 0, + deployments: [], + perHour: 0, + lastsUntil: null, + runwayDays: null, + autoReloadEnabled: false, + autoReloadThreshold: null, + isLoading: false, + isError: false, + ...partial + }; + + return ( + data, + UsdValue: MockUsdValue, + Link: ({ href, children }: { href: string; children: ReactNode }) => {children} + } as unknown as typeof DEPENDENCIES + } + /> + ); + }; + + const view = render(renderView(overview)); + + return { ...view, rerenderWith: (next: Partial) => view.rerender(renderView(next)) }; + } +}); 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..e3621c2b32 --- /dev/null +++ b/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/AccountBalanceOverview.tsx @@ -0,0 +1,180 @@ +"use client"; +import React, { useMemo, useState } from "react"; +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"; + +export const DEPENDENCIES = { + useAccountBalanceOverview, + Card, + CardContent, + CardHeader, + CustomNoDivTooltip, + Skeleton, + UsdValue, + BalanceBreakdownBar, + Wallet, + 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 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 ( + + + + + + + + + + + + ); + } + + const reservedSegments = segments.filter(segment => segment.key !== "available"); + const activeHoveredKey = hoveredKey && segments.some(segment => segment.key === hoveredKey) ? hoveredKey : null; + const hasRunway = overview.runwayDays !== null && overview.lastsUntil !== null; + + return ( + + +

Account balance

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

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

+ )} +
+ + + +
+
+
+ + Reserved + + + + + +
+
+ {usd(overview.reserved)} +
+

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

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

Free to spend on something new

+
+
+ + {reservedSegments.length > 0 && ( +
+ + {isBreakdownOpen && ( +
+
    + {reservedSegments.map(segment => ( +
  • setHoveredKey(segment.key)} + onMouseLeave={() => setHoveredKey(null)} + > + + + {segment.label} + {segment.perHourUsd !== undefined && {usd(segment.perHourUsd)}/hr} + {usd(segment.amountUsd)} + +
  • + ))} +
+

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

+
+ )} +
+ )} + + {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 new file mode 100644 index 0000000000..0d5652374c --- /dev/null +++ b/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/BalanceBreakdownBar.spec.tsx @@ -0,0 +1,113 @@ +import { IntlProvider } from "react-intl"; +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([]); + }); + + 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); + + 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, perHourUsd: reservedUsd / 120 })); + } +}); + +describe(BalanceBreakdownBar.name, () => { + it("renders one element per segment sized by flex-grow", () => { + setup({ + segments: [ + { 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({ segments: [{ key: "d1", label: "llama", amountUsd: 100, color: "hsl(var(--primary))" }] }); + + expect(screen.getByRole("img").getAttribute("aria-label")).toContain("llama"); + }); + + it("marks the auto top-up threshold when one is provided", () => { + setup({ + segments: [ + { key: "d1", label: "llama", amountUsd: 1000, color: "hsl(var(--primary))" }, + { key: "available", label: "Available", amountUsd: 1000, color: "hsl(var(--success))" } + ], + threshold: 250 + }); + + expect(screen.getByText(/Tops up at/)).toHaveTextContent("$250"); + }); + + it("omits the threshold marker when no threshold is provided", () => { + setup({ segments: [{ 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({ + segments: [ + { key: "d1", label: "llama", amountUsd: 1000, color: "hsl(var(--primary))" }, + { key: "available", label: "Available", amountUsd: 1000, color: "hsl(var(--success))" } + ], + threshold: 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(input: { 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 new file mode 100644 index 0000000000..3d25e8f361 --- /dev/null +++ b/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/BalanceBreakdownBar.tsx @@ -0,0 +1,137 @@ +"use client"; +import React from "react"; +import { useIntl } from "react-intl"; +import { Flash } from "iconoir-react"; + +import type { ReservedDeployment } from "./useAccountBalanceOverview"; + +export type BalanceSegment = { + key: string; + 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; +}; + +/** 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)); +} + +/** 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 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, + 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[]; + hoveredKey?: string | null; + onHover?: (key: string | null) => void; + threshold?: number | null; +}> = ({ segments, hoveredKey = null, onHover, threshold = null }) => { + 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; + const marker = threshold !== null && threshold > 0 && available > 0 && total > 0 ? buildThresholdMarker(threshold, available, total) : null; + + return ( +
+
+
+ {segments.map(segment => ( +
onHover?.(segment.key)} + onMouseLeave={() => onHover?.(null)} + /> + ))} + {marker && ( +
+ )} +
+ {marker && ( +
+ )} +
+ {marker && ( +

+ + + Tops up at {formatUsd(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 new file mode 100644 index 0000000000..959e87a15a --- /dev/null +++ b/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/useAccountBalanceOverview.spec.ts @@ -0,0 +1,203 @@ +import { describe, expect, it } from "vitest"; + +import { UACT_DENOM, 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, perHourUsd: 0 }, + { dseq: "1", name: "Deployment 1", reservedUsd: 50, perHourUsd: 0 } + ]); + }); + + it("keeps reserved in sync with the per-deployment breakdown since both come from the same balances", () => { + const { result } = setup({ + totalUsd: 500, + deployments: [ + { dseq: "1", fundsUsd: 100 }, + { dseq: "2", fundsUsd: 50 } + ] + }); + + expect(result.current.reserved).toBe(150); + expect(result.current.reserved).toBe(result.current.deployments.reduce((sum, deployment) => sum + deployment.reservedUsd, 0)); + expect(result.current.available).toBe(350); + }); + + 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("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", amount: "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 balances resolve", () => { + const { result } = setup({ balancesMissing: true }); + + 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("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 }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.totalUsd).toBe(500); + expect(result.current.available).toBe(350); + }); + + function setup(input: { + totalUsd?: number; + reservedUsd?: number; + deployments?: Array<{ dseq: string; fundsUsd: number }>; + names?: Record; + leases?: Array<{ dseq: string; amount?: string }>; + hasLiveLease?: boolean; + autoReloadEnabled?: boolean; + autoReloadThreshold?: number; + fixedThresholdEnabled?: boolean; + balancesMissing?: boolean; + balancesError?: boolean; + balancesIdle?: boolean; + priceUnavailable?: boolean; + }) { + const reservedDeployments = input.deployments ?? (input.reservedUsd ? [{ dseq: "reserved", fundsUsd: input.reservedUsd }] : []); + const activeDeployments = reservedDeployments.map(deployment => ({ + dseq: deployment.dseq, + escrowAccount: { state: { funds: [{ denom: UAKT_DENOM, amount: String(deployment.fundsUsd) }] } } + })); + const reservedTotal = reservedDeployments.reduce((sum, deployment) => sum + deployment.fundsUsd, 0); + + const balances = { + balanceUAKT: 0, + balanceUUSDC: 0, + balanceUACT: (input.totalUsd ?? 0) - reservedTotal, + deploymentEscrowUAKT: 0, + deploymentEscrowUUSDC: 0, + deploymentEscrowUACT: 0, + deploymentGrantsUAKT: 0, + deploymentGrantsUUSDC: 0, + deploymentGrantsUACT: 0, + activeDeployments, + deploymentGrants: [] + }; + + const leases = input.leases + ? input.leases.map(lease => ({ dseq: lease.dseq, state: "active", price: { denom: UACT_DENOM, amount: lease.amount ?? "1000000" } })) + : input.hasLiveLease + ? [{ state: "active", price: { denom: UACT_DENOM, amount: "1000000" } }] + : []; + + const dependencies = { + ...DEPENDENCIES, + useWallet: () => ({ address: "akash1abc" }), + usePricing: () => ({ + price: input.priceUnavailable ? undefined : 1, + isLoaded: !input.priceUnavailable, + udenomToUsd: (amount: string | number) => Number(amount) + }), + useFlag: () => input.fixedThresholdEnabled ?? false, + 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 }) + } 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..ef997a5cd3 --- /dev/null +++ b/apps/deploy-web/src/components/billing-usage/AccountBalanceOverview/useAccountBalanceOverview.ts @@ -0,0 +1,110 @@ +"use client"; +import { useMemo } from "react"; +import differenceInCalendarDays from "date-fns/differenceInCalendarDays"; + +import { useLocalNotes } from "@src/components/LocalNoteManager/useLocalNotes"; +import { useWallet } from "@src/context/WalletProvider"; +import { useFlag } from "@src/hooks/useFlag"; +import { usePricing } from "@src/hooks/usePricing/usePricing"; +import { computeWalletBalance } from "@src/hooks/useWalletBalance"; +import { useWalletSettingsQuery } from "@src/queries"; +import { useBalances } from "@src/queries/useBalancesQuery"; +import { useAllLeases } from "@src/queries/useLeaseQuery"; +import { getLeasesCostPerBlockUsd, getTimeLeft, perBlockToHourly } from "@src/utils/priceUtils"; +import { isLeaseLive } from "@src/utils/reclamationUtils"; + +export const DEPENDENCIES = { + useWallet, + usePricing, + useFlag, + useBalances, + useAllLeases, + useWalletSettingsQuery, + useLocalNotes +}; + +export type ReservedDeployment = { + dseq: string; + name: string; + reservedUsd: number; + perHourUsd: number; +}; + +export type AccountBalanceOverview = { + totalUsd: number; + reserved: number; + available: number; + deployments: ReservedDeployment[]; + perHour: number; + /** null when nothing is being spent (runway is effectively infinite). */ + 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; + /** 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, isError: isBalancesError, fetchStatus: balancesFetchStatus } = 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"); + + /** Not gated on the AKT market price: managed wallets hold ACT/USDC (1:1 USD), and blocking on market data would strand the card on its skeleton during an outage. */ + const walletBalance = useMemo(() => (balances ? computeWalletBalance(balances, price ?? 0, udenomToUsd) : null), [balances, price, udenomToUsd]); + + const { perHourByDseq, spend } = useMemo(() => { + const perHourByDseq = new Map(); + if (!leases) return { perHourByDseq, spend: { perBlockUsd: 0, perHour: 0 } }; + + let totalPerBlockUsd = 0; + for (const lease of leases.filter(isLeaseLive)) { + const perBlockUsd = getLeasesCostPerBlockUsd([lease]); + totalPerBlockUsd += perBlockUsd; + perHourByDseq.set(lease.dseq, (perHourByDseq.get(lease.dseq) ?? 0) + perBlockToHourly(perBlockUsd)); + } + + return { perHourByDseq, spend: { perBlockUsd: totalPerBlockUsd, perHour: perBlockToHourly(totalPerBlockUsd) } }; + }, [leases]); + + 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), + perHourUsd: perHourByDseq.get(deployment.dseq) ?? 0 + })) + .sort((a, b) => b.reservedUsd - a.reservedUsd); + }, [balances, getDeploymentName, udenomToUsd, perHourByDseq]); + + const totalUsd = walletBalance?.totalUsd ?? 0; + const reserved = deployments.reduce((sum, deployment) => sum + deployment.reservedUsd, 0); + const available = Math.max(0, totalUsd - reserved); + const hasSpend = spend.perBlockUsd > 0; + const lastsUntil = hasSpend ? getTimeLeft(spend.perBlockUsd, totalUsd) : null; + const autoReloadEnabled = walletSettings?.autoReloadEnabled ?? false; + const isBalanceUnavailable = !balances && (isBalancesError || (!!address && balancesFetchStatus === "idle")); + const autoReloadThreshold = isFixedThresholdEnabled && autoReloadEnabled ? walletSettings?.autoReloadThreshold ?? null : null; + + return { + totalUsd, + reserved, + available, + deployments, + perHour: spend.perHour, + lastsUntil, + runwayDays: lastsUntil ? differenceInCalendarDays(lastsUntil, new Date()) : null, + autoReloadEnabled, + autoReloadThreshold, + isLoading: !walletBalance && !isBalanceUnavailable, + isError: isBalanceUnavailable + }; +} 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..edd7c99bb7 --- /dev/null +++ b/apps/deploy-web/src/components/billing-usage/AutoTopUpSection/AutoTopUpSection.spec.tsx @@ -0,0 +1,237 @@ +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 and top-up amounts when enabled", () => { + setup({ + isFixedThresholdEnabled: true, + defaultPaymentMethod: { id: "pm_123" }, + walletSettings: { autoReloadEnabled: true, autoReloadThreshold: 20, autoReloadAmount: 100 } + }); + + expect(screen.getByText("Threshold")).toBeInTheDocument(); + expect(screen.getByText("Top up")).toBeInTheDocument(); + expect(screen.getByText("20")).toBeInTheDocument(); + expect(screen.getByText("100")).toBeInTheDocument(); + }); + + 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(/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", () => { + 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("shows the next top-up estimate even when the default payment method is not a card", () => { + setup({ + isFixedThresholdEnabled: true, + defaultPaymentMethod: { id: "pm_123" }, + walletSettings: { autoReloadEnabled: true, autoReloadThreshold: 20, autoReloadAmount: 50 }, + perHour: 1, + available: 100 + }); + + expect(screen.getByText(/Charges your default payment method/)).toBeInTheDocument(); + expect(screen.getByText(/Next top-up in about/)).toBeInTheDocument(); + }); + + it("names the default card in the charge summary", () => { + setup({ + isFixedThresholdEnabled: true, + defaultPaymentMethod: { id: "pm_123", card: { brand: "visa", last4: "4242" } }, + walletSettings: { autoReloadEnabled: true, autoReloadThreshold: 20, autoReloadAmount: 50 } + }); + + expect(screen.getByText(/Charges Visa \*\*\*\* 4242/)).toBeInTheDocument(); + }); + + 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("shows a skeleton instead of the add-payment-method prompt while the payment method query is loading", () => { + setup({ isFixedThresholdEnabled: true, defaultPaymentMethod: undefined, isDefaultPaymentMethodLoading: true }); + + expect(screen.queryByText(/Add a payment method/)).not.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, + 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; card?: { brand?: string; last4?: string } }; + isDefaultPaymentMethodLoading?: boolean; + walletSettings?: { autoReloadEnabled: boolean; autoReloadThreshold?: number; autoReloadAmount?: number }; + weeklyCost?: number; + confirmResult?: boolean; + 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]) => ( + onCheckedChange?.(e.target.checked)} /> + )); + const MockUsdValue = 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.isDefaultPaymentMethodLoading ?? 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) })), + Button: MockButton, + Switch: MockSwitch, + UsdValue: MockUsdValue + } as unknown as typeof DEPENDENCIES; + + render(); + + 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 new file mode 100644 index 0000000000..239bf79732 --- /dev/null +++ b/apps/deploy-web/src/components/billing-usage/AutoTopUpSection/AutoTopUpSection.tsx @@ -0,0 +1,241 @@ +"use client"; +import React, { useCallback, useMemo, useState } from "react"; +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 } 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 { 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"; + +const HOURS_PER_DAY = 24; + +export const DEPENDENCIES = { + useSnackbar, + useDefaultPaymentMethodQuery, + useWalletSettingsQuery, + useWeeklyDeploymentCostQuery, + useWalletSettingsMutations, + useAccountBalanceOverview, + usePopup, + useBillingActions, + useFlag, + AutoTopUpSettingsPopup, + UsdValue, + Button, + Card, + CardContent, + CardHeader, + Skeleton, + 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, isLoading: isDefaultPaymentMethodLoading } = d.useDefaultPaymentMethodQuery(); + 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 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 autoReloadEnabled = walletSettings?.autoReloadEnabled ?? false; + const isFirstLoad = (isWalletSettingsLoading && !walletSettings) || (isDefaultPaymentMethodLoading && !defaultPaymentMethod); + + 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 ( + <> + + {upsertWalletSettings.isPending && ( +
+ +
+ )} + +
+

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

+ {isFixedThresholdEnabled ? ( +

+ Tops up when your available balance runs low. +

+ ) : ( +

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 + +
+

+ Charges {defaultCardLabel ?? "your default payment method"} when your available balance falls below the threshold. + {nextTopUpDays !== null && ( + <> + {" "} + Next top-up in about {`${nextTopUpDays} day${nextTopUpDays === 1 ? "" : "s"}`} based + on your current spend rate. + + )} +

+
+ ) : ( +

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

+ ) + ) : ( +

+ Recharge amount is approximately {usd(weeklyCost ?? 0)} 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/BillingActionsProvider/BillingActionsProvider.spec.tsx b/apps/deploy-web/src/components/billing-usage/BillingActionsProvider/BillingActionsProvider.spec.tsx new file mode 100644 index 0000000000..3054649a21 --- /dev/null +++ b/apps/deploy-web/src/components/billing-usage/BillingActionsProvider/BillingActionsProvider.spec.tsx @@ -0,0 +1,113 @@ +import React from "react"; +import { describe, expect, it, vi } from "vitest"; +import { mock } from "vitest-mock-extended"; + +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(); + }); + + it("keeps the popup closed and toasts when creating the setup intent fails", () => { + const { toast } = setup({ isError: true }); + + fireEvent.click(screen.getByText("open")); + + expect(screen.queryByTestId("popup")).not.toBeInTheDocument(); + expect(toast).toHaveBeenCalledWith(expect.objectContaining({ variant: "destructive" })); + }); + + function setup(input: { setupIntent?: { clientSecret: string }; theme?: string; isError?: boolean } = {}) { + const createSetupIntent = vi.fn(); + const resetSetupIntent = vi.fn(); + const refresh = vi.fn().mockResolvedValue(undefined); + const toast = vi.fn(); + + const setupIntentMutation = input.isError + ? mock>({ mutate: createSetupIntent, reset: resetSetupIntent, isError: true, data: undefined }) + : mock>({ + mutate: createSetupIntent, + reset: resetSetupIntent, + isError: false, + data: input.setupIntent + }); + + const dependencies: typeof DEPENDENCIES = { + useTheme: () => mock>({ resolvedTheme: input.theme ?? "light" }), + useToast: () => mock>({ toast }), + useSetupIntentMutation: () => setupIntentMutation, + useRefreshPaymentMethods: () => refresh, + AddPaymentMethodPopup: MockPopup + }; + + render( + + + + ); + + return { createSetupIntent, resetSetupIntent, refresh, toast }; + } +}); 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..4861b83f12 --- /dev/null +++ b/apps/deploy-web/src/components/billing-usage/BillingActionsProvider/BillingActionsProvider.tsx @@ -0,0 +1,76 @@ +"use client"; +import type { ReactNode } from "react"; +import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react"; +import { useToast } from "@akashnetwork/ui/hooks"; +import { useTheme } from "next-themes"; + +import { AddPaymentMethodPopup } from "@src/components/billing-usage/AddPaymentMethodPopup/AddPaymentMethodPopup"; +import { useRefreshPaymentMethods, useSetupIntentMutation } from "@src/queries"; + +export const DEPENDENCIES = { + useTheme, + useToast, + 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 { toast } = d.useToast(); + const { data: setupIntent, mutate: createSetupIntent, reset: resetSetupIntent, isError: isSetupIntentError } = d.useSetupIntentMutation(); + const refreshPaymentMethods = d.useRefreshPaymentMethods(); + const [isOpen, setIsOpen] = useState(false); + + const openAddPaymentMethod = useCallback(() => { + resetSetupIntent(); + createSetupIntent(); + setIsOpen(true); + }, [createSetupIntent, resetSetupIntent]); + + useEffect( + function notifyAndClosePopupOnSetupIntentError() { + if (!isSetupIntentError) return; + setIsOpen(false); + toast({ title: "Couldn't start adding a payment method", description: "Please try again.", variant: "destructive" }); + }, + [isSetupIntentError, toast] + ); + + 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..d8aa815837 100644 --- a/apps/deploy-web/src/components/billing-usage/BillingContainer/BillingContainer.tsx +++ b/apps/deploy-web/src/components/billing-usage/BillingContainer/BillingContainer.tsx @@ -18,6 +18,7 @@ export type ChildrenProps = { data: BillingTransaction[]; hasMore: boolean; hasPrevious: boolean; + isLoading: boolean; isFetching: boolean; isError: boolean; errorMessage: string; @@ -25,7 +26,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 +38,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 +73,8 @@ export const BillingContainer: React.FC = ({ children, de }; const exportCsv = async () => { - if (!startDate || !endDate) { - return; - } + if (!dateRange) return; + const { from: startDate, to: endDate } = dateRange; 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 57ff73d991..a2b62a4477 100644 --- a/apps/deploy-web/src/components/billing-usage/BillingPage.tsx +++ b/apps/deploy-web/src/components/billing-usage/BillingPage.tsx @@ -1,23 +1,42 @@ 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 { BillingActionsProvider } from "@src/components/billing-usage/BillingActionsProvider/BillingActionsProvider"; 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 { 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"; -import { AccountOverview } from "./AccountOverview/AccountOverview"; export const BillingPage: FC = () => { const isAutoCreditReloadEnabled = useFlag("auto_credit_reload"); + const isBackgroundLoading = useBillingBackgroundLoading(); return ( - + - - {isAutoCreditReloadEnabled && } - {props => } - + : undefined} + > + {isAutoCreditReloadEnabled ? ( + + + + {props => } + {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/BillingView/BillingView.spec.tsx b/apps/deploy-web/src/components/billing-usage/BillingView/BillingView.spec.tsx index 817512524c..ba6b6d1771 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", () => { @@ -31,8 +31,8 @@ describe(BillingView.name, () => { 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("Description")).toBeInTheDocument(); expect(screen.getByText("Status")).toBeInTheDocument(); expect(screen.getByText("Receipt")).toBeInTheDocument(); }); @@ -138,6 +138,11 @@ describe(BillingView.name, () => { expect(screen.getByText(/Export as CSV/i)).not.toBeDisabled(); }); + it("disables export button when no date range is selected", () => { + setup({ dateRange: null }); + expect(screen.getByText(/Export as CSV/i)).toBeDisabled(); + }); + it("calls onDateRangeChange when date range start changes", () => { const onDateRangeChange = vi.fn(); setup({ @@ -221,6 +226,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..5a4fbe9b35 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"; @@ -66,10 +68,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-28 px-4 py-2", "w-36 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 +82,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 +91,7 @@ export const BillingView: React.FC = ({ data, hasMore, hasPrevious, + isLoading, isFetching, errorMessage, isError, @@ -204,14 +210,6 @@ export const BillingView: React.FC = ({ } }); - if (isFetching) { - return ( -
- -
- ); - } - if (isError) { return ( @@ -221,158 +219,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/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/PaymentMethodsView/PaymentMethodsRow.spec.tsx b/apps/deploy-web/src/components/billing-usage/PaymentMethodsView/PaymentMethodsRow.spec.tsx index 1d01d9b980..b2d98783fd 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; @@ -30,8 +29,8 @@ const MockDropdownMenuContent = ({ children, align: _align, onMouseLeave, onClic ); }; -const MockButton = ({ children, onClick, size: _size, variant: _variant, className }: any) => ( - ); @@ -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, @@ -162,6 +160,12 @@ describe(PaymentMethodsRow.name, () => { expect(screen.getByRole("button")).toBeInTheDocument(); }); + + it("disables the actions trigger when isDisabled is set", () => { + setup({ paymentMethod: createMockPaymentMethod({ isDefault: false }), isDisabled: true }); + + expect(screen.getByRole("button")).toBeDisabled(); + }); }); describe("Dropdown Menu Interactions", () => { @@ -426,18 +430,14 @@ describe(PaymentMethodsRow.name, () => { render(
- - - - -
+
Outside element
); @@ -464,6 +464,7 @@ function setup( paymentMethod?: PaymentMethod; onSetPaymentMethodAsDefault?: Mock; onRemovePaymentMethod?: Mock; + isDisabled?: boolean; dependencies?: typeof mockDependencies; } = {} ) { @@ -484,13 +485,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..3547ee1bc2 100644 --- a/apps/deploy-web/src/components/billing-usage/PaymentMethodsView/PaymentMethodsRow.tsx +++ b/apps/deploy-web/src/components/billing-usage/PaymentMethodsView/PaymentMethodsRow.tsx @@ -1,27 +1,28 @@ 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 = { paymentMethod: PaymentMethod; onSetPaymentMethodAsDefault: (id: string) => void; onRemovePaymentMethod: (id: string) => void; + isDisabled?: boolean; dependencies?: typeof DEPENDENCIES; }; @@ -29,6 +30,7 @@ export const PaymentMethodsRow: React.FC = ({ paymentMethod, onSetPaymentMethodAsDefault, onRemovePaymentMethod, + isDisabled = false, dependencies: d = DEPENDENCIES }) => { const [open, setOpen] = useState(false); @@ -77,7 +79,7 @@ export const PaymentMethodsRow: React.FC = ({ } return ( - + Default @@ -97,15 +99,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 +134,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..002e897de8 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,148 @@ 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} - - - - +const MockPaymentMethodsRow = ({ paymentMethod, onSetPaymentMethodAsDefault, onRemovePaymentMethod, isDisabled }: PaymentMethodsRowProps) => ( +
+ {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("disables each row's actions while an operation is in progress", () => { + setup({ data: createMockPaymentMethods(), isInProgress: true }); - expect(screen.queryByTestId("circular-progress")).toBeInTheDocument(); - }); + expect(screen.getAllByText("Set as Default")[0]).toBeDisabled(); + expect(screen.getAllByText("Remove")[0]).toBeDisabled(); }); - describe("Edge Cases", () => { - it("handles empty payment methods array", () => { - setup({ data: [] }); + it("shows skeletons and no rows on first load", () => { + setup({ isLoadingPaymentMethods: true, data: [] }); - expect(screen.getByText("No payment methods added yet.")).toBeInTheDocument(); - expect(screen.queryByTestId(/payment-method-row-/)).not.toBeInTheDocument(); - }); - - 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(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("Component Structure", () => { - it("renders main container with correct spacing", () => { - const { container } = setup(); - - const mainDiv = container.querySelector(".space-y-2"); - expect(mainDiv).toBeInTheDocument(); - }); + it("calls onSetPaymentMethodAsDefault with the payment method id", () => { + const { onSetPaymentMethodAsDefault } = setup({ data: createMockPaymentMethods() }); - it("renders Card components correctly", () => { - setup(); + fireEvent.click(screen.getAllByText("Set as Default")[0]); - // 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(onSetPaymentMethodAsDefault).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 - }; + it("calls onRemovePaymentMethod with the payment method id", () => { + const { onRemovePaymentMethod } = setup({ data: createMockPaymentMethods() }); - 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(); + fireEvent.click(screen.getAllByText("Remove")[0]); - const props = { - ...defaultProps, - ...input, - onSetPaymentMethodAsDefault: mockOnSetPaymentMethodAsDefault, - onRemovePaymentMethod: mockOnRemovePaymentMethod, - onAddPaymentMethod: mockOnAddPaymentMethod, - setShowAddPaymentMethod: mockSetShowAddPaymentMethod, - onAddCardSuccess: mockOnAddCardSuccess - }; - - const renderResult = render(); + expect(onRemovePaymentMethod).toHaveBeenCalledWith("pm_123"); + }); - 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..2148d48f2d 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,51 @@ 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 d158c53d1b..b0f8f79e0b 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/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 }) => ; diff --git a/apps/deploy-web/src/components/billing-usage/useBillingBackgroundLoading.spec.ts b/apps/deploy-web/src/components/billing-usage/useBillingBackgroundLoading.spec.ts new file mode 100644 index 0000000000..425d6ef40b --- /dev/null +++ b/apps/deploy-web/src/components/billing-usage/useBillingBackgroundLoading.spec.ts @@ -0,0 +1,69 @@ +import type { useIsFetching } from "@tanstack/react-query"; +import { describe, expect, it } from "vitest"; + +import { QueryKeys } from "@src/queries/queryKeys"; +import type { DEPENDENCIES } from "./useBillingBackgroundLoading"; +import { useBillingBackgroundLoading } from "./useBillingBackgroundLoading"; + +import { renderHook } from "@testing-library/react"; + +const ADDRESS = "akash1abc"; + +describe(useBillingBackgroundLoading.name, () => { + it("counts a background refetch of the all-leases query", () => { + const { isMatching } = setup(); + + expect(isMatching(QueryKeys.getAllLeasesKey(ADDRESS), { hasData: true })).toBe(true); + }); + + it("ignores the lease-existence probe even though it extends the all-leases key", () => { + const { isMatching } = setup(); + + expect(isMatching(QueryKeys.getLeaseExistenceKey(ADDRESS), { hasData: true })).toBe(false); + }); + + it("ignores initial loads that have no data yet", () => { + const { isMatching } = setup(); + + expect(isMatching(QueryKeys.getAllLeasesKey(ADDRESS), { hasData: false })).toBe(false); + }); + + it("counts SDK queries by prefix since they append the request input to their key", () => { + const { isMatching } = setup(); + + expect(isMatching(["v1", "listStripeTransactions", { limit: 10 }], { hasData: true })).toBe(true); + }); + + it("ignores queries for other wallets", () => { + const { isMatching } = setup(); + + expect(isMatching(QueryKeys.getAllLeasesKey("akash1other"), { hasData: true })).toBe(false); + }); + + function setup() { + let predicate: ((query: { queryKey: readonly unknown[]; state: { data: unknown } }) => boolean) | undefined; + + const dependencies = { + useServices: () => ({ + api: { + v1: { + getWalletSettings: { getKey: () => ["v1", "getWalletSettings"] }, + getDefaultPaymentMethod: { getKey: () => ["v1", "getDefaultPaymentMethod"] }, + listStripeTransactions: { getKey: () => ["v1", "listStripeTransactions"] } + } + } + }), + useWallet: () => ({ address: ADDRESS }), + useIsFetching: ((filters: { predicate: NonNullable }) => { + predicate = filters.predicate; + return 0; + }) as unknown as typeof useIsFetching + } as unknown as typeof DEPENDENCIES; + + renderHook(() => useBillingBackgroundLoading(dependencies)); + + const isMatching = (queryKey: readonly unknown[], input: { hasData: boolean }) => predicate!({ queryKey, state: { data: input.hasData ? {} : undefined } }); + + return { isMatching }; + } +}); 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..5e1673a99a --- /dev/null +++ b/apps/deploy-web/src/components/billing-usage/useBillingBackgroundLoading.ts @@ -0,0 +1,42 @@ +"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"; + +export const DEPENDENCIES = { useServices, useWallet, useIsFetching }; + +const startsWithPrefix = (key: readonly unknown[], prefix: readonly unknown[]) => prefix.length > 0 && prefix.every((part, index) => key[index] === part); + +const keysEqual = (key: readonly unknown[], other: readonly unknown[]) => key.length === other.length && startsWithPrefix(key, other); + +/** + * 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. + * + * `QueryKeys` entries are matched exactly: prefix matching would also catch derived keys such as the + * app-wide lease-existence probe (`[...getAllLeasesKey, "EXISTENCE"]`), which is onboarding activity, + * not billing. SDK keys are matched by prefix because their queries append the request input. + */ +export function useBillingBackgroundLoading(d: typeof DEPENDENCIES = DEPENDENCIES): boolean { + const { api } = d.useServices(); + const { address } = d.useWallet(); + + const exactKeys = [ + QueryKeys.getBalancesKey(address), + QueryKeys.getAllLeasesKey(address), + QueryKeys.getPaymentMethodsKey(), + QueryKeys.getWeeklyDeploymentCostKey() + ].filter(key => key.length > 0); + + const prefixes = [api.v1.getWalletSettings.getKey(), api.v1.getDefaultPaymentMethod.getKey(), api.v1.listStripeTransactions.getKey()]; + + const backgroundFetchingCount = d.useIsFetching({ + predicate: query => + query.state.data !== undefined && + (exactKeys.some(key => keysEqual(query.queryKey as unknown[], key)) || prefixes.some(prefix => startsWithPrefix(query.queryKey as unknown[], prefix))) + }); + + return backgroundFetchingCount > 0; +} 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..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(); @@ -41,14 +45,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 +76,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 +95,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 +122,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); diff --git a/apps/deploy-web/src/components/deployments/DeploymentDetailLegacy.tsx b/apps/deploy-web/src/components/deployments/DeploymentDetailLegacy.tsx index 156c02a043..604f11fd8d 100644 --- a/apps/deploy-web/src/components/deployments/DeploymentDetailLegacy.tsx +++ b/apps/deploy-web/src/components/deployments/DeploymentDetailLegacy.tsx @@ -18,7 +18,6 @@ import { useSettings } from "@src/context/SettingsProvider"; import { useWallet } from "@src/context/WalletProvider"; import { useDeclaredGpuInterconnect } from "@src/hooks/useDeclaredGpuInterconnect"; import { useDeclaredTeeTypes } from "@src/hooks/useDeclaredTeeTypes"; -import { useFlag } from "@src/hooks/useFlag"; import { useNavigationGuard } from "@src/hooks/useNavigationGuard/useNavigationGuard"; import { useUser } from "@src/hooks/useUser"; import { useDeploymentDetail } from "@src/queries/useDeploymentQuery"; @@ -57,7 +56,7 @@ export const DeploymentDetailLegacy: FC = ({ dseq } const isRemoteDeploy = sdlAnalyzer.hasCiCdImage(editedManifest); const repo: string | null = isRemoteDeploy ? extractRepositoryUrl(editedManifest) : null; const { user } = useUser(); - const isAlertsEnabled = useFlag("alerts") && !!user?.userId; + const isAlertsEnabled = !!user?.userId; const [badgedTabs, setBadgedTabs] = useState>>({}); const { data: deployment, isFetching: isLoadingDeployment, refetch: getDeploymentDetail, error: deploymentError } = useDeploymentDetail(address, dseq); diff --git a/apps/deploy-web/src/components/home/YourAccount/YourAccount.spec.tsx b/apps/deploy-web/src/components/home/YourAccount/YourAccount.spec.tsx index 7f5ab26b57..23c55a269e 100644 --- a/apps/deploy-web/src/components/home/YourAccount/YourAccount.spec.tsx +++ b/apps/deploy-web/src/components/home/YourAccount/YourAccount.spec.tsx @@ -98,47 +98,37 @@ describe(YourAccount.name, () => { ); }); - it("computes costs from active leases with AKT denom", () => { + it("ignores AKT-denominated leases since AKT deployments no longer exist", () => { const AccountStatsCardsMock = vi.fn(ComponentMock); setup({ wallet: { address: "akash1abc" }, walletBalance: createWalletBalance(), activeDeployments: [createDeployment()], leases: [createLease({ state: "active", denom: UAKT_DENOM, amount: "1000" })], - pricing: { price: 3.5, isLoaded: true }, dependencies: { AccountStatsCards: AccountStatsCardsMock } }); - expect(AccountStatsCardsMock).toHaveBeenCalledWith( - expect.objectContaining({ - costPerMonth: expect.any(Number), - costPerHour: expect.any(Number) - }), - expect.anything() - ); const props = AccountStatsCardsMock.mock.calls[0][0]; - expect(props.costPerMonth).toBeGreaterThan(0); - expect(props.costPerHour).toBeGreaterThan(0); + expect(props.costPerMonth).toBe(0); + expect(props.costPerHour).toBe(0); }); - it("computes costs from active leases with USDC denom", () => { + it("ignores USDC-denominated leases since deployments are funded in ACT", () => { const AccountStatsCardsMock = vi.fn(ComponentMock); setup({ wallet: { address: "akash1abc" }, walletBalance: createWalletBalance(), activeDeployments: [createDeployment()], leases: [createLease({ state: "active", denom: "ibc/usdc-denom", amount: "5000" })], - usdcDenom: "ibc/usdc-denom", - pricing: { price: 3.5, isLoaded: true }, dependencies: { AccountStatsCards: AccountStatsCardsMock } }); const props = AccountStatsCardsMock.mock.calls[0][0]; - expect(props.costPerMonth).toBeGreaterThan(0); + expect(props.costPerMonth).toBe(0); }); it("computes costs from active leases with ACT denom", () => { @@ -148,7 +138,6 @@ describe(YourAccount.name, () => { walletBalance: createWalletBalance(), activeDeployments: [createDeployment()], leases: [createLease({ state: "active", denom: UACT_DENOM, amount: "2000" })], - pricing: { price: 3.5, isLoaded: true }, dependencies: { AccountStatsCards: AccountStatsCardsMock } @@ -164,28 +153,6 @@ describe(YourAccount.name, () => { wallet: { address: "akash1abc" }, walletBalance: createWalletBalance(), leases: null, - pricing: { price: 3.5, isLoaded: true }, - dependencies: { - AccountStatsCards: AccountStatsCardsMock - } - }); - - expect(AccountStatsCardsMock).toHaveBeenCalledWith( - expect.objectContaining({ - costPerMonth: undefined, - costPerHour: undefined - }), - expect.anything() - ); - }); - - it("returns null costs when price is not loaded", () => { - const AccountStatsCardsMock = vi.fn(ComponentMock); - setup({ - wallet: { address: "akash1abc" }, - walletBalance: createWalletBalance(), - leases: [createLease({ state: "active" })], - pricing: { price: undefined, isLoaded: false }, dependencies: { AccountStatsCards: AccountStatsCardsMock } @@ -255,8 +222,6 @@ describe(YourAccount.name, () => { leases?: Array | null; providers?: Array; wallet?: { address?: string }; - pricing?: { price?: number; isLoaded?: boolean }; - usdcDenom?: string; dependencies?: Partial; } = {} ) { @@ -274,20 +239,6 @@ describe(YourAccount.name, () => { hasWallet: !!input.wallet?.address }); - const useUsdcDenom: typeof DEPENDENCIES.useUsdcDenom = () => input.usdcDenom ?? "ibc/usdc-test-denom"; - - const usePricing: typeof DEPENDENCIES.usePricing = () => - ({ - isLoaded: input.pricing?.isLoaded ?? true, - isLoading: false, - price: input.pricing?.price ?? 3.5, - uaktToUSD: vi.fn(), - aktToUSD: vi.fn(), - usdToAkt: vi.fn(), - getPriceForDenom: vi.fn(), - udenomToUsd: vi.fn() - }) as ReturnType; - render( { ...MockComponents(DEPENDENCIES), useSettings, useWallet, - useUsdcDenom, - usePricing, ...input.dependencies }} /> diff --git a/apps/deploy-web/src/components/home/YourAccount/YourAccount.tsx b/apps/deploy-web/src/components/home/YourAccount/YourAccount.tsx index bc31bccb72..55d626ec2f 100644 --- a/apps/deploy-web/src/components/home/YourAccount/YourAccount.tsx +++ b/apps/deploy-web/src/components/home/YourAccount/YourAccount.tsx @@ -3,17 +3,13 @@ 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"; -import { usePricing } from "@src/hooks/usePricing/usePricing"; 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"; @@ -28,9 +24,7 @@ export const DEPENDENCIES = { NoDeploymentsState, ResourceStatsGrid, useSettings, - useWallet, - useUsdcDenom, - usePricing + useWallet }; type Props = { @@ -55,7 +49,6 @@ export const YourAccount: React.FunctionComponent = ({ }) => { const { settings } = d.useSettings(); const { address } = d.useWallet(); - const usdcIbcDenom = d.useUsdcDenom(); const totalCpu = activeDeployments.map(d => d.cpuAmount).reduce((a, b) => a + b, 0); const totalGpu = activeDeployments.map(d => d.gpuAmount).reduce((a = 0, b = 0) => a + b, 0); const totalMemory = activeDeployments.map(d => d.memoryAmount).reduce((a, b) => a + b, 0); @@ -63,33 +56,18 @@ export const YourAccount: React.FunctionComponent = ({ const _ram = bytesToShrink(totalMemory); const _storage = bytesToShrink(totalStorage); const [, setDeploySdl] = useAtom(sdlStore.deploySdl); - const { price, isLoaded: isAktPriceLoaded } = d.usePricing(); 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); + if (!leases) return null; + const totalCostPerBlock = getLeasesCostPerBlockUsd(leases.filter(isLeaseLive)); const monthlyAvg = getAvgCostPerMonth(totalCostPerBlock); return { perMonth: monthlyAvg, perHour: monthlyAvg / (AVG_AMOUNT_OF_DAYS_IN_MONTH * ONE_DAY_IN_HOURS) }; - }, [leases, price, isAktPriceLoaded, usdcIbcDenom]); + }, [leases]); const userProviders = useMemo(() => { if (!leases || !providers) return []; const activeLeases = leases.filter(isLeaseLive); diff --git a/apps/deploy-web/src/components/layout/AccountMenu.spec.tsx b/apps/deploy-web/src/components/layout/AccountMenu.spec.tsx index 4101a5db33..7c2df07e91 100644 --- a/apps/deploy-web/src/components/layout/AccountMenu.spec.tsx +++ b/apps/deploy-web/src/components/layout/AccountMenu.spec.tsx @@ -57,7 +57,7 @@ describe(AccountMenu.name, () => { }); it("shows only Logout in the minimal variant when signed in", async () => { - setup({ username: "erin", userId: "user-1", isBillingUsageEnabled: true, minimal: true }); + setup({ username: "erin", userId: "user-1", minimal: true }); await userEvent.click(screen.getByRole("button", { name: /account menu/i })); @@ -78,11 +78,10 @@ describe(AccountMenu.name, () => { expect(authService.logout).toHaveBeenCalledTimes(1); }); - it("shows Billing & Usage when flag and userId are both present", async () => { + it("shows Billing & Usage when a userId is present", async () => { setup({ username: "carol", - userId: "user-1", - isBillingUsageEnabled: true + userId: "user-1" }); await userEvent.click(screen.getByRole("button", { name: /account menu/i })); @@ -90,11 +89,9 @@ describe(AccountMenu.name, () => { expect(await screen.findByText("Billing & Usage")).toBeInTheDocument(); }); - it("hides Billing & Usage when the flag is disabled", async () => { + it("hides Billing & Usage when there is no userId", async () => { setup({ - username: "dan", - userId: "user-1", - isBillingUsageEnabled: false + username: "dan" }); await userEvent.click(screen.getByRole("button", { name: /account menu/i })); @@ -103,7 +100,7 @@ describe(AccountMenu.name, () => { expect(screen.queryByText("Billing & Usage")).not.toBeInTheDocument(); }); - function setup(input: { isLoading?: boolean; username?: string; userId?: string; isBillingUsageEnabled?: boolean; minimal?: boolean }) { + function setup(input: { isLoading?: boolean; username?: string; userId?: string; minimal?: boolean }) { const push = vi.fn(); const authService = mock(); @@ -113,8 +110,7 @@ describe(AccountMenu.name, () => { user: input.username ? { username: input.username, userId: input.userId } : undefined, isLoading: input.isLoading ?? false }), - useRouter: () => mock>({ push }), - useFlag: flagName => (flagName === "billing_usage" && input.isBillingUsageEnabled) ?? false + useRouter: () => mock>({ push }) }; render( diff --git a/apps/deploy-web/src/components/layout/AccountMenu.tsx b/apps/deploy-web/src/components/layout/AccountMenu.tsx index d8e3e32429..e46e1df6f8 100644 --- a/apps/deploy-web/src/components/layout/AccountMenu.tsx +++ b/apps/deploy-web/src/components/layout/AccountMenu.tsx @@ -15,10 +15,9 @@ import { useRouter } from "next/navigation"; import { useServices } from "@src/context/ServicesProvider"; import { useCustomUser } from "@src/hooks/useCustomUser"; -import { useFlag } from "@src/hooks/useFlag"; import { CustomDropdownLinkItem } from "../shared/CustomDropdownLinkItem"; -export const DEPENDENCIES = { useCustomUser, useRouter, useFlag }; +export const DEPENDENCIES = { useCustomUser, useRouter }; interface Props { dependencies?: typeof DEPENDENCIES; @@ -30,7 +29,6 @@ export function AccountMenu({ dependencies: d = DEPENDENCIES, minimal = false }: const { user, isLoading } = d.useCustomUser(); const username = user?.username; const router = d.useRouter(); - const isBillingUsageEnabled = d.useFlag("billing_usage"); const { authService, urlService } = useServices(); return ( @@ -83,7 +81,7 @@ export function AccountMenu({ dependencies: d = DEPENDENCIES, minimal = false }: router.push(urlService.userFavorites())} icon={}> Favorites - {isBillingUsageEnabled && user?.userId && ( + {user?.userId && ( router.push(urlService.billing())} icon={}> Billing & Usage 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..e94afebbfa --- /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 navLink(title: string, url: string, isActive: boolean): SettingsNavLink { + return { title, url, isActive, icon: () => null }; + } + + 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} +
+ ); + } +}); 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..9b0515ef40 --- /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.spec.tsx b/apps/deploy-web/src/components/layout/TopNav/TopNav.spec.tsx index c045c68c23..84e7c1ccbf 100644 --- a/apps/deploy-web/src/components/layout/TopNav/TopNav.spec.tsx +++ b/apps/deploy-web/src/components/layout/TopNav/TopNav.spec.tsx @@ -1,7 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import { mock } from "vitest-mock-extended"; -import type { FeatureFlag } from "@src/types/feature-flags"; import { DEPENDENCIES, TopNav } from "./TopNav"; import { render, screen } from "@testing-library/react"; @@ -52,8 +51,8 @@ describe(TopNav.name, () => { expect(screen.getByRole("link", { name: "Providers" })).not.toHaveAttribute("aria-current"); }); - it("shows all settings items when billing and alerts flags are on", async () => { - setup({ isAuthenticated: true, flags: { billing_usage: true, alerts: true } }); + it("shows all settings items", async () => { + setup({ isAuthenticated: true }); await userEvent.click(screen.getByRole("button", { name: /settings/i })); @@ -63,18 +62,7 @@ describe(TopNav.name, () => { expect(screen.getByRole("menuitem", { name: "Alerts" })).toHaveAttribute("href", "/alerts"); }); - it("hides flag-gated settings items when flags are off", async () => { - setup({ isAuthenticated: true, flags: {} }); - - await userEvent.click(screen.getByRole("button", { name: /settings/i })); - - expect(await screen.findByRole("menuitem", { name: "API Keys" })).toBeInTheDocument(); - expect(screen.queryByRole("menuitem", { name: "Billing" })).not.toBeInTheDocument(); - expect(screen.queryByRole("menuitem", { name: "Usage" })).not.toBeInTheDocument(); - expect(screen.queryByRole("menuitem", { name: "Alerts" })).not.toBeInTheDocument(); - }); - - function setup(input: { isAuthenticated?: boolean; minimal?: boolean; pathname?: string; flags?: Partial> }) { + function setup(input: { isAuthenticated?: boolean; minimal?: boolean; pathname?: string }) { const accountMenu = vi.fn(() => <>); const dependencies = MockComponents(DEPENDENCIES, { @@ -82,7 +70,6 @@ describe(TopNav.name, () => { mock>({ user: input.isAuthenticated ? { userId: "user-1" } : undefined }), - useFlag: (flag: FeatureFlag) => input.flags?.[flag] ?? false, usePathname: () => input.pathname ?? "/", useCookieTheme: () => "light", TopNavAccountMenu: accountMenu diff --git a/apps/deploy-web/src/components/layout/TopNav/TopNav.tsx b/apps/deploy-web/src/components/layout/TopNav/TopNav.tsx index 3e1a8e4e0a..8830ec4aed 100644 --- a/apps/deploy-web/src/components/layout/TopNav/TopNav.tsx +++ b/apps/deploy-web/src/components/layout/TopNav/TopNav.tsx @@ -13,12 +13,12 @@ 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 { isRouteActive, useSettingsNavLinks } from "@src/hooks/useSettingsNavLinks"; import useCookieTheme from "@src/hooks/useTheme"; import { useUser } from "@src/hooks/useUser"; import { UrlService } from "@src/utils/urlUtils"; @@ -28,7 +28,7 @@ import { TopBanner } from "../TopBanner"; import { usePublishHeaderHeight } from "../usePublishHeaderHeight"; import { TopNavAccountMenu } from "./TopNavAccountMenu"; -export const DEPENDENCIES = { useUser, useFlag, usePathname, useCookieTheme, TopBanner, HackathonCouponNavEntry, TopNavAccountMenu, SkipOnboardingButton }; +export const DEPENDENCIES = { useUser, usePathname, useCookieTheme, TopBanner, HackathonCouponNavEntry, TopNavAccountMenu, SkipOnboardingButton }; interface Props { dependencies?: typeof DEPENDENCIES; @@ -50,24 +50,15 @@ 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}/`)); - const navLinks: TopNavLink[] = [ - { title: "Deployments", url: UrlService.deploymentList(), isActive: isRouteActive("/deployments", "/new-deployment"), icon: Cloud }, - { title: "Providers", url: UrlService.providers(), isActive: isRouteActive("/providers"), icon: Server }, - { title: "Templates", url: UrlService.templates(), isActive: isRouteActive("/templates"), icon: MultiplePages } + { title: "Deployments", url: UrlService.deploymentList(), isActive: isRouteActive(pathname, "/deployments", "/new-deployment"), icon: Cloud }, + { title: "Providers", url: UrlService.providers(), isActive: isRouteActive(pathname, "/providers"), icon: Server }, + { title: "Templates", url: UrlService.templates(), isActive: isRouteActive(pathname, "/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: { usePathname: d.usePathname } }); const isSettingsActive = settingsLinks.some(link => link.isActive); const showNavLinks = isAuthenticated && !minimal; diff --git a/apps/deploy-web/src/hooks/useProviderJwt/useProviderJwt.spec.tsx b/apps/deploy-web/src/hooks/useProviderJwt/useProviderJwt.spec.tsx index 5c05a75506..31696ddb87 100644 --- a/apps/deploy-web/src/hooks/useProviderJwt/useProviderJwt.spec.tsx +++ b/apps/deploy-web/src/hooks/useProviderJwt/useProviderJwt.spec.tsx @@ -9,6 +9,7 @@ import type { CustomUserProfile } from "@src/types/user"; import type * as storedWalletsService from "@src/utils/walletUtils"; import { DEPENDENCIES, REFRESH_SKEW_SECONDS, useProviderJwt } from "./useProviderJwt"; +import { act } from "@testing-library/react"; import { buildWallet } from "@tests/seeders"; import { buildManagedLocalWallet } from "@tests/seeders/localWallet"; import type { RenderAppHookOptions } from "@tests/unit/query-client"; @@ -59,7 +60,9 @@ describe(useProviderJwt.name, () => { wallet: { hasWallet: true } }); - await result.current.generateToken(); + await act(async () => { + await result.current.generateToken(); + }); expect(consoleApiHttpClient.post).toHaveBeenCalledWith("/v1/create-jwt-token", { data: { 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..51884051b1 --- /dev/null +++ b/apps/deploy-web/src/hooks/useSettingsNavLinks.spec.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; + +import type { DEPENDENCIES } from "./useSettingsNavLinks"; +import { useSettingsNavLinks } from "./useSettingsNavLinks"; + +import { renderHook } from "@testing-library/react"; + +describe(useSettingsNavLinks.name, () => { + it("includes every settings item", () => { + const links = setup({}); + + expect(links.map(link => link.title)).toEqual(["Billing", "API Keys", "Usage", "Alerts"]); + }); + + it("marks the item matching the current route as active", () => { + const links = setup({ pathname: "/usage" }); + + expect(links.find(link => link.title === "Usage")?.isActive).toBe(true); + expect(links.find(link => link.title === "Billing")?.isActive).toBe(false); + }); + + it("marks Billing active on the billing route", () => { + const links = setup({ pathname: "/billing" }); + + expect(links.find(link => link.title === "Billing")?.isActive).toBe(true); + }); + + it("treats sub-routes as active", () => { + const links = setup({ pathname: "/alerts/notification-channels/new" }); + + expect(links.find(link => link.title === "Alerts")?.isActive).toBe(true); + }); + + function setup(input: { pathname?: string }) { + const dependencies: typeof DEPENDENCIES = { + usePathname: () => input.pathname ?? "/" + }; + + 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..ff7a64afb1 --- /dev/null +++ b/apps/deploy-web/src/hooks/useSettingsNavLinks.ts @@ -0,0 +1,34 @@ +"use client"; +import type { ComponentType } from "react"; +import { CreditCard, Key, MessageAlert, StatsUpSquare } from "iconoir-react"; +import { usePathname } from "next/navigation"; + +import { UrlService } from "@src/utils/urlUtils"; + +export type SettingsNavLink = { + title: string; + url: string; + isActive: boolean; + icon: ComponentType<{ className?: string }>; +}; + +export const DEPENDENCIES = { usePathname }; + +/** True when the pathname is one of the prefixes or a sub-route of one. */ +export const isRouteActive = (pathname: string | null, ...prefixes: string[]) => + !!pathname && prefixes.some(prefix => pathname === prefix || pathname.startsWith(`${prefix}/`)); + +/** + * 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 active state). + */ +export function useSettingsNavLinks({ dependencies: d = DEPENDENCIES }: { dependencies?: typeof DEPENDENCIES } = {}): SettingsNavLink[] { + const pathname = d.usePathname(); + + return [ + { title: "Billing", url: UrlService.billing(), isActive: isRouteActive(pathname, "/billing"), icon: CreditCard }, + { title: "API Keys", url: UrlService.userApiKeys(), isActive: isRouteActive(pathname, "/user/api-keys"), icon: Key }, + { title: "Usage", url: UrlService.usage(), isActive: isRouteActive(pathname, "/usage"), icon: StatsUpSquare }, + { title: "Alerts", url: UrlService.alerts(), isActive: isRouteActive(pathname, "/alerts"), icon: MessageAlert } + ]; +} diff --git a/apps/deploy-web/src/hooks/useWalletBalance.ts b/apps/deploy-web/src/hooks/useWalletBalance.ts index 4c68c23795..ddd046a35e 100644 --- a/apps/deploy-web/src/hooks/useWalletBalance.ts +++ b/apps/deploy-web/src/hooks/useWalletBalance.ts @@ -6,8 +6,10 @@ import { useWallet } from "@src/context/WalletProvider"; import { useChainParam } from "@src/hooks/useChainParam/useChainParam"; import { useBalances } from "@src/queries/useBalancesQuery"; import walletStore from "@src/store/walletStore"; +import type { Balances } from "@src/types"; import { udenomToDenom } from "@src/utils/mathHelpers"; import { uaktToAKT } from "@src/utils/priceUtils"; +import type { PricingContext } from "./usePricing/usePricing"; import { usePricing } from "./usePricing/usePricing"; import { useUsdcDenom } from "./useDenom"; @@ -35,45 +37,53 @@ export type WalletBalanceReturnType = { balance: WalletBalance | null; }; +/** Converts on-chain balances into USD totals; shared by the wallet-balance atom and callers needing the same figures synchronously. */ +export function computeWalletBalance(balances: Balances, price: number, udenomToUsd: PricingContext["udenomToUsd"]): WalletBalance { + const aktUsdValue = uaktToAKT(balances.balanceUAKT, 6) * price; + const totalUsdcValue = udenomToDenom(balances.balanceUUSDC, 6); + const totalDeploymentEscrowUSD = balances.activeDeployments.reduce( + (acc, d) => acc + d.escrowAccount.state.funds.reduce((fundAcc, fund) => fundAcc + udenomToUsd(fund.amount, fund.denom), 0), + 0 + ); + const { deploymentGrants } = balances; + const totalDeploymentGrantsUSD = deploymentGrants.reduce( + (sum, grant) => sum + grant.authorization.spend_limits.reduce((grantSum, spendLimit) => grantSum + udenomToUsd(spendLimit.amount, spendLimit.denom), 0), + 0 + ); + + return { + totalUsd: aktUsdValue + totalUsdcValue + udenomToUsd(balances.balanceUACT, UACT_DENOM) + totalDeploymentEscrowUSD + totalDeploymentGrantsUSD, + balanceUAKT: balances.balanceUAKT + balances.deploymentGrantsUAKT, + balanceUUSDC: balances.balanceUUSDC + balances.deploymentGrantsUUSDC, + balanceUACT: balances.balanceUACT + balances.deploymentGrantsUACT, + totalUAKT: balances.balanceUAKT + balances.deploymentEscrowUAKT + balances.deploymentGrantsUAKT, + totalUUSDC: balances.balanceUUSDC + balances.deploymentEscrowUUSDC + balances.deploymentGrantsUUSDC, + totalUACT: balances.balanceUACT + balances.deploymentEscrowUACT + balances.deploymentGrantsUACT, + totalDeploymentEscrowUAKT: balances.deploymentEscrowUAKT, + totalDeploymentEscrowUUSDC: balances.deploymentEscrowUUSDC, + totalDeploymentEscrowUACT: balances.deploymentEscrowUACT, + totalDeploymentEscrowUSD: totalDeploymentEscrowUSD, + totalDeploymentGrantsUAKT: balances.deploymentGrantsUAKT, + totalDeploymentGrantsUUSDC: balances.deploymentGrantsUUSDC, + totalDeploymentGrantsUACT: balances.deploymentGrantsUACT, + totalDeploymentGrantsUSD: totalDeploymentGrantsUSD + }; +} + export const useWalletBalance = (): WalletBalanceReturnType => { - const { isLoaded, price, udenomToUsd } = usePricing(); + const { price, udenomToUsd } = usePricing(); const { address } = useWallet(); const { data: balances, isFetching: isLoadingBalances, refetch } = useBalances(address); const [walletBalance, setWalletBalance] = useAtom(walletStore.balance); - useEffect(() => { - if (isLoaded && balances && price) { - const aktUsdValue = uaktToAKT(balances.balanceUAKT, 6) * price; - const totalUsdcValue = udenomToDenom(balances.balanceUUSDC, 6); - const totalDeploymentEscrowUSD = balances.activeDeployments.reduce( - (acc, d) => acc + d.escrowAccount.state.funds.reduce((fundAcc, fund) => fundAcc + udenomToUsd(fund.amount, fund.denom), 0), - 0 - ); - const { deploymentGrants } = balances; - const totalDeploymentGrantsUSD = deploymentGrants.reduce( - (sum, grant) => sum + grant.authorization.spend_limits.reduce((grantSum, spendLimit) => grantSum + udenomToUsd(spendLimit.amount, spendLimit.denom), 0), - 0 - ); - - setWalletBalance({ - totalUsd: aktUsdValue + totalUsdcValue + udenomToUsd(balances.balanceUACT, UACT_DENOM) + totalDeploymentEscrowUSD + totalDeploymentGrantsUSD, - balanceUAKT: balances.balanceUAKT + balances.deploymentGrantsUAKT, - balanceUUSDC: balances.balanceUUSDC + balances.deploymentGrantsUUSDC, - balanceUACT: balances.balanceUACT + balances.deploymentGrantsUACT, - totalUAKT: balances.balanceUAKT + balances.deploymentEscrowUAKT + balances.deploymentGrantsUAKT, - totalUUSDC: balances.balanceUUSDC + balances.deploymentEscrowUUSDC + balances.deploymentGrantsUUSDC, - totalUACT: balances.balanceUACT + balances.deploymentEscrowUACT + balances.deploymentGrantsUACT, - totalDeploymentEscrowUAKT: balances.deploymentEscrowUAKT, - totalDeploymentEscrowUUSDC: balances.deploymentEscrowUUSDC, - totalDeploymentEscrowUACT: balances.deploymentEscrowUACT, - totalDeploymentEscrowUSD: totalDeploymentEscrowUSD, - totalDeploymentGrantsUAKT: balances.deploymentGrantsUAKT, - totalDeploymentGrantsUUSDC: balances.deploymentGrantsUUSDC, - totalDeploymentGrantsUACT: balances.deploymentGrantsUACT, - totalDeploymentGrantsUSD: totalDeploymentGrantsUSD - }); - } - }, [isLoaded, price, balances, udenomToUsd]); + useEffect( + function publishBalanceWhenLoaded() { + if (balances) { + setWalletBalance(computeWalletBalance(balances, price ?? 0, udenomToUsd)); + } + }, + [price, balances, udenomToUsd] + ); return { balance: walletBalance, diff --git a/apps/deploy-web/src/lib/nextjs/pageGuards/pageGuards.spec.ts b/apps/deploy-web/src/lib/nextjs/pageGuards/pageGuards.spec.ts index 0443d1ce4a..4d3146f0a2 100644 --- a/apps/deploy-web/src/lib/nextjs/pageGuards/pageGuards.spec.ts +++ b/apps/deploy-web/src/lib/nextjs/pageGuards/pageGuards.spec.ts @@ -4,10 +4,9 @@ import { describe, expect, it, vi } from "vitest"; import { mock } from "vitest-mock-extended"; import type { Session } from "@src/lib/auth0"; -import type { FeatureFlagService } from "@src/services/feature-flag/feature-flag.service"; import { UrlService } from "@src/utils/urlUtils"; import type { AppTypedContext } from "../defineServerSideProps/defineServerSideProps"; -import { isAuthenticated, isFeatureEnabled, redirectIfAccessTokenExpired, requireAuth } from "./pageGuards"; +import { isAuthenticated, redirectIfAccessTokenExpired, requireAuth } from "./pageGuards"; describe("pageGuards", () => { describe("isAuthenticated", () => { @@ -37,33 +36,6 @@ describe("pageGuards", () => { }); }); - describe("isFeatureEnabled", () => { - it("returns true when feature flag is enabled", async () => { - const context = setup({ - enabledFeatures: ["test"] - }); - - expect(await isFeatureEnabled("test", context)).toBe(true); - expect(await isFeatureEnabled("test2", context)).toBe(false); - expect(context.services.featureFlagService.isEnabledForCtx).toHaveBeenCalledWith("test", context, expect.anything()); - }); - - it("passes the user id to the feature flag service", async () => { - const userId = faker.string.uuid(); - const context = setup({ - enabledFeatures: ["test"], - session: { - user: { - id: userId - } - } - }); - - expect(await isFeatureEnabled("test", context)).toBe(true); - expect(context.services.featureFlagService.isEnabledForCtx).toHaveBeenCalledWith("test", context, { userId }); - }); - }); - describe("redirectIfAccessTokenExpired", () => { it("returns true when access token is not expired", async () => { const context = setup({ @@ -129,7 +101,7 @@ describe("pageGuards", () => { }); }); -function setup(input?: { enabledFeatures?: string[]; session?: Partial; resolvedUrl?: string }) { +function setup(input?: { session?: Partial; resolvedUrl?: string }) { return mock({ getCurrentSession: vi.fn().mockImplementation(async () => { if (!input?.session) return null; @@ -139,9 +111,6 @@ function setup(input?: { enabledFeatures?: string[]; session?: Partial; }; }), services: { - featureFlagService: mock({ - isEnabledForCtx: vi.fn(async featureName => !!input?.enabledFeatures?.includes(featureName)) - }), logger: mock(), urlService: UrlService }, diff --git a/apps/deploy-web/src/lib/nextjs/pageGuards/pageGuards.ts b/apps/deploy-web/src/lib/nextjs/pageGuards/pageGuards.ts index 77b9fb03e6..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,7 @@ 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 }); diff --git a/apps/deploy-web/src/pages/alerts/index.tsx b/apps/deploy-web/src/pages/alerts/index.tsx index a176dd27c9..d015105955 100644 --- a/apps/deploy-web/src/pages/alerts/index.tsx +++ b/apps/deploy-web/src/pages/alerts/index.tsx @@ -1,10 +1,8 @@ import { AlertsPage } from "@src/components/alerts/AlertsPage"; import { defineServerSideProps } from "@src/lib/nextjs/defineServerSideProps/defineServerSideProps"; -import { isFeatureEnabled } from "@src/lib/nextjs/pageGuards/pageGuards"; export default AlertsPage; export const getServerSideProps = defineServerSideProps({ - route: "/alerts", - if: async ctx => await isFeatureEnabled("alerts", ctx) + route: "/alerts" }); diff --git a/apps/deploy-web/src/pages/alerts/notification-channels/[id]/index.tsx b/apps/deploy-web/src/pages/alerts/notification-channels/[id]/index.tsx index 9145f0dfdd..8a277f661f 100644 --- a/apps/deploy-web/src/pages/alerts/notification-channels/[id]/index.tsx +++ b/apps/deploy-web/src/pages/alerts/notification-channels/[id]/index.tsx @@ -5,7 +5,6 @@ import { z } from "zod"; import { EditNotificationChannelPage } from "@src/components/alerts/EditNotificationChannelPage"; import { defineServerSideProps } from "@src/lib/nextjs/defineServerSideProps/defineServerSideProps"; -import { isFeatureEnabled } from "@src/lib/nextjs/pageGuards/pageGuards"; export default EditNotificationChannelPage; @@ -24,7 +23,6 @@ export const getServerSideProps = defineServerSideProps({ id: z.string().uuid() }) }), - if: async ctx => await isFeatureEnabled("alerts", ctx), handler: async (context): Promise> => { const session = (await context.services.getSession(context.req, context.res))!; 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/alerts/notification-channels/new.tsx b/apps/deploy-web/src/pages/alerts/notification-channels/new.tsx index a9398b3899..b03bb9a668 100644 --- a/apps/deploy-web/src/pages/alerts/notification-channels/new.tsx +++ b/apps/deploy-web/src/pages/alerts/notification-channels/new.tsx @@ -1,10 +1,8 @@ import { CreateNotificationChannelPage } from "@src/components/alerts/CreateNotificationChannelPage"; import { defineServerSideProps } from "@src/lib/nextjs/defineServerSideProps/defineServerSideProps"; -import { isFeatureEnabled } from "@src/lib/nextjs/pageGuards/pageGuards"; export default CreateNotificationChannelPage; export const getServerSideProps = defineServerSideProps({ - route: "/alerts/notification-channels/new", - if: async ctx => await isFeatureEnabled("alerts", ctx) + route: "/alerts/notification-channels/new" }); diff --git a/apps/deploy-web/src/pages/billing/index.tsx b/apps/deploy-web/src/pages/billing/index.tsx index d92ac44499..9ee6500532 100644 --- a/apps/deploy-web/src/pages/billing/index.tsx +++ b/apps/deploy-web/src/pages/billing/index.tsx @@ -1,10 +1,8 @@ import { BillingPage } from "@src/components/billing-usage/BillingPage"; import { defineServerSideProps } from "@src/lib/nextjs/defineServerSideProps/defineServerSideProps"; -import { isFeatureEnabled } from "@src/lib/nextjs/pageGuards/pageGuards"; export default BillingPage; export const getServerSideProps = defineServerSideProps({ - route: "/billing", - if: async ctx => await isFeatureEnabled("billing_usage", ctx) + route: "/billing" }); 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 } }) }); diff --git a/apps/deploy-web/src/pages/usage/index.tsx b/apps/deploy-web/src/pages/usage/index.tsx index 6312beb6ad..adcb263e2c 100644 --- a/apps/deploy-web/src/pages/usage/index.tsx +++ b/apps/deploy-web/src/pages/usage/index.tsx @@ -1,10 +1,8 @@ import { UsagePage } from "@src/components/billing-usage/UsagePage"; import { defineServerSideProps } from "@src/lib/nextjs/defineServerSideProps/defineServerSideProps"; -import { isFeatureEnabled } from "@src/lib/nextjs/pageGuards/pageGuards"; export default UsagePage; export const getServerSideProps = defineServerSideProps({ - route: "/usage", - if: async ctx => await isFeatureEnabled("billing_usage", ctx) + route: "/usage" }); 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 } ); }; diff --git a/apps/deploy-web/src/types/feature-flags.ts b/apps/deploy-web/src/types/feature-flags.ts index bf05e25d12..65e0d5d69f 100644 --- a/apps/deploy-web/src/types/feature-flags.ts +++ b/apps/deploy-web/src/types/feature-flags.ts @@ -1,8 +1,6 @@ export type FeatureFlag = - | "alerts" | "notifications_general_alerts_update" | "ui_deployment_closed_alert" - | "billing_usage" | "ui_sdl_log_collector_enabled" | "maintenance_banner" | "auto_credit_reload" diff --git a/apps/deploy-web/src/utils/priceUtils.spec.ts b/apps/deploy-web/src/utils/priceUtils.spec.ts index 5b18662bd5..e018f6fe5f 100644 --- a/apps/deploy-web/src/utils/priceUtils.spec.ts +++ b/apps/deploy-web/src/utils/priceUtils.spec.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; -import { averageBlockTime, perBlockToHourly } from "./priceUtils"; +import { UACT_DENOM, UAKT_DENOM } from "@src/config/denom.config"; +import { averageBlockTime, getLeasesCostPerBlockUsd, perBlockToHourly } from "./priceUtils"; describe("perBlockToHourly", () => { it("converts a per-block price into its hourly equivalent (3600 / blockTime blocks per hour)", () => { @@ -8,3 +9,17 @@ describe("perBlockToHourly", () => { expect(perBlockToHourly(0)).toBe(0); }); }); + +describe(getLeasesCostPerBlockUsd.name, () => { + it("sums ACT lease prices as 1:1 USD", () => { + const leases = [{ price: { denom: UACT_DENOM, amount: "500000" } }, { price: { denom: UACT_DENOM, amount: "500000" } }]; + + expect(getLeasesCostPerBlockUsd(leases)).toBeCloseTo(1, 6); + }); + + it("ignores AKT and USDC leases since deployments are funded in ACT", () => { + const leases = [{ price: { denom: UAKT_DENOM, amount: "5000000000" } }, { price: { denom: "ibc/usdc", amount: "5000000000" } }]; + + expect(getLeasesCostPerBlockUsd(leases)).toBe(0); + }); +}); diff --git a/apps/deploy-web/src/utils/priceUtils.ts b/apps/deploy-web/src/utils/priceUtils.ts index bbe56bf46d..cf423a9405 100644 --- a/apps/deploy-web/src/utils/priceUtils.ts +++ b/apps/deploy-web/src/utils/priceUtils.ts @@ -62,6 +62,19 @@ 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. Deployments are only ever + * funded in ACT (1:1 USD); any other denom is ignored. + */ +export function getLeasesCostPerBlockUsd(leases: PricedLease[]): number { + return leases.reduce((total, { price }) => { + if (price.denom !== UACT_DENOM) return total; + return total + udenomToDenom(price.amount, 10); + }, 0); +} + export function getTimeLeft(pricePerBlock: number, balance: number) { const blocksLeft = balance / pricePerBlock; const timestamp = new Date().getTime(); diff --git a/apps/deploy-web/src/utils/urlUtils.ts b/apps/deploy-web/src/utils/urlUtils.ts index 0824479298..1eafc43049 100644 --- a/apps/deploy-web/src/utils/urlUtils.ts +++ b/apps/deploy-web/src/utils/urlUtils.ts @@ -75,7 +75,6 @@ export const UrlService = { userFavorites: () => `/user/settings/favorites`, userProfile: (username: string) => `/profile/${username}`, usage: () => "/usage", - paymentMethods: () => "/payment-methods", billing: ({ openPayment }: { openPayment?: boolean } = {}) => `/billing${appendSearchParams({ openPayment })}`, /** @deprecated use .newLogin instead */ login: () => "/api/auth/login", @@ -99,7 +98,6 @@ export const UrlService = { providerDetailRaw: (owner: string) => `/providers/${owner}/raw`, alerts: () => "/alerts", alertDetails: (id: string) => `/alerts/${id}`, - notificationChannels: () => "/alerts/notification-channels", newNotificationChannel: () => "/alerts/notification-channels/new", notificationChannelDetails: (id: string) => `/alerts/notification-channels/${id}`, diff --git a/apps/deploy-web/tests/ui/pages/BillingPage.ts b/apps/deploy-web/tests/ui/pages/BillingPage.ts index c9be282e20..acdca2b254 100644 --- a/apps/deploy-web/tests/ui/pages/BillingPage.ts +++ b/apps/deploy-web/tests/ui/pages/BillingPage.ts @@ -10,7 +10,7 @@ export class BillingPage { } getAvailableBalance() { - return this.page.locator('[aria-label="Available balance amount"]'); + return this.page.locator('[aria-label="Available balance"]'); } /**