diff --git a/app/(dashboard)/mypredictions/__tests__/page.test.tsx b/app/(dashboard)/mypredictions/__tests__/page.test.tsx new file mode 100644 index 00000000..3742f52c --- /dev/null +++ b/app/(dashboard)/mypredictions/__tests__/page.test.tsx @@ -0,0 +1,128 @@ +import React from "react"; +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import MyPredictionsAndHistoryPage from "../page"; + +// Mock next/navigation +jest.mock("next/navigation", () => ({ + useRouter() { + return { + push: jest.fn(), + replace: jest.fn(), + prefetch: jest.fn(), + }; + }, +})); + +// Mock next/image to render a plain with the same alt so tests +// can assert on the illustration without pulling in Next's runtime. +jest.mock("next/image", () => ({ + __esModule: true, + default: ({ src, alt }: { src: string; alt: string }) => ( + // eslint-disable-next-line @next/next/no-img-element + {alt} + ), +})); + +describe("MyPredictionsAndHistoryPage empty state", () => { + it("renders the page with the default All tab and a populated card list", () => { + render(); + // One of the seeded cards should be present. + expect(screen.getByText(/NBA Finals/i)).toBeInTheDocument(); + // The empty state is NOT shown while there is data. + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + }); + + it("shows a themed empty state when the Active tab has no matches", async () => { + const user = userEvent.setup(); + render(); + // There are active predictions in the seed data, so we type a + // search term that no active prediction matches, then assert that + // the empty state is shown with the correct copy and a reset CTA. + await user.click(screen.getByRole("button", { name: /^Active$/ })); + await user.type( + screen.getByRole("searchbox", { name: /search predictions/i }), + "ZZZ-no-match-zzz", + ); + const region = screen.getByRole("status"); + expect(region).toBeInTheDocument(); + expect( + within(region).getByText(/No "Active" predictions/i), + ).toBeInTheDocument(); + expect( + within(region).getByRole("button", { name: /reset filters/i }), + ).toBeInTheDocument(); + }); + + it("renders the reset CTA and clicking it restores the default view", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("button", { name: /^Completed$/ })); + // Switch to Completed and type a non-matching search term. + await user.type( + screen.getByRole("searchbox", { name: /search predictions/i }), + "ZZZ-no-match-zzz", + ); + const region = screen.getByRole("status"); + expect( + within(region).getByText(/No "Completed" predictions/i), + ).toBeInTheDocument(); + + // Click the reset CTA. + await user.click( + within(region).getByRole("button", { name: /reset filters/i }), + ); + // The empty state should disappear and the seeded data returns. + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + expect(screen.getByText(/NBA Finals/i)).toBeInTheDocument(); + }); + + it("the empty state is announced politely to screen readers", async () => { + const user = userEvent.setup(); + render(); + // Filter to a tab whose copy we know we can rely on. + await user.click(screen.getByRole("button", { name: /^Active$/ })); + // Search for something that doesn't match. + await user.type( + screen.getByRole("searchbox", { name: /search predictions/i }), + "ZZZ-no-match-zzz", + ); + const region = screen.getByRole("status"); + expect(region).toHaveAttribute("aria-live", "polite"); + }); + + it("the empty state renders a Browse events link when not on the Active tab", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("button", { name: /^Pending$/ })); + await user.type( + screen.getByRole("searchbox", { name: /search predictions/i }), + "ZZZ-no-match-zzz", + ); + const region = screen.getByRole("status"); + const link = within(region).getByRole("link", { name: /browse events/i }); + expect(link).toHaveAttribute("href", "/events"); + }); + + it("search-only filtering is honoured: the reset CTA clears the search", async () => { + const user = userEvent.setup(); + render(); + // Type a non-matching search term while on the default All tab. + await user.type( + screen.getByRole("searchbox", { name: /search predictions/i }), + "ZZZ-no-match-zzz", + ); + const region = screen.getByRole("status"); + expect( + within(region).getByText(/No predictions yet/i), + ).toBeInTheDocument(); + // Click the reset CTA and assert searchbox is empty and the data is back. + await user.click( + within(region).getByRole("button", { name: /reset filters/i }), + ); + expect( + screen.getByRole("searchbox", { name: /search predictions/i }), + ).toHaveValue(""); + expect(screen.getByText(/NBA Finals/i)).toBeInTheDocument(); + }); +}); diff --git a/app/(dashboard)/mypredictions/page.tsx b/app/(dashboard)/mypredictions/page.tsx index eeae1577..38a2c13d 100644 --- a/app/(dashboard)/mypredictions/page.tsx +++ b/app/(dashboard)/mypredictions/page.tsx @@ -8,7 +8,10 @@ import { XCircle, Clock, Activity, + RotateCcw, + Sparkles, } from "lucide-react"; +import Image from "next/image"; // --- 1. Type Definitions --- @@ -211,7 +214,6 @@ const PredictionCard: React.FC<{ prediction: Prediction }> = ({

{title}

- div

{description}

@@ -263,49 +265,157 @@ const PredictionCard: React.FC<{ prediction: Prediction }> = ({ ); }; +/** + * Themed empty state for the predictions list. Rendered when a status + * filter (or the combined search + filter state) yields zero matches. + * + * Provides a themed illustration, copy that names the active filter so + * the user knows why the list is empty, and a "Reset filters" CTA that + * restores the default view. Honour the existing dark/light surface via + * Tailwind tokens (no hard-coded colors). + */ +function PredictionsEmptyState({ + activeTab, + hasActiveSearch, + onReset, +}: { + activeTab: FilterTab; + /** When true, the empty state copy mentions the active search term. */ + hasActiveSearch: boolean; + onReset: () => void; +}) { + // Dark/light tokens — bg and text use Tailwind's `dark:` variants so + // the empty state respects the existing theme. + const headingClass = "text-lg sm:text-xl font-semibold text-gray-900 dark:text-gray-100"; + const bodyClass = "text-sm sm:text-base text-gray-600 dark:text-gray-300 max-w-sm"; + + // The empty state copy adapts to the active filter so the user + // immediately knows whether the list is empty because nothing exists + // for that status, or because the search/filter is too narrow. + const headline = + activeTab === "All" + ? "No predictions yet" + : `No "${activeTab}" predictions`; + const description = hasActiveSearch + ? `Nothing matches your search in the "${activeTab}" tab. Try a broader search or clear the active filters.` + : activeTab === "All" + ? "Start predicting on events to see your activity here." + : `You have no predictions in the "${activeTab}" tab right now. Switch tabs to see other predictions, or reset the filter.`; + + return ( +
+
+ +
+ +

{headline}

+

{description}

+ +
+ + {activeTab !== "Active" && ( + + + )} +
+
+ ); +} + /** * PredictionsList component handles the internal filtering and rendering of cards. */ const PredictionsList: React.FC = () => { const TABS: FilterTab[] = ["All", "Active", "Pending", "Completed"]; const [activeTab, setActiveTab] = useState("All"); + const [searchQuery, setSearchQuery] = useState(""); const filteredPredictions = useMemo(() => { - if (activeTab === "All") { - return MOCK_PREDICTIONS; - } - - if (activeTab === "Completed") { - return MOCK_PREDICTIONS.filter( - (p) => p.status === "won" || p.status === "lost" - ); - } - - const status: PredictionStatus = - activeTab.toLowerCase() as PredictionStatus; - return MOCK_PREDICTIONS.filter((p) => p.status === status); - }, [activeTab]); + const needle = searchQuery.trim().toLowerCase(); + const list = (() => { + if (activeTab === "All") return MOCK_PREDICTIONS; + if (activeTab === "Completed") { + return MOCK_PREDICTIONS.filter( + (p) => p.status === "won" || p.status === "lost" + ); + } + const status: PredictionStatus = + activeTab.toLowerCase() as PredictionStatus; + return MOCK_PREDICTIONS.filter((p) => p.status === status); + })(); + if (!needle) return list; + return list.filter( + (p) => + p.title.toLowerCase().includes(needle) || + p.description.toLowerCase().includes(needle) + ); + }, [activeTab, searchQuery]); + + const resetFilters = () => { + setActiveTab("All"); + setSearchQuery(""); + }; return (
{/* Tab Navigation for Status Filtering */} -
- {TABS.map((tab) => ( - - ))} +
+
+ {TABS.map((tab) => ( + + ))} +
+
+
{/* Predictions Grid */} @@ -315,9 +425,11 @@ const PredictionsList: React.FC = () => { )) ) : ( -

- No predictions found for the "{activeTab}" status. -

+ 0} + onReset={resetFilters} + /> )}