From 532889ba19166348dadbf343dbbc1fc6288a496b Mon Sep 17 00:00:00 2001 From: GODSWILL-dev Date: Sun, 27 Sep 2026 16:06:19 +0100 Subject: [PATCH] feat(markets): sync filters to the URL and announce result counts (#965) --- app/markets/__tests__/page.test.tsx | 136 +++++++++++++++++++++++++++- app/markets/page.tsx | 117 ++++++++++++++++++++++-- 2 files changed, 240 insertions(+), 13 deletions(-) diff --git a/app/markets/__tests__/page.test.tsx b/app/markets/__tests__/page.test.tsx index 15261585..9075ce73 100644 --- a/app/markets/__tests__/page.test.tsx +++ b/app/markets/__tests__/page.test.tsx @@ -1,9 +1,30 @@ import React from "react"; -import { render, screen } from "@testing-library/react"; +import { act, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import MarketsPage from "../page"; +// ── next/navigation ──────────────────────────────────────────────────────── +// The page syncs filters to the URL via router.replace; capture the calls and +// drive the initial query string from `mockSearchParams`. +const mockReplace = jest.fn(); +const mockPush = jest.fn(); +let mockSearchParams = new URLSearchParams(); + +jest.mock("next/navigation", () => ({ + useRouter: () => ({ replace: mockReplace, push: mockPush, prefetch: jest.fn() }), + usePathname: () => "/markets", + useSearchParams: () => mockSearchParams, +})); + +// ── live region ──────────────────────────────────────────────────────────── +// The real hook is a module-level store; stubbing it keeps the assertion on the +// announcement payload local to this suite. +const mockAnnounce = jest.fn(); +jest.mock("@/hooks/use-global-live-region", () => ({ + useGlobalLiveRegion: () => ({ announcements: [], announce: mockAnnounce }), +})); + // next/link needs a router in tests jest.mock("next/link", () => { const Link = ({ children, href }: { children: React.ReactNode; href: string }) => ( @@ -13,6 +34,15 @@ jest.mock("next/link", () => { return Link; }); +beforeEach(() => { + mockReplace.mockClear(); + mockPush.mockClear(); + mockAnnounce.mockClear(); + mockSearchParams = new URLSearchParams(); +}); + +const searchbox = () => screen.getByRole("searchbox", { name: /search markets/i }); + describe("MarketsPage — empty state", () => { it("renders markets when no filters are active", () => { render(); @@ -24,7 +54,7 @@ describe("MarketsPage — empty state", () => { const user = userEvent.setup(); render(); - await user.type(screen.getByRole("searchbox", { name: /search markets/i }), "zzznomatch"); + await user.type(searchbox(), "zzznomatch"); expect(screen.getByRole("status")).toBeInTheDocument(); expect(screen.getByText(/no markets match your search/i)).toBeInTheDocument(); @@ -34,23 +64,119 @@ describe("MarketsPage — empty state", () => { const user = userEvent.setup(); render(); - await user.type(screen.getByRole("searchbox", { name: /search markets/i }), "zzznomatch"); + await user.type(searchbox(), "zzznomatch"); expect(screen.getByRole("status")).toBeInTheDocument(); await user.click(screen.getByRole("button", { name: /clear all filters/i })); expect(screen.queryByRole("status")).not.toBeInTheDocument(); expect(screen.getByText(/will argentina win/i)).toBeInTheDocument(); - expect(screen.getByRole("searchbox", { name: /search markets/i })).toHaveValue(""); + expect(searchbox()).toHaveValue(""); }); it("empty state has role=status and aria-live=polite for accessibility", async () => { const user = userEvent.setup(); render(); - await user.type(screen.getByRole("searchbox", { name: /search markets/i }), "zzznomatch"); + await user.type(searchbox(), "zzznomatch"); const status = screen.getByRole("status"); expect(status).toHaveAttribute("aria-live", "polite"); }); }); + +describe("MarketsPage — URL-backed filters", () => { + it("hydrates search and category from the query string", () => { + mockSearchParams = new URLSearchParams("q=argentina&category=Football"); + + render(); + + expect(searchbox()).toHaveValue("argentina"); + expect(screen.getByRole("combobox", { name: /filter by category/i })).toHaveTextContent( + "Football", + ); + expect(screen.getByText(/will argentina win/i)).toBeInTheDocument(); + expect(screen.queryByText(/will brazil reach/i)).not.toBeInTheDocument(); + }); + + it("falls back to 'all' for an unknown category param instead of hiding every market", () => { + mockSearchParams = new URLSearchParams("category=NotARealCategory"); + + render(); + + expect(screen.getByRole("combobox", { name: /filter by category/i })).toHaveTextContent( + /all categories/i, + ); + expect(screen.getByText(/will argentina win/i)).toBeInTheDocument(); + expect(screen.getByText(/will brazil reach/i)).toBeInTheDocument(); + }); + + it("writes ?q= with router.replace after the search debounce, never router.push", async () => { + jest.useFakeTimers(); + const user = userEvent.setup({ advanceTimers: (ms: number) => jest.advanceTimersByTime(ms) }); + try { + render(); + + await user.type(searchbox(), "argentina"); + // Still inside the debounce window: no URL write yet. + expect(mockReplace).not.toHaveBeenCalled(); + + act(() => { + jest.advanceTimersByTime(300); + }); + + expect(mockReplace).toHaveBeenCalledTimes(1); + expect(mockReplace.mock.calls[0][0]).toBe("/markets?q=argentina"); + expect(mockPush).not.toHaveBeenCalled(); + } finally { + jest.useRealTimers(); + } + }); + + it("clears the query string immediately when the empty-state button resets filters", async () => { + jest.useFakeTimers(); + const user = userEvent.setup({ advanceTimers: (ms: number) => jest.advanceTimersByTime(ms) }); + try { + mockSearchParams = new URLSearchParams("q=zzznomatch&category=Football"); + render(); + + expect(screen.getByRole("status")).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: /clear all filters/i })); + + expect(mockReplace).toHaveBeenCalledWith("/markets", { scroll: false }); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + } finally { + jest.useRealTimers(); + } + }); +}); + +describe("MarketsPage — result announcements", () => { + it("announces the matching count politely, once per change", async () => { + jest.useFakeTimers(); + const user = userEvent.setup({ advanceTimers: (ms: number) => jest.advanceTimersByTime(ms) }); + try { + render(); + + // The initial render is not a change, so the live region stays quiet. + expect(mockAnnounce).not.toHaveBeenCalled(); + + await user.type(searchbox(), "arg"); + + expect(mockAnnounce).toHaveBeenCalledTimes(1); + expect(mockAnnounce).toHaveBeenCalledWith({ + message: "1 market found", + priority: "polite", + }); + + // A trailing space trims to the same query: the count is unchanged, so + // there must be no second announcement. + await user.type(searchbox(), " "); + await waitFor(() => expect(searchbox()).toHaveValue("arg ")); + expect(mockAnnounce).toHaveBeenCalledTimes(1); + } finally { + jest.useRealTimers(); + } + }); +}); diff --git a/app/markets/page.tsx b/app/markets/page.tsx index b6bff229..04f1e7b8 100644 --- a/app/markets/page.tsx +++ b/app/markets/page.tsx @@ -1,6 +1,7 @@ "use client"; import * as React from "react"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; import Link from "next/link"; import { Search, Filter, Trophy, CircleDollarSign, Building2, TrendingUp } from "lucide-react"; import { Input } from "@/components/ui/input"; @@ -14,6 +15,7 @@ import { SelectValue, } from "@/components/ui/select"; import { NoMatchEmptyState } from "@/components/events/NoMatchEmptyState"; +import { useGlobalLiveRegion } from "@/hooks/use-global-live-region"; // ── Types ────────────────────────────────────────────────────────────────── @@ -104,6 +106,16 @@ const STATUS_LABELS: Record = { closed: "Closed", }; +const CATEGORIES = ["Football", "Crypto", "Politics", "Other"] as const; +const ALL_CATEGORIES = "all"; + +/** One URL write per pause in typing, not one per keystroke. */ +const SEARCH_DEBOUNCE_MS = 300; + +function isKnownCategory(value: string): value is MarketCategory { + return (CATEGORIES as readonly string[]).includes(value); +} + // ── Market card ──────────────────────────────────────────────────────────── function MarketCard({ market }: { market: Market }) { @@ -156,26 +168,115 @@ function MarketCard({ market }: { market: Market }) { // ── Page ─────────────────────────────────────────────────────────────────── -const CATEGORIES = ["Football", "Crypto", "Politics", "Other"] as const; +function MarketsPageFallback() { + return ( +
+
+

Markets

+

+ Browse and predict on live markets — FWC26 campaign and more. +

+
+
+ ); +} +/** + * `useSearchParams()` opts this route into client-side rendering, so the + * filters live behind a Suspense boundary and the shell can still prerender. + */ export default function MarketsPage() { - const [search, setSearch] = React.useState(""); - const [category, setCategory] = React.useState("all"); + return ( + }> + + + ); +} + +function MarketsPageContent() { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const { announce } = useGlobalLiveRegion(); + + // Hydrate both controls from the URL so /markets?q=argentina&category=Football + // is shareable, bookmarkable and restores the filtered view on reload. An + // unknown category value falls back to "all" instead of filtering to nothing. + const urlSearch = searchParams.get("q") ?? ""; + const urlCategoryParam = searchParams.get("category") ?? ALL_CATEGORIES; + const urlCategory = isKnownCategory(urlCategoryParam) ? urlCategoryParam : ALL_CATEGORIES; + + const [search, setSearch] = React.useState(urlSearch); + const [category, setCategory] = React.useState(urlCategory); + const [debouncedSearch, setDebouncedSearch] = React.useState(urlSearch); + + React.useEffect(() => { + const handle = window.setTimeout(() => setDebouncedSearch(search), SEARCH_DEBOUNCE_MS); + return () => window.clearTimeout(handle); + }, [search]); + + const buildUrl = React.useCallback( + (nextSearch: string, nextCategory: string) => { + const params = new URLSearchParams(searchParams.toString()); + const trimmed = nextSearch.trim(); + if (trimmed) params.set("q", trimmed); + else params.delete("q"); + if (nextCategory !== ALL_CATEGORIES) params.set("category", nextCategory); + else params.delete("category"); + const query = params.toString(); + return query ? `${pathname}?${query}` : pathname; + }, + [pathname, searchParams], + ); + + const currentUrl = `${pathname}${searchParams.toString() ? `?${searchParams.toString()}` : ""}`; + + // `router.replace` (not `push`) keeps the whole filter session on a single + // history entry: Back returns to whatever preceded the markets page instead + // of replaying each keystroke. + React.useEffect(() => { + const next = buildUrl(debouncedSearch, category); + if (next !== currentUrl) router.replace(next, { scroll: false }); + }, [buildUrl, category, currentUrl, debouncedSearch, router]); const filtered = React.useMemo(() => { + const needle = search.trim().toLowerCase(); return MARKETS.filter((m) => { - const matchesSearch = m.title.toLowerCase().includes(search.trim().toLowerCase()); - const matchesCategory = category === "all" || m.category === category; + const matchesSearch = m.title.toLowerCase().includes(needle); + const matchesCategory = category === ALL_CATEGORIES || m.category === category; return matchesSearch && matchesCategory; }); }, [search, category]); + const lastAnnouncedCount = React.useRef(null); + + // WCAG 4.1.3: tell screen-reader users how many markets match after each + // change. Deduplicated on the count itself, so typing that does not change + // the result set stays silent and never spams the live region. + React.useEffect(() => { + const count = filtered.length; + if (lastAnnouncedCount.current === null) { + // The initial render is not a change; leave the live region quiet. + lastAnnouncedCount.current = count; + return; + } + if (lastAnnouncedCount.current === count) return; + lastAnnouncedCount.current = count; + announce({ + message: `${count} ${count === 1 ? "market" : "markets"} found`, + priority: "polite", + }); + }, [filtered.length, announce]); + const hasSearch = search.trim().length > 0; - const hasCategory = category !== "all"; + const hasCategory = category !== ALL_CATEGORIES; const resetFilters = () => { setSearch(""); - setCategory("all"); + setCategory(ALL_CATEGORIES); + setDebouncedSearch(""); + // Clear the query string immediately rather than waiting out the debounce. + if (searchParams.toString()) router.replace(pathname, { scroll: false }); }; return ( @@ -207,7 +308,7 @@ export default function MarketsPage() { - All categories + All categories {CATEGORIES.map((c) => ( {c} ))}