Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/frontend/src/components/MaintenanceBanner/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export const MaintenanceBanner: FC = () => {
};

return (
<div className="px-4 pt-3">
<div className="relative z-20 px-4 pt-3">
<div className="mx-auto max-w-2xl">
<div className="rounded-lg border border-orange-200 bg-white p-3 shadow-sm sm:p-4">
<div className="flex items-start space-x-3">
Expand Down
31 changes: 31 additions & 0 deletions apps/frontend/src/hooks/useMaintenanceStatus.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
59 changes: 59 additions & 0 deletions apps/frontend/src/layouts/index.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<BaseLayout main={<div />} />);

expect(await screen.findByText("Scheduled maintenance")).toBeInTheDocument();
});

it("keeps the maintenance banner off the marketing pages", async () => {
widgetMode.value = false;

render(<BaseLayout main={<div />} />);

await waitFor(() => expect(useMaintenanceStore.getState().maintenanceStatus?.is_maintenance_active).toBe(true));
expect(screen.queryByText("Scheduled maintenance")).not.toBeInTheDocument();
});
});
8 changes: 4 additions & 4 deletions apps/frontend/src/layouts/index.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -21,17 +21,17 @@ export const BaseLayout: FC<BaseLayoutProps> = ({ main, modals }) => {
const isQuoteComponentDisplayed = useIsQuoteComponentDisplayed();

useInitTokenBalances();
useFetchMaintenanceStatus();
useMaintenanceStatus();

const isStepperHidden = isWidgetMode && isQuoteComponentDisplayed;

return (
<>
{modals}
<Navbar />
<MaintenanceBanner />
{isWidgetMode && (
<>
<MaintenanceBanner />
<div className="container relative z-20 mx-auto px-4 md:w-120">
{isStepperHidden ? <div className="h-[56px]" /> : <Stepper steps={steps} />}
</div>
Expand Down
3 changes: 2 additions & 1 deletion apps/frontend/src/stores/maintenanceStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<MaintenanceStore>((set, get) => ({
clearError: () => set({ error: null }),
Expand Down
Loading