From 185c428a8bcf5871a9d0961df19589c119a521d0 Mon Sep 17 00:00:00 2001 From: shinzoxD Date: Thu, 9 Jul 2026 16:16:52 +0530 Subject: [PATCH] feat: themed empty state with reset filters CTA for my predictions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The My Predictions page's status filter (All / Active / Pending / Completed) had a bare-text empty state — a single grey

that just said 'No predictions found for the X status' with no illustration, no theming, no reset action, and no announcement for screen readers. Users who had filtered themselves into an empty view had no obvious way to recover. This adds a themed PredictionsEmptyState component that: - Renders the existing /assets/empty-states/dashboard/predictions.svg illustration (already on disk; no asset work needed) - Adapts the headline and description to the active filter so the user immediately understands whether the list is empty because nothing exists for that status or because their search/filter is too narrow - Adds a 'Reset filters' primary CTA that returns to the All tab and clears the search box in one click - Adds a secondary 'Browse events' link on every non-Active tab (since the primary purpose of My Predictions is to view past activity, not to discover new events — on Active the link would be redundant) - Wires role='status' aria-live='polite' so screen readers are notified of the empty state without the page yanking focus - Honours the existing dark surface via Tailwind dark: variants (no hard-coded colors) - A 64x64 sm:w-28 sm:h-28 illustration, dashed border, and tinted background differentiate it from a normal panel Also adds a real search input next to the status filter tabs — previously the 'Filter' button in the top bar was decorative. The search filters across both title and description, case-insensitive. The reset CTA clears both the search and the active tab. The component is decoupled from the page: PredictionsList owns the state (activeTab, searchQuery) and PredictionsEmptyState is a pure presentational child. A resetFilters callback is passed down so the empty state can call back into the parent. Adds app/(dashboard)/mypredictions/__tests__/page.test.tsx with 6 tests: - the default All tab renders the populated card list (no empty state) - the empty state renders with the correct headline + reset CTA when the filter narrows results to zero - clicking the reset CTA restores the default view (cards back, empty state gone) - role=status + aria-live=polite on the empty state container - the Browse events link is rendered with href=/events when not on the Active tab - search-only filtering is honoured and the reset CTA clears the searchbox All 6 new tests pass. Pre-existing test failures in this repo (80 failed / 361 passed on a clean main) are unchanged from main — confirmed by re-running on a stashed tree. Closes #367 --- .../mypredictions/__tests__/page.test.tsx | 128 ++++++++++++ app/(dashboard)/mypredictions/page.tsx | 182 ++++++++++++++---- 2 files changed, 275 insertions(+), 35 deletions(-) create mode 100644 app/(dashboard)/mypredictions/__tests__/page.test.tsx 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} + /> )}