Skip to content
Open
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
128 changes: 128 additions & 0 deletions app/(dashboard)/mypredictions/__tests__/page.test.tsx
Original file line number Diff line number Diff line change
@@ -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 <img> 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
<img src={src} alt={alt} />
),
}));

describe("MyPredictionsAndHistoryPage empty state", () => {
it("renders the page with the default All tab and a populated card list", () => {
render(<MyPredictionsAndHistoryPage />);
// 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(<MyPredictionsAndHistoryPage />);
// 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(<MyPredictionsAndHistoryPage />);
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(<MyPredictionsAndHistoryPage />);
// 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(<MyPredictionsAndHistoryPage />);
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(<MyPredictionsAndHistoryPage />);
// 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();
});
});
182 changes: 147 additions & 35 deletions app/(dashboard)/mypredictions/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ import {
XCircle,
Clock,
Activity,
RotateCcw,
Sparkles,
} from "lucide-react";
import Image from "next/image";

// --- 1. Type Definitions ---

Expand Down Expand Up @@ -211,7 +214,6 @@ const PredictionCard: React.FC<{ prediction: Prediction }> = ({
<h3 className="text-[17px] font-semibold text-[#111827] truncate">
{title}
</h3>
div
<div>
<p className="text-[#6B7280] text-[15px]">{description}</p>
</div>
Expand Down Expand Up @@ -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 (
<div
role="status"
aria-live="polite"
className="col-span-full flex flex-col items-center justify-center rounded-xl border border-dashed border-[#E5E7EB] dark:border-[#374151] bg-white/60 dark:bg-[#1F2937]/60 px-6 py-10 sm:py-14 text-center"
>
<div className="w-24 h-24 sm:w-28 sm:h-28 mb-4 opacity-90">
<Image
src="/assets/empty-states/dashboard/predictions.svg"
alt=""
width={112}
height={112}
className="w-full h-full"
aria-hidden="true"
/>
</div>

<h3 className={headingClass}>{headline}</h3>
<p className={bodyClass}>{description}</p>

<div className="mt-6 flex flex-col sm:flex-row items-center gap-3">
<button
type="button"
onClick={onReset}
className="inline-flex items-center gap-2 rounded-lg bg-[#6C17B0] hover:bg-[#5A1196] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white px-4 py-2 text-sm font-semibold text-white transition"
>
<RotateCcw size={16} aria-hidden="true" />
Reset filters
</button>
{activeTab !== "Active" && (
<a
href="/events"
className="inline-flex items-center gap-2 rounded-lg border border-[#E5E7EB] dark:border-[#374151] hover:bg-white/80 dark:hover:bg-[#111827]/60 px-4 py-2 text-sm font-semibold text-gray-700 dark:text-gray-200 transition"
>
<Sparkles size={16} aria-hidden="true" />
Browse events
</a>
)}
</div>
</div>
);
}

/**
* 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<FilterTab>("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 (
<div className="space-y-6">
{/* Tab Navigation for Status Filtering */}
<div className="flex space-x-2 pb-2">
{TABS.map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`
px-4 py-2 text-sm font-medium rounded-lg transition duration-200
${
activeTab === tab
? "bg-[#6C17B0] text-white border-b-4 border-white p-3 rounded-lg"
: "text-[#6B7280] hover:bg-gray-700 hover:text-white active:bg-gray-600"
}
`}
>
{tab}
</button>
))}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex space-x-2 pb-2 overflow-x-auto">
{TABS.map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`
px-4 py-2 text-sm font-medium rounded-lg transition duration-200 whitespace-nowrap
${
activeTab === tab
? "bg-[#6C17B0] text-white border-b-4 border-white p-3 rounded-lg"
: "text-[#6B7280] hover:bg-gray-700 hover:text-white active:bg-gray-600"
}
`}
>
{tab}
</button>
))}
</div>
<div className="relative w-full sm:w-72">
<Search
className="pointer-events-none absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground"
aria-hidden="true"
/>
<input
type="search"
role="searchbox"
aria-label="Search predictions"
placeholder="Search predictions…"
className="w-full rounded-lg border border-[#E5E7EB] bg-white py-2 pl-8 pr-3 text-sm text-gray-900 placeholder:text-[#9CA3AF] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#6C17B0]"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
</div>

{/* Predictions Grid */}
Expand All @@ -315,9 +425,11 @@ const PredictionsList: React.FC = () => {
<PredictionCard key={prediction.id} prediction={prediction} />
))
) : (
<p className="text-gray-400 text-center col-span-full py-10 text-lg">
No predictions found for the "{activeTab}" status.
</p>
<PredictionsEmptyState
activeTab={activeTab}
hasActiveSearch={searchQuery.trim().length > 0}
onReset={resetFilters}
/>
)}
</div>
</div>
Expand Down