Skip to content

Commit 45aa0d8

Browse files
authored
feat(markets): sync filters to the URL and announce result counts (#965) (#968)
1 parent fd01c83 commit 45aa0d8

2 files changed

Lines changed: 240 additions & 13 deletions

File tree

‎app/markets/__tests__/page.test.tsx‎

Lines changed: 131 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,30 @@
11
import React from "react";
2-
import { render, screen } from "@testing-library/react";
2+
import { act, render, screen, waitFor } from "@testing-library/react";
33
import userEvent from "@testing-library/user-event";
44

55
import MarketsPage from "../page";
66

7+
// ── next/navigation ────────────────────────────────────────────────────────
8+
// The page syncs filters to the URL via router.replace; capture the calls and
9+
// drive the initial query string from `mockSearchParams`.
10+
const mockReplace = jest.fn();
11+
const mockPush = jest.fn();
12+
let mockSearchParams = new URLSearchParams();
13+
14+
jest.mock("next/navigation", () => ({
15+
useRouter: () => ({ replace: mockReplace, push: mockPush, prefetch: jest.fn() }),
16+
usePathname: () => "/markets",
17+
useSearchParams: () => mockSearchParams,
18+
}));
19+
20+
// ── live region ────────────────────────────────────────────────────────────
21+
// The real hook is a module-level store; stubbing it keeps the assertion on the
22+
// announcement payload local to this suite.
23+
const mockAnnounce = jest.fn();
24+
jest.mock("@/hooks/use-global-live-region", () => ({
25+
useGlobalLiveRegion: () => ({ announcements: [], announce: mockAnnounce }),
26+
}));
27+
728
// next/link needs a router in tests
829
jest.mock("next/link", () => {
930
const Link = ({ children, href }: { children: React.ReactNode; href: string }) => (
@@ -13,6 +34,15 @@ jest.mock("next/link", () => {
1334
return Link;
1435
});
1536

37+
beforeEach(() => {
38+
mockReplace.mockClear();
39+
mockPush.mockClear();
40+
mockAnnounce.mockClear();
41+
mockSearchParams = new URLSearchParams();
42+
});
43+
44+
const searchbox = () => screen.getByRole("searchbox", { name: /search markets/i });
45+
1646
describe("MarketsPage — empty state", () => {
1747
it("renders markets when no filters are active", () => {
1848
render(<MarketsPage />);
@@ -24,7 +54,7 @@ describe("MarketsPage — empty state", () => {
2454
const user = userEvent.setup();
2555
render(<MarketsPage />);
2656

27-
await user.type(screen.getByRole("searchbox", { name: /search markets/i }), "zzznomatch");
57+
await user.type(searchbox(), "zzznomatch");
2858

2959
expect(screen.getByRole("status")).toBeInTheDocument();
3060
expect(screen.getByText(/no markets match your search/i)).toBeInTheDocument();
@@ -34,23 +64,119 @@ describe("MarketsPage — empty state", () => {
3464
const user = userEvent.setup();
3565
render(<MarketsPage />);
3666

37-
await user.type(screen.getByRole("searchbox", { name: /search markets/i }), "zzznomatch");
67+
await user.type(searchbox(), "zzznomatch");
3868
expect(screen.getByRole("status")).toBeInTheDocument();
3969

4070
await user.click(screen.getByRole("button", { name: /clear all filters/i }));
4171

4272
expect(screen.queryByRole("status")).not.toBeInTheDocument();
4373
expect(screen.getByText(/will argentina win/i)).toBeInTheDocument();
44-
expect(screen.getByRole("searchbox", { name: /search markets/i })).toHaveValue("");
74+
expect(searchbox()).toHaveValue("");
4575
});
4676

4777
it("empty state has role=status and aria-live=polite for accessibility", async () => {
4878
const user = userEvent.setup();
4979
render(<MarketsPage />);
5080

51-
await user.type(screen.getByRole("searchbox", { name: /search markets/i }), "zzznomatch");
81+
await user.type(searchbox(), "zzznomatch");
5282

5383
const status = screen.getByRole("status");
5484
expect(status).toHaveAttribute("aria-live", "polite");
5585
});
5686
});
87+
88+
describe("MarketsPage — URL-backed filters", () => {
89+
it("hydrates search and category from the query string", () => {
90+
mockSearchParams = new URLSearchParams("q=argentina&category=Football");
91+
92+
render(<MarketsPage />);
93+
94+
expect(searchbox()).toHaveValue("argentina");
95+
expect(screen.getByRole("combobox", { name: /filter by category/i })).toHaveTextContent(
96+
"Football",
97+
);
98+
expect(screen.getByText(/will argentina win/i)).toBeInTheDocument();
99+
expect(screen.queryByText(/will brazil reach/i)).not.toBeInTheDocument();
100+
});
101+
102+
it("falls back to 'all' for an unknown category param instead of hiding every market", () => {
103+
mockSearchParams = new URLSearchParams("category=NotARealCategory");
104+
105+
render(<MarketsPage />);
106+
107+
expect(screen.getByRole("combobox", { name: /filter by category/i })).toHaveTextContent(
108+
/all categories/i,
109+
);
110+
expect(screen.getByText(/will argentina win/i)).toBeInTheDocument();
111+
expect(screen.getByText(/will brazil reach/i)).toBeInTheDocument();
112+
});
113+
114+
it("writes ?q= with router.replace after the search debounce, never router.push", async () => {
115+
jest.useFakeTimers();
116+
const user = userEvent.setup({ advanceTimers: (ms: number) => jest.advanceTimersByTime(ms) });
117+
try {
118+
render(<MarketsPage />);
119+
120+
await user.type(searchbox(), "argentina");
121+
// Still inside the debounce window: no URL write yet.
122+
expect(mockReplace).not.toHaveBeenCalled();
123+
124+
act(() => {
125+
jest.advanceTimersByTime(300);
126+
});
127+
128+
expect(mockReplace).toHaveBeenCalledTimes(1);
129+
expect(mockReplace.mock.calls[0][0]).toBe("/markets?q=argentina");
130+
expect(mockPush).not.toHaveBeenCalled();
131+
} finally {
132+
jest.useRealTimers();
133+
}
134+
});
135+
136+
it("clears the query string immediately when the empty-state button resets filters", async () => {
137+
jest.useFakeTimers();
138+
const user = userEvent.setup({ advanceTimers: (ms: number) => jest.advanceTimersByTime(ms) });
139+
try {
140+
mockSearchParams = new URLSearchParams("q=zzznomatch&category=Football");
141+
render(<MarketsPage />);
142+
143+
expect(screen.getByRole("status")).toBeInTheDocument();
144+
145+
await user.click(screen.getByRole("button", { name: /clear all filters/i }));
146+
147+
expect(mockReplace).toHaveBeenCalledWith("/markets", { scroll: false });
148+
expect(screen.queryByRole("status")).not.toBeInTheDocument();
149+
} finally {
150+
jest.useRealTimers();
151+
}
152+
});
153+
});
154+
155+
describe("MarketsPage — result announcements", () => {
156+
it("announces the matching count politely, once per change", async () => {
157+
jest.useFakeTimers();
158+
const user = userEvent.setup({ advanceTimers: (ms: number) => jest.advanceTimersByTime(ms) });
159+
try {
160+
render(<MarketsPage />);
161+
162+
// The initial render is not a change, so the live region stays quiet.
163+
expect(mockAnnounce).not.toHaveBeenCalled();
164+
165+
await user.type(searchbox(), "arg");
166+
167+
expect(mockAnnounce).toHaveBeenCalledTimes(1);
168+
expect(mockAnnounce).toHaveBeenCalledWith({
169+
message: "1 market found",
170+
priority: "polite",
171+
});
172+
173+
// A trailing space trims to the same query: the count is unchanged, so
174+
// there must be no second announcement.
175+
await user.type(searchbox(), " ");
176+
await waitFor(() => expect(searchbox()).toHaveValue("arg "));
177+
expect(mockAnnounce).toHaveBeenCalledTimes(1);
178+
} finally {
179+
jest.useRealTimers();
180+
}
181+
});
182+
});

‎app/markets/page.tsx‎

Lines changed: 109 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"use client";
22

33
import * as React from "react";
4+
import { usePathname, useRouter, useSearchParams } from "next/navigation";
45
import Link from "next/link";
56
import { Search, Filter, Trophy, CircleDollarSign, Building2, TrendingUp } from "lucide-react";
67
import { Input } from "@/components/ui/input";
@@ -14,6 +15,7 @@ import {
1415
SelectValue,
1516
} from "@/components/ui/select";
1617
import { NoMatchEmptyState } from "@/components/events/NoMatchEmptyState";
18+
import { useGlobalLiveRegion } from "@/hooks/use-global-live-region";
1719

1820
// ── Types ──────────────────────────────────────────────────────────────────
1921

@@ -104,6 +106,16 @@ const STATUS_LABELS: Record<MarketStatus, string> = {
104106
closed: "Closed",
105107
};
106108

109+
const CATEGORIES = ["Football", "Crypto", "Politics", "Other"] as const;
110+
const ALL_CATEGORIES = "all";
111+
112+
/** One URL write per pause in typing, not one per keystroke. */
113+
const SEARCH_DEBOUNCE_MS = 300;
114+
115+
function isKnownCategory(value: string): value is MarketCategory {
116+
return (CATEGORIES as readonly string[]).includes(value);
117+
}
118+
107119
// ── Market card ────────────────────────────────────────────────────────────
108120

109121
function MarketCard({ market }: { market: Market }) {
@@ -156,26 +168,115 @@ function MarketCard({ market }: { market: Market }) {
156168

157169
// ── Page ───────────────────────────────────────────────────────────────────
158170

159-
const CATEGORIES = ["Football", "Crypto", "Politics", "Other"] as const;
171+
function MarketsPageFallback() {
172+
return (
173+
<div className="mx-auto max-w-5xl px-4 py-8 space-y-6">
174+
<div className="flex flex-col gap-1">
175+
<h1 className="text-2xl font-bold text-[#540D8D]">Markets</h1>
176+
<p className="text-sm text-muted-foreground">
177+
Browse and predict on live markets — FWC26 campaign and more.
178+
</p>
179+
</div>
180+
</div>
181+
);
182+
}
160183

184+
/**
185+
* `useSearchParams()` opts this route into client-side rendering, so the
186+
* filters live behind a Suspense boundary and the shell can still prerender.
187+
*/
161188
export default function MarketsPage() {
162-
const [search, setSearch] = React.useState("");
163-
const [category, setCategory] = React.useState("all");
189+
return (
190+
<React.Suspense fallback={<MarketsPageFallback />}>
191+
<MarketsPageContent />
192+
</React.Suspense>
193+
);
194+
}
195+
196+
function MarketsPageContent() {
197+
const router = useRouter();
198+
const pathname = usePathname();
199+
const searchParams = useSearchParams();
200+
const { announce } = useGlobalLiveRegion();
201+
202+
// Hydrate both controls from the URL so /markets?q=argentina&category=Football
203+
// is shareable, bookmarkable and restores the filtered view on reload. An
204+
// unknown category value falls back to "all" instead of filtering to nothing.
205+
const urlSearch = searchParams.get("q") ?? "";
206+
const urlCategoryParam = searchParams.get("category") ?? ALL_CATEGORIES;
207+
const urlCategory = isKnownCategory(urlCategoryParam) ? urlCategoryParam : ALL_CATEGORIES;
208+
209+
const [search, setSearch] = React.useState(urlSearch);
210+
const [category, setCategory] = React.useState(urlCategory);
211+
const [debouncedSearch, setDebouncedSearch] = React.useState(urlSearch);
212+
213+
React.useEffect(() => {
214+
const handle = window.setTimeout(() => setDebouncedSearch(search), SEARCH_DEBOUNCE_MS);
215+
return () => window.clearTimeout(handle);
216+
}, [search]);
217+
218+
const buildUrl = React.useCallback(
219+
(nextSearch: string, nextCategory: string) => {
220+
const params = new URLSearchParams(searchParams.toString());
221+
const trimmed = nextSearch.trim();
222+
if (trimmed) params.set("q", trimmed);
223+
else params.delete("q");
224+
if (nextCategory !== ALL_CATEGORIES) params.set("category", nextCategory);
225+
else params.delete("category");
226+
const query = params.toString();
227+
return query ? `${pathname}?${query}` : pathname;
228+
},
229+
[pathname, searchParams],
230+
);
231+
232+
const currentUrl = `${pathname}${searchParams.toString() ? `?${searchParams.toString()}` : ""}`;
233+
234+
// `router.replace` (not `push`) keeps the whole filter session on a single
235+
// history entry: Back returns to whatever preceded the markets page instead
236+
// of replaying each keystroke.
237+
React.useEffect(() => {
238+
const next = buildUrl(debouncedSearch, category);
239+
if (next !== currentUrl) router.replace(next, { scroll: false });
240+
}, [buildUrl, category, currentUrl, debouncedSearch, router]);
164241

165242
const filtered = React.useMemo(() => {
243+
const needle = search.trim().toLowerCase();
166244
return MARKETS.filter((m) => {
167-
const matchesSearch = m.title.toLowerCase().includes(search.trim().toLowerCase());
168-
const matchesCategory = category === "all" || m.category === category;
245+
const matchesSearch = m.title.toLowerCase().includes(needle);
246+
const matchesCategory = category === ALL_CATEGORIES || m.category === category;
169247
return matchesSearch && matchesCategory;
170248
});
171249
}, [search, category]);
172250

251+
const lastAnnouncedCount = React.useRef<number | null>(null);
252+
253+
// WCAG 4.1.3: tell screen-reader users how many markets match after each
254+
// change. Deduplicated on the count itself, so typing that does not change
255+
// the result set stays silent and never spams the live region.
256+
React.useEffect(() => {
257+
const count = filtered.length;
258+
if (lastAnnouncedCount.current === null) {
259+
// The initial render is not a change; leave the live region quiet.
260+
lastAnnouncedCount.current = count;
261+
return;
262+
}
263+
if (lastAnnouncedCount.current === count) return;
264+
lastAnnouncedCount.current = count;
265+
announce({
266+
message: `${count} ${count === 1 ? "market" : "markets"} found`,
267+
priority: "polite",
268+
});
269+
}, [filtered.length, announce]);
270+
173271
const hasSearch = search.trim().length > 0;
174-
const hasCategory = category !== "all";
272+
const hasCategory = category !== ALL_CATEGORIES;
175273

176274
const resetFilters = () => {
177275
setSearch("");
178-
setCategory("all");
276+
setCategory(ALL_CATEGORIES);
277+
setDebouncedSearch("");
278+
// Clear the query string immediately rather than waiting out the debounce.
279+
if (searchParams.toString()) router.replace(pathname, { scroll: false });
179280
};
180281

181282
return (
@@ -207,7 +308,7 @@ export default function MarketsPage() {
207308
<SelectValue placeholder="Category" />
208309
</SelectTrigger>
209310
<SelectContent>
210-
<SelectItem value="all">All categories</SelectItem>
311+
<SelectItem value={ALL_CATEGORIES}>All categories</SelectItem>
211312
{CATEGORIES.map((c) => (
212313
<SelectItem key={c} value={c}>{c}</SelectItem>
213314
))}

0 commit comments

Comments
 (0)