diff --git a/apps/frontend/src/hooks/useMaintenanceStatus.test.ts b/apps/frontend/src/hooks/useMaintenanceStatus.test.ts
new file mode 100644
index 000000000..92ccfa005
--- /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/layouts/index.test.tsx b/apps/frontend/src/layouts/index.test.tsx
new file mode 100644
index 000000000..fb1995a89
--- /dev/null
+++ b/apps/frontend/src/layouts/index.test.tsx
@@ -0,0 +1,59 @@
+// @vitest-environment jsdom
+import { render, screen, waitFor } from "@testing-library/react";
+import { HttpResponse, http } from "msw";
+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: () => widgetMode.value }));
+vi.mock("../hooks/ramp/useIsQuoteComponentDisplayed", () => ({ useIsQuoteComponentDisplayed: () => false }));
+
+import { BaseLayout } from "./index";
+
+describe("BaseLayout", () => {
+ beforeEach(() => {
+ 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"
+ }
+ })
+ )
+ );
+ });
+
+ 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(
} />);
+
+ 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 6deb7ae8e..56217c4ad 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;
@@ -29,9 +29,9 @@ export const BaseLayout: FC = ({ main, modals }) => {
<>
{modals}
-
{isWidgetMode && (
<>
+
diff --git a/apps/frontend/src/stores/maintenanceStore.ts b/apps/frontend/src/stores/maintenanceStore.ts
index fb994a434..08a1d06e9 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 }),