diff --git a/app/(marketing)/_components/__tests__/markets-widget.test.tsx b/app/(marketing)/_components/__tests__/markets-widget.test.tsx index 770b5ade..f0b2c501 100644 --- a/app/(marketing)/_components/__tests__/markets-widget.test.tsx +++ b/app/(marketing)/_components/__tests__/markets-widget.test.tsx @@ -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", () => ({ @@ -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(); @@ -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(); + + 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(); + }); }); diff --git a/app/(marketing)/_components/markets-widget.tsx b/app/(marketing)/_components/markets-widget.tsx index 90ad334f..15a1b671 100644 --- a/app/(marketing)/_components/markets-widget.tsx +++ b/app/(marketing)/_components/markets-widget.tsx @@ -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; @@ -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); @@ -60,13 +64,39 @@ export function MarketsWidget({ className }: MarketsWidgetProps) { {/* Markets Card */} -
+

Popular Prediction Markets

- +
+ + +
+ {savedMarkets.length > 0 && savedListOpen && ( +
    + {savedMarkets.map((market) => ( +
  • Saved: {market.title}
  • + ))} +
+ )} +
{sampleMarkets.map((market, index) => { const IconComponent = iconMap[market.icon as keyof typeof iconMap]; @@ -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; @@ -165,9 +195,12 @@ function MarketCard({ market, IconComponent, colors, index, reducedMotion }: Mar )}
-
-
Yes: {market.yesOdds}%
-
No: {market.noOdds}%
+
+
+
Yes: {market.yesOdds}%
+
No: {market.noOdds}%
+
+
@@ -185,4 +218,4 @@ function MarketCard({ market, IconComponent, colors, index, reducedMotion }: Mar
); -} \ No newline at end of file +} diff --git a/app/components/BookmarkButton.tsx b/app/components/BookmarkButton.tsx new file mode 100644 index 00000000..15e241c5 --- /dev/null +++ b/app/components/BookmarkButton.tsx @@ -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 ( + + ); +} diff --git a/app/components/__tests__/BookmarkButton.test.tsx b/app/components/__tests__/BookmarkButton.test.tsx new file mode 100644 index 00000000..7a5123ae --- /dev/null +++ b/app/components/__tests__/BookmarkButton.test.tsx @@ -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(); + + 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); + }); +}); diff --git a/app/state/__tests__/bookmarks.test.ts b/app/state/__tests__/bookmarks.test.ts new file mode 100644 index 00000000..2175b067 --- /dev/null +++ b/app/state/__tests__/bookmarks.test.ts @@ -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); + }); +}); diff --git a/app/state/bookmarks.ts b/app/state/bookmarks.ts new file mode 100644 index 00000000..d30c4a9a --- /dev/null +++ b/app/state/bookmarks.ts @@ -0,0 +1,75 @@ +"use client"; + +import { create } from "zustand"; +import { persist } from "zustand/middleware"; + +interface BookmarksState { + bookmarkedIds: Set; + isBookmarked: (marketId: string) => boolean; + bookmark: (marketId: string) => void; + unbookmark: (marketId: string) => void; + toggle: (marketId: string) => boolean; + getCount: () => number; +} + +export const useBookmarksStore = create()( + persist( + (set, get) => ({ + bookmarkedIds: new Set(), + + 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(parsed.state.bookmarkedIds ?? []), + }, + }; + }, + setItem: (key, value) => { + const serialisable = { + ...value, + state: { + ...value.state, + bookmarkedIds: [...(value.state.bookmarkedIds as Set)], + }, + }; + localStorage.setItem(key, JSON.stringify(serialisable)); + }, + removeItem: (key) => localStorage.removeItem(key), + }, + } + ) +); diff --git a/docs/bookmark-market.md b/docs/bookmark-market.md new file mode 100644 index 00000000..2493c5e8 --- /dev/null +++ b/docs/bookmark-market.md @@ -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.