From 20d6f88d664e96a26215c3a8054351320bd10c44 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 2 Oct 2026 07:12:04 +0200 Subject: [PATCH 1/4] fix(frontend): poll the maintenance status from the layout again Extracting the polling into hooks/useMaintenanceStatus (94c8a6f05) left the layout calling the store's useFetchMaintenanceStatus selector, which only returns the fetch function. The frontend has not requested /maintenance/status since, so the maintenance banner and the maintenance-aware buttons never activated. --- apps/frontend/src/layouts/index.test.tsx | 39 ++++++++++++++++++++++++ apps/frontend/src/layouts/index.tsx | 6 ++-- 2 files changed, 42 insertions(+), 3 deletions(-) create mode 100644 apps/frontend/src/layouts/index.test.tsx diff --git a/apps/frontend/src/layouts/index.test.tsx b/apps/frontend/src/layouts/index.test.tsx new file mode 100644 index 0000000000..005db37499 --- /dev/null +++ b/apps/frontend/src/layouts/index.test.tsx @@ -0,0 +1,39 @@ +// @vitest-environment jsdom +import { render, screen } from "@testing-library/react"; +import { HttpResponse, http } from "msw"; +import { describe, expect, it, vi } from "vitest"; +import "../test/i18n"; +import { API_BASE_URL, server } from "../test/msw-server"; + +// Only the maintenance wiring is under test; stub the layout's unrelated chrome and hooks. +vi.mock("../components/Navbar", () => ({ Navbar: () => null })); +vi.mock("../components/Footer", () => ({ Footer: () => null })); +vi.mock("../components/Stepper", () => ({ default: () => null })); +vi.mock("../hooks/useInitTokenBalances", () => ({ useInitTokenBalances: () => undefined })); +vi.mock("../hooks/useStepper", () => ({ useStepper: () => ({ steps: [] }) })); +vi.mock("../hooks/useWidgetMode", () => ({ useWidgetMode: () => false })); +vi.mock("../hooks/ramp/useIsQuoteComponentDisplayed", () => ({ useIsQuoteComponentDisplayed: () => false })); + +import { BaseLayout } from "./index"; + +describe("BaseLayout", () => { + it("fetches the maintenance status on mount and shows an active maintenance banner", async () => { + server.use( + http.get(`${API_BASE_URL}/maintenance/status`, () => + HttpResponse.json({ + is_maintenance_active: true, + maintenance_details: { + end_datetime: "2026-10-02T12:00:00Z", + message: "Ramps are paused while we upgrade.", + start_datetime: "2026-10-02T10:00:00Z", + title: "Scheduled maintenance" + } + }) + ) + ); + + render(} />); + + expect(await screen.findByText("Scheduled maintenance")).toBeTruthy(); + }); +}); diff --git a/apps/frontend/src/layouts/index.tsx b/apps/frontend/src/layouts/index.tsx index 6deb7ae8e5..6e8b734a2e 100644 --- a/apps/frontend/src/layouts/index.tsx +++ b/apps/frontend/src/layouts/index.tsx @@ -1,13 +1,13 @@ -import { FC, ReactNode, useEffect } from "react"; +import { FC, ReactNode } from "react"; import { Footer } from "../components/Footer"; import { MaintenanceBanner } from "../components/MaintenanceBanner"; import { Navbar } from "../components/Navbar"; import Stepper from "../components/Stepper"; import { useIsQuoteComponentDisplayed } from "../hooks/ramp/useIsQuoteComponentDisplayed"; import { useInitTokenBalances } from "../hooks/useInitTokenBalances"; +import { useMaintenanceStatus } from "../hooks/useMaintenanceStatus"; import { useStepper } from "../hooks/useStepper"; import { useWidgetMode } from "../hooks/useWidgetMode"; -import { useFetchMaintenanceStatus } from "../stores/maintenanceStore"; interface BaseLayoutProps { main: ReactNode; @@ -21,7 +21,7 @@ export const BaseLayout: FC = ({ main, modals }) => { const isQuoteComponentDisplayed = useIsQuoteComponentDisplayed(); useInitTokenBalances(); - useFetchMaintenanceStatus(); + useMaintenanceStatus(); const isStepperHidden = isWidgetMode && isQuoteComponentDisplayed; From 54d291223e8d83d59df14cf4bc51887acf9c5c76 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 5 Oct 2026 10:38:41 +0200 Subject: [PATCH 2/4] fix(frontend): refetch the maintenance status on every poll The store cache matched the hook's 5-minute poll, and lastFetched is only set once the response arrives, so every other tick hit the cache and the status refreshed every 10 minutes. Banners and disabled ramp buttons could outlive a maintenance window by that much. --- .../src/hooks/useMaintenanceStatus.test.ts | 31 +++++++++++++++++++ apps/frontend/src/stores/maintenanceStore.ts | 3 +- 2 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 apps/frontend/src/hooks/useMaintenanceStatus.test.ts diff --git a/apps/frontend/src/hooks/useMaintenanceStatus.test.ts b/apps/frontend/src/hooks/useMaintenanceStatus.test.ts new file mode 100644 index 0000000000..92ccfa0059 --- /dev/null +++ b/apps/frontend/src/hooks/useMaintenanceStatus.test.ts @@ -0,0 +1,31 @@ +// @vitest-environment jsdom +import { act, renderHook } from "@testing-library/react"; +import { afterEach, expect, it, vi } from "vitest"; +import { useMaintenanceStore } from "../stores/maintenanceStore"; +import { useMaintenanceStatus } from "./useMaintenanceStatus"; + +const { getMaintenanceStatus } = vi.hoisted(() => ({ getMaintenanceStatus: vi.fn() })); +vi.mock("../services/api/maintenance.service", () => ({ getMaintenanceStatus })); + +afterEach(() => { + vi.useRealTimers(); + useMaintenanceStore.getState().reset(); +}); + +it("refetches on every 5-minute poll and stops after unmount", async () => { + vi.useFakeTimers(); + // Real responses take time; the store only records lastFetched once they arrive. + getMaintenanceStatus.mockImplementation( + () => new Promise(resolve => setTimeout(() => resolve({ is_maintenance_active: false, maintenance_details: null }), 200)) + ); + + const { unmount } = renderHook(() => useMaintenanceStatus()); + expect(getMaintenanceStatus).toHaveBeenCalledTimes(1); + + await act(() => vi.advanceTimersByTimeAsync(5 * 60 * 1000)); + expect(getMaintenanceStatus).toHaveBeenCalledTimes(2); + + unmount(); + await act(() => vi.advanceTimersByTimeAsync(10 * 60 * 1000)); + expect(getMaintenanceStatus).toHaveBeenCalledTimes(2); +}); diff --git a/apps/frontend/src/stores/maintenanceStore.ts b/apps/frontend/src/stores/maintenanceStore.ts index fb994a434b..08a1d06e93 100644 --- a/apps/frontend/src/stores/maintenanceStore.ts +++ b/apps/frontend/src/stores/maintenanceStore.ts @@ -14,7 +14,8 @@ interface MaintenanceStore { reset: () => void; } -const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes in milliseconds +// Below the hook's 5-minute poll: lastFetched lands after the response, so an equal window skips every other poll. +const CACHE_DURATION = 60 * 1000; export const useMaintenanceStore = create((set, get) => ({ clearError: () => set({ error: null }), From b05595cd5dd1d3d032b9171b7dbc78fc1940c1a6 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 5 Oct 2026 10:38:41 +0200 Subject: [PATCH 3/4] fix(frontend): lift the maintenance banner above the widget backdrop The widget's absolutely positioned z-0 gradient painted over the unpositioned banner, so widget users only saw disabled buttons without the maintenance message. --- apps/frontend/src/components/MaintenanceBanner/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/frontend/src/components/MaintenanceBanner/index.tsx b/apps/frontend/src/components/MaintenanceBanner/index.tsx index 7e3d62e166..382b980e37 100644 --- a/apps/frontend/src/components/MaintenanceBanner/index.tsx +++ b/apps/frontend/src/components/MaintenanceBanner/index.tsx @@ -40,7 +40,7 @@ export const MaintenanceBanner: FC = () => { }; return ( -
+
From 2a51fbc676fa6b1075712ed8e8c83c5c0da4b635 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 5 Oct 2026 10:38:42 +0200 Subject: [PATCH 4/4] feat(frontend): show the maintenance banner only in the widget Marketing pages carry no ramp actions, so the banner is noise there. The layout still polls the status on every page so the widget is up to date when a visitor opens it. --- apps/frontend/src/layouts/index.test.tsx | 30 ++++++++++++++++++++---- apps/frontend/src/layouts/index.tsx | 2 +- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/apps/frontend/src/layouts/index.test.tsx b/apps/frontend/src/layouts/index.test.tsx index 005db37499..fb1995a898 100644 --- a/apps/frontend/src/layouts/index.test.tsx +++ b/apps/frontend/src/layouts/index.test.tsx @@ -1,23 +1,26 @@ // @vitest-environment jsdom -import { render, screen } from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; import { HttpResponse, http } from "msw"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import "../test/i18n"; +import { useMaintenanceStore } from "../stores/maintenanceStore"; import { API_BASE_URL, server } from "../test/msw-server"; +const widgetMode = vi.hoisted(() => ({ value: true })); + // Only the maintenance wiring is under test; stub the layout's unrelated chrome and hooks. vi.mock("../components/Navbar", () => ({ Navbar: () => null })); vi.mock("../components/Footer", () => ({ Footer: () => null })); vi.mock("../components/Stepper", () => ({ default: () => null })); vi.mock("../hooks/useInitTokenBalances", () => ({ useInitTokenBalances: () => undefined })); vi.mock("../hooks/useStepper", () => ({ useStepper: () => ({ steps: [] }) })); -vi.mock("../hooks/useWidgetMode", () => ({ useWidgetMode: () => false })); +vi.mock("../hooks/useWidgetMode", () => ({ useWidgetMode: () => widgetMode.value })); vi.mock("../hooks/ramp/useIsQuoteComponentDisplayed", () => ({ useIsQuoteComponentDisplayed: () => false })); import { BaseLayout } from "./index"; describe("BaseLayout", () => { - it("fetches the maintenance status on mount and shows an active maintenance banner", async () => { + beforeEach(() => { server.use( http.get(`${API_BASE_URL}/maintenance/status`, () => HttpResponse.json({ @@ -31,9 +34,26 @@ describe("BaseLayout", () => { }) ) ); + }); + + afterEach(() => { + useMaintenanceStore.getState().reset(); + }); + + it("fetches the maintenance status on mount and shows an active maintenance banner in the widget", async () => { + widgetMode.value = true; + + render(} />); + + expect(await screen.findByText("Scheduled maintenance")).toBeInTheDocument(); + }); + + it("keeps the maintenance banner off the marketing pages", async () => { + widgetMode.value = false; render(} />); - expect(await screen.findByText("Scheduled maintenance")).toBeTruthy(); + await waitFor(() => expect(useMaintenanceStore.getState().maintenanceStatus?.is_maintenance_active).toBe(true)); + expect(screen.queryByText("Scheduled maintenance")).not.toBeInTheDocument(); }); }); diff --git a/apps/frontend/src/layouts/index.tsx b/apps/frontend/src/layouts/index.tsx index 6e8b734a2e..56217c4ad8 100644 --- a/apps/frontend/src/layouts/index.tsx +++ b/apps/frontend/src/layouts/index.tsx @@ -29,9 +29,9 @@ export const BaseLayout: FC = ({ main, modals }) => { <> {modals} - {isWidgetMode && ( <> +
{isStepperHidden ?
: }