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
49 changes: 46 additions & 3 deletions app/(marketing)/_components/__tests__/markets-widget.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@
*/

import React from "react";
import { render, screen, act } from "@testing-library/react";
import { fireEvent, render, screen, act } from "@testing-library/react";
import { useFollowsStore } from "@/app/state/follows";
import { useBookmarksStore } from "@/app/state/bookmarks";

// ── minimal stub for the Card component ─────────────────────────────────────
jest.mock("@/components/ui/card", () => ({
Expand All @@ -24,12 +25,36 @@ function resetFollows() {
});
}

function resetBookmarks() {
act(() => {
useBookmarksStore.setState({ bookmarkedIds: new Set() });
});
}

// We import the file under test AFTER mocks are in place.
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { MarketsWidget } = require("../markets-widget");

describe("MarketsWidget – following indicator", () => {
beforeEach(resetFollows);
beforeAll(() => {
Object.defineProperty(window, "matchMedia", {
writable: true,
value: jest.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});
});

beforeEach(() => {
resetFollows();
resetBookmarks();
});

it("does not show any following indicator when no markets are followed", () => {
render(<MarketsWidget />);
Expand Down Expand Up @@ -64,4 +89,22 @@ describe("MarketsWidget – following indicator", () => {
const indicators = screen.getAllByTestId("following-indicator");
expect(indicators).toHaveLength(1);
});

it("lets users save markets and review the saved list from the widget header", () => {
render(<MarketsWidget />);

fireEvent.click(screen.getByRole("button", { name: "Save Bitcoin Price for later" }));

expect(screen.getByRole("button", { name: "Remove Bitcoin Price from saved markets" })).toHaveAttribute(
"aria-pressed",
"true"
);
const savedHeaderButton = screen.getByRole("button", { name: "Saved markets, 1 saved" });
expect(savedHeaderButton).toHaveAttribute("aria-expanded", "false");

fireEvent.click(savedHeaderButton);

expect(savedHeaderButton).toHaveAttribute("aria-expanded", "true");
expect(screen.getByText("Saved: Bitcoin Price")).toBeInTheDocument();
});
});
55 changes: 44 additions & 11 deletions app/(marketing)/_components/markets-widget.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
"use client";

import { ArrowRight, TrendingUp, Globe, BarChart3, CheckCircle2, Coins, Bell } from "lucide-react";
import LanguageBadge from "@/components/LanguageBadge";
import { TrendingUp, Globe, BarChart3, CheckCircle2, Coins, Bell, Bookmark, type LucideIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { BookmarkButton } from "@/app/components/BookmarkButton";
import { sampleMarkets, winNotifications, type Market } from "@/content/markets.sample";
import { useState, useEffect } from "react";
import { useFollowsStore } from "@/app/state/follows";
import { useBookmarksStore } from "@/app/state/bookmarks";

interface MarketsWidgetProps {
className?: string;
Expand Down Expand Up @@ -35,6 +36,9 @@ const colorMap = {

export function MarketsWidget({ className }: MarketsWidgetProps) {
const [reducedMotion, setReducedMotion] = useState(false);
const [savedListOpen, setSavedListOpen] = useState(false);
const bookmarkedIds = useBookmarksStore((state) => state.bookmarkedIds);
const savedMarkets = sampleMarkets.filter((market) => bookmarkedIds.has(market.id));

useEffect(() => {
setReducedMotion(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
Expand All @@ -60,13 +64,39 @@ export function MarketsWidget({ className }: MarketsWidgetProps) {

{/* Markets Card */}
<Card className="w-full max-w-md border-white/10 bg-gradient-to-b from-[#48097B] to-[#111827] p-6 backdrop-blur-xl">
<div className="mb-6 flex items-center justify-between">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<h2 className="text-xl font-semibold text-white">Popular Prediction Markets</h2>
<button className="text-sm text-purple-300 hover:text-purple-200 transition-colors">
View All
</button>
<div className="flex items-center gap-2">
<button
type="button"
aria-label={`Saved markets, ${savedMarkets.length} saved`}
aria-controls="saved-markets-list"
aria-expanded={savedMarkets.length > 0 && savedListOpen}
disabled={savedMarkets.length === 0}
onClick={() => setSavedListOpen((open) => !open)}
className="inline-flex items-center gap-1 rounded-md border border-purple-300/30 px-2.5 py-1.5 text-sm font-medium text-purple-200 transition-colors hover:bg-purple-500/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-purple-300"
>
<Bookmark className="h-4 w-4" aria-hidden="true" />
Saved {savedMarkets.length}
</button>
<button className="text-sm text-purple-300 transition-colors hover:text-purple-200">
View All
</button>
</div>
</div>

{savedMarkets.length > 0 && savedListOpen && (
<ul
id="saved-markets-list"
aria-label="Saved markets"
className="mb-4 space-y-1 rounded-md border border-purple-300/20 bg-purple-500/10 px-3 py-2 text-sm text-purple-100"
>
{savedMarkets.map((market) => (
<li key={market.id}>Saved: {market.title}</li>
))}
</ul>
)}

<div className="space-y-4">
{sampleMarkets.map((market, index) => {
const IconComponent = iconMap[market.icon as keyof typeof iconMap];
Expand Down Expand Up @@ -108,7 +138,7 @@ export function MarketsWidget({ className }: MarketsWidgetProps) {

interface MarketCardProps {
market: Market;
IconComponent: any;
IconComponent: LucideIcon;
colors: { bg: string; icon: string };
index: number;
reducedMotion: boolean;
Expand Down Expand Up @@ -165,9 +195,12 @@ function MarketCard({ market, IconComponent, colors, index, reducedMotion }: Mar
)}
</div>
</div>
<div className="text-right">
<div className="text-sm font-medium text-green-400">Yes: {market.yesOdds}%</div>
<div className="text-sm text-red-400">No: {market.noOdds}%</div>
<div className="flex items-start gap-2">
<div className="text-right">
<div className="text-sm font-medium text-green-400">Yes: {market.yesOdds}%</div>
<div className="text-sm text-red-400">No: {market.noOdds}%</div>
</div>
<BookmarkButton marketId={market.id} marketTitle={market.title} />
</div>
</div>

Expand All @@ -185,4 +218,4 @@ function MarketCard({ market, IconComponent, colors, index, reducedMotion }: Mar
</div>
</Card>
);
}
}
35 changes: 35 additions & 0 deletions app/components/BookmarkButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"use client";

import { Bookmark } from "lucide-react";
import { useBookmarksStore } from "@/app/state/bookmarks";
import { cn } from "@/lib/utils";

interface BookmarkButtonProps {
marketId: string;
marketTitle: string;
className?: string;
}

export function BookmarkButton({ marketId, marketTitle, className }: BookmarkButtonProps) {
const isBookmarked = useBookmarksStore((state) => state.isBookmarked(marketId));
const toggle = useBookmarksStore((state) => state.toggle);
const label = isBookmarked
? `Remove ${marketTitle} from saved markets`
: `Save ${marketTitle} for later`;

return (
<button
type="button"
aria-label={label}
aria-pressed={isBookmarked}
className={cn(
"inline-flex h-9 w-9 items-center justify-center rounded-md border border-white/10 text-white/70 transition-colors hover:bg-white/10 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-purple-300",
isBookmarked && "border-purple-300/60 bg-purple-500/20 text-purple-200",
className
)}
onClick={() => toggle(marketId)}
>
<Bookmark className={cn("h-4 w-4", isBookmarked && "fill-current")} aria-hidden="true" />
</button>
);
}
34 changes: 34 additions & 0 deletions app/components/__tests__/BookmarkButton.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import React from "react";
import { fireEvent, render, screen } from "@testing-library/react";
import { BookmarkButton } from "../BookmarkButton";
import { useBookmarksStore } from "@/app/state/bookmarks";

function resetStore() {
useBookmarksStore.setState({ bookmarkedIds: new Set() });
}

describe("BookmarkButton", () => {
beforeEach(resetStore);

it("saves and removes a market with accessible pressed state", () => {
render(<BookmarkButton marketId="btc-price" marketTitle="Bitcoin Price" />);

const button = screen.getByRole("button", { name: "Save Bitcoin Price for later" });
expect(button).toHaveAttribute("aria-pressed", "false");

fireEvent.click(button);

expect(
screen.getByRole("button", { name: "Remove Bitcoin Price from saved markets" })
).toHaveAttribute("aria-pressed", "true");
expect(useBookmarksStore.getState().isBookmarked("btc-price")).toBe(true);

fireEvent.click(screen.getByRole("button", { name: "Remove Bitcoin Price from saved markets" }));

expect(screen.getByRole("button", { name: "Save Bitcoin Price for later" })).toHaveAttribute(
"aria-pressed",
"false"
);
expect(useBookmarksStore.getState().isBookmarked("btc-price")).toBe(false);
});
});
53 changes: 53 additions & 0 deletions app/state/__tests__/bookmarks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { act } from "@testing-library/react";
import { useBookmarksStore } from "../bookmarks";

function resetStore() {
act(() => {
useBookmarksStore.setState({ bookmarkedIds: new Set() });
});
}

describe("useBookmarksStore", () => {
beforeEach(resetStore);

it("starts with no saved markets", () => {
expect(useBookmarksStore.getState().isBookmarked("btc-price")).toBe(false);
expect(useBookmarksStore.getState().getCount()).toBe(0);
});

it("adds and removes saved markets directly", () => {
act(() => {
useBookmarksStore.getState().bookmark("btc-price");
});

expect(useBookmarksStore.getState().isBookmarked("btc-price")).toBe(true);
expect(useBookmarksStore.getState().getCount()).toBe(1);

act(() => {
useBookmarksStore.getState().unbookmark("btc-price");
});

expect(useBookmarksStore.getState().isBookmarked("btc-price")).toBe(false);
expect(useBookmarksStore.getState().getCount()).toBe(0);
});

it("toggles a market bookmark and returns the new state", () => {
let result: boolean;

act(() => {
result = useBookmarksStore.getState().toggle("btc-price");
});

expect(result!).toBe(true);
expect(useBookmarksStore.getState().isBookmarked("btc-price")).toBe(true);
expect(useBookmarksStore.getState().getCount()).toBe(1);

act(() => {
result = useBookmarksStore.getState().toggle("btc-price");
});

expect(result!).toBe(false);
expect(useBookmarksStore.getState().isBookmarked("btc-price")).toBe(false);
expect(useBookmarksStore.getState().getCount()).toBe(0);
});
});
75 changes: 75 additions & 0 deletions app/state/bookmarks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
"use client";

import { create } from "zustand";
import { persist } from "zustand/middleware";

interface BookmarksState {
bookmarkedIds: Set<string>;
isBookmarked: (marketId: string) => boolean;
bookmark: (marketId: string) => void;
unbookmark: (marketId: string) => void;
toggle: (marketId: string) => boolean;
getCount: () => number;
}

export const useBookmarksStore = create<BookmarksState>()(
persist(
(set, get) => ({
bookmarkedIds: new Set<string>(),

isBookmarked: (marketId) => get().bookmarkedIds.has(marketId),

bookmark: (marketId) =>
set((state) => ({
bookmarkedIds: new Set([...state.bookmarkedIds, marketId]),
})),

unbookmark: (marketId) =>
set((state) => {
const next = new Set(state.bookmarkedIds);
next.delete(marketId);
return { bookmarkedIds: next };
}),

toggle: (marketId) => {
const bookmarked = get().isBookmarked(marketId);
if (bookmarked) {
get().unbookmark(marketId);
} else {
get().bookmark(marketId);
}
return !bookmarked;
},

getCount: () => get().bookmarkedIds.size,
}),
{
name: "predictify-bookmarks",
storage: {
getItem: (key) => {
const raw = localStorage.getItem(key);
if (!raw) return null;
const parsed = JSON.parse(raw);
return {
...parsed,
state: {
...parsed.state,
bookmarkedIds: new Set<string>(parsed.state.bookmarkedIds ?? []),
},
};
},
setItem: (key, value) => {
const serialisable = {
...value,
state: {
...value.state,
bookmarkedIds: [...(value.state.bookmarkedIds as Set<string>)],
},
};
localStorage.setItem(key, JSON.stringify(serialisable));
},
removeItem: (key) => localStorage.removeItem(key),
},
}
)
);
11 changes: 11 additions & 0 deletions docs/bookmark-market.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Saved Market Bookmarks

Markets can be saved from the marketing widget with the bookmark button on each
market card. Saved market IDs are stored in the client-side
`useBookmarksStore` Zustand store and persisted to `localStorage` under
`predictify-bookmarks`.

The widget header exposes the saved count with the accessible label
`Saved markets, N saved`. When at least one market is saved, the header also
shows a compact saved list so users can review their personal market shortlist
without leaving the widget.