diff --git a/apps/deploy-web/src/components/deployments/ConfigureDeployment/ConfigureDeploymentHeader/ConfigureDeploymentHeader.spec.tsx b/apps/deploy-web/src/components/deployments/ConfigureDeployment/ConfigureDeploymentHeader/ConfigureDeploymentHeader.spec.tsx index 87ae5d4a20..9ab18cbea3 100644 --- a/apps/deploy-web/src/components/deployments/ConfigureDeployment/ConfigureDeploymentHeader/ConfigureDeploymentHeader.spec.tsx +++ b/apps/deploy-web/src/components/deployments/ConfigureDeployment/ConfigureDeploymentHeader/ConfigureDeploymentHeader.spec.tsx @@ -6,6 +6,7 @@ import { mock } from "vitest-mock-extended"; import type { DeploymentCost } from "../useDeploymentCost/useDeploymentCost"; import type { DeploymentFlow, DeploymentFlowActions } from "../useDeploymentFlow/useDeploymentFlow"; +import type { QuoteExpiry } from "../useQuoteExpiry/useQuoteExpiry"; import type { DEPENDENCIES } from "./ConfigureDeploymentHeader"; import { ConfigureDeploymentHeader } from "./ConfigureDeploymentHeader"; @@ -56,9 +57,10 @@ describe(ConfigureDeploymentHeader.name, () => { expect(screen.getByRole("button", { name: /request quotes/i })).toBeInTheDocument(); }); - it("keeps the Requesting CTA while closing", () => { + it("shows a Cancelling CTA while closing", () => { setup({ phase: "closing" }); - expect(screen.getByRole("button", { name: /requesting/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /cancelling/i })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /requesting/i })).not.toBeInTheDocument(); }); it("restores Request quotes after an error so the spec can be retried", () => { @@ -130,6 +132,52 @@ describe(ConfigureDeploymentHeader.name, () => { expect(useDeploymentCost).toHaveBeenCalledWith(expect.objectContaining({ sdl: "the-sdl", selections: { p1: "akash1a/1/1/1" } })); }); + it("shows the quote-expiry countdown once bids arrive", () => { + setup({ phase: "quoting", cost: { minPerBlock: 5, maxPerBlock: 5, denom: "uakt" }, expiry: { secondsLeft: 165, isExpired: false } }); + expect(screen.getByTestId("quote-expiry")).toHaveTextContent("expires in 2:45"); + expect(screen.getByTestId("quote-expiry")).toHaveClass("text-muted-foreground"); + }); + + it("marks the countdown red in the final minute", () => { + setup({ phase: "quoting", cost: { minPerBlock: 5, maxPerBlock: 5, denom: "uakt" }, expiry: { secondsLeft: 45, isExpired: false } }); + expect(screen.getByTestId("quote-expiry")).toHaveTextContent("expires in 0:45"); + expect(screen.getByTestId("quote-expiry")).toHaveClass("text-destructive"); + }); + + it("hides the countdown until the first bid arrives", () => { + setup({ phase: "quoting", expiry: null }); + expect(screen.queryByTestId("quote-expiry")).not.toBeInTheDocument(); + }); + + it('keeps the expiry line as "expired" once the window elapses rather than hiding it or showing 0:00', () => { + setup({ phase: "quoting", cost: null, expiry: { secondsLeft: 0, isExpired: true } }); + const line = screen.getByTestId("quote-expiry"); + expect(line).toHaveTextContent("expired"); + expect(line).not.toHaveTextContent("0:00"); + expect(line).toHaveClass("text-destructive"); + }); + + it("does not offer Close and Edit while open bids remain, even after the indicative timer elapses", () => { + setup({ + phase: "quoting", + allPlacementsHaveBids: true, + placements: [{ id: "p1" }], + selections: { p1: "akash1a/1/1/1" }, + cost: { minPerBlock: 5, maxPerBlock: 5, denom: "uakt" }, + expiry: { secondsLeft: 0, isExpired: true } + }); + expect(screen.queryByRole("button", { name: "Close and Edit" })).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Deploy" })).toBeInTheDocument(); + }); + + it("flips the CTA to Close and Edit once the window elapses and no open bids remain, and runs cancelAndEdit", async () => { + const cancelAndEdit = vi.fn(); + setup({ phase: "quoting", cost: null, expiry: { secondsLeft: 0, isExpired: true }, cancelAndEdit }); + expect(screen.queryByRole("button", { name: /requesting/i })).not.toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Close and Edit" })); + expect(cancelAndEdit).toHaveBeenCalled(); + }); + function setup(input: { phase: DeploymentFlow["phase"]; requestQuotes?: (sdl: string) => void; @@ -142,12 +190,18 @@ describe(ConfigureDeploymentHeader.name, () => { deploy?: () => void; cost?: DeploymentCost | null; sdl?: string; + expiry?: QuoteExpiry | null; + cancelAndEdit?: () => void; }) { const flow = mock({ phase: input.phase, dseq: null, deployError: input.deployError, - actions: mock({ requestQuotes: input.requestQuotes ?? vi.fn(), deploy: input.deploy ?? vi.fn() }) + actions: mock({ + requestQuotes: input.requestQuotes ?? vi.fn(), + deploy: input.deploy ?? vi.fn(), + cancelAndEdit: input.cancelAndEdit ?? vi.fn() + }) }); flow.selections = input.selections ?? {}; const enqueueSnackbar = vi.fn(); @@ -159,7 +213,9 @@ describe(ConfigureDeploymentHeader.name, () => { generateSdl: () => GENERATED_SDL, validateGeneratedSdl: () => input.validationErrors ?? [], useDeploymentCost: useDeploymentCost as typeof DEPENDENCIES.useDeploymentCost, - PriceValue: ({ value }) => {String(value)} + PriceValue: ({ value }) => {String(value)}, + useQuoteExpiry: () => input.expiry ?? null, + CustomTooltip: ({ children }) => <>{children} }; render( diff --git a/apps/deploy-web/src/components/deployments/ConfigureDeployment/ConfigureDeploymentHeader/ConfigureDeploymentHeader.tsx b/apps/deploy-web/src/components/deployments/ConfigureDeployment/ConfigureDeploymentHeader/ConfigureDeploymentHeader.tsx index 8adf795719..672c327060 100644 --- a/apps/deploy-web/src/components/deployments/ConfigureDeployment/ConfigureDeploymentHeader/ConfigureDeploymentHeader.tsx +++ b/apps/deploy-web/src/components/deployments/ConfigureDeployment/ConfigureDeploymentHeader/ConfigureDeploymentHeader.tsx @@ -1,8 +1,9 @@ import type { FC, ReactNode } from "react"; import { useFormContext, useWatch } from "react-hook-form"; -import { Button, Snackbar } from "@akashnetwork/ui/components"; +import { Button, CustomTooltip, Snackbar } from "@akashnetwork/ui/components"; +import { cn } from "@akashnetwork/ui/utils"; import { Send } from "iconoir-react"; -import { LoaderCircle } from "lucide-react"; +import { Clock, LoaderCircle } from "lucide-react"; import { useSnackbar } from "notistack"; import { PriceValue } from "@src/components/shared/PriceValue"; @@ -14,8 +15,20 @@ import { useDeploymentResourceSummary } from "../DeploymentResourceSummary/useDe import type { DeploymentCost } from "../useDeploymentCost/useDeploymentCost"; import { useDeploymentCost } from "../useDeploymentCost/useDeploymentCost"; import type { DeploymentFlow } from "../useDeploymentFlow/useDeploymentFlow"; +import type { QuoteExpiry } from "../useQuoteExpiry/useQuoteExpiry"; +import { useQuoteExpiry } from "../useQuoteExpiry/useQuoteExpiry"; -export const DEPENDENCIES = { useDeploymentResourceSummary, useSnackbar, Snackbar, generateSdl, validateGeneratedSdl, useDeploymentCost, PriceValue }; +export const DEPENDENCIES = { + useDeploymentResourceSummary, + useSnackbar, + Snackbar, + generateSdl, + validateGeneratedSdl, + useDeploymentCost, + PriceValue, + useQuoteExpiry, + CustomTooltip +}; type Props = { flow: DeploymentFlow; sdl: string; onDeploy: () => void; allPlacementsHaveBids: boolean; dependencies?: typeof DEPENDENCIES }; @@ -25,6 +38,7 @@ export const ConfigureDeploymentHeader: FC = ({ flow, sdl, onDeploy, allP const { enqueueSnackbar } = d.useSnackbar(); const placements = useWatch({ control, name: "placements" }); const cost = d.useDeploymentCost({ dseq: flow.dseq, sdl, placements, selections: flow.selections }); + const expiry = d.useQuoteExpiry({ dseq: flow.dseq, enabled: flow.phase === "quoting" }); const isEditable = flow.phase === "configuring" || flow.phase === "error"; /** Deploy only takes over from the loading CTA once every placement has bids; until then quoting still reads as "Requesting…". */ @@ -32,6 +46,15 @@ export const ConfigureDeploymentHeader: FC = ({ flow, sdl, onDeploy, allP const allPlacementsSelected = placements.length > 0 && placements.every(placement => !!flow.selections[placement.id]); /** A failed deploy returns to quoting with the error set; the CTA then re-fires the same request rather than re-opening review. */ const hasDeployError = !!flow.deployError; + const quotesExpired = !!expiry?.isExpired; + /** + * The timer is only indicative — providers can close their bids a little earlier or later — so we switch to + * "Close and Edit" only once the bids are actually gone (no placement has an open bid, hence no cost), not the + * moment the timer elapses. While any open bid remains the user can still deploy. + */ + const hasOpenBids = !!cost; + /** Closing (after Close and Edit) reuses the disabled loading CTA, but labelled to match the action in flight. */ + const isClosing = flow.phase === "closing"; /** * Request quotes runs the zod form validation first, then regenerates the SDL from the values @@ -74,14 +97,23 @@ export const ConfigureDeploymentHeader: FC = ({ flow, sdl, onDeploy, allP
- -