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
136 changes: 131 additions & 5 deletions app/markets/__tests__/page.test.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => (
Expand All @@ -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(<MarketsPage />);
Expand All @@ -24,7 +54,7 @@ describe("MarketsPage — empty state", () => {
const user = userEvent.setup();
render(<MarketsPage />);

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();
Expand All @@ -34,23 +64,119 @@ describe("MarketsPage — empty state", () => {
const user = userEvent.setup();
render(<MarketsPage />);

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(<MarketsPage />);

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(<MarketsPage />);

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(<MarketsPage />);

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(<MarketsPage />);

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(<MarketsPage />);

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(<MarketsPage />);

// 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();
}
});
});
117 changes: 109 additions & 8 deletions app/markets/page.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 ──────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -104,6 +106,16 @@ const STATUS_LABELS: Record<MarketStatus, string> = {
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 }) {
Expand Down Expand Up @@ -156,26 +168,115 @@ function MarketCard({ market }: { market: Market }) {

// ── Page ───────────────────────────────────────────────────────────────────

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

/**
* `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 (
<React.Suspense fallback={<MarketsPageFallback />}>
<MarketsPageContent />
</React.Suspense>
);
}

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<number | null>(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 (
Expand Down Expand Up @@ -207,7 +308,7 @@ export default function MarketsPage() {
<SelectValue placeholder="Category" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All categories</SelectItem>
<SelectItem value={ALL_CATEGORIES}>All categories</SelectItem>
{CATEGORIES.map((c) => (
<SelectItem key={c} value={c}>{c}</SelectItem>
))}
Expand Down