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
+
+ ),
+}));
+
+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 (
+