From 9d3402652dd733b76ab2ac5b7bf18ace3e3d95ac Mon Sep 17 00:00:00 2001 From: Annie <168873935+AnnieIj@users.noreply.github.com> Date: Thu, 23 Jul 2026 19:54:32 +0000 Subject: [PATCH] feat: trending markets rail Add "What is happening now" trending markets rail on the home page. - New TrendingRail component with horizontal scrollable carousel - 8 curated trending markets with volume/hot indicators - 11 tests covering rendering, hot badges, keyboard nav, accessibility - Placed between Hero and KpiStrip on the marketing page - Follows RecommendationsStrip pattern for consistency Closes #456 --- app/(marketing)/page.tsx | 2 + app/components/TrendingRail.test.tsx | 159 ++++++++++++++ app/components/TrendingRail.tsx | 301 +++++++++++++++++++++++++++ docs/TRENDING_RAIL.md | 97 +++++++++ 4 files changed, 559 insertions(+) create mode 100644 app/components/TrendingRail.test.tsx create mode 100644 app/components/TrendingRail.tsx create mode 100644 docs/TRENDING_RAIL.md diff --git a/app/(marketing)/page.tsx b/app/(marketing)/page.tsx index b2875c5b..c366cfba 100644 --- a/app/(marketing)/page.tsx +++ b/app/(marketing)/page.tsx @@ -20,6 +20,7 @@ import Navbar from "./_components/navbar"; import KpiStrip from "./_sections/kpi-strip"; import { CTA } from "./_sections/cta"; import { useParallax } from "@/hooks/use-parallax"; +import { TrendingRail } from "@/app/components/TrendingRail"; export default function MarketingRoute() { const orbRef1 = useParallax({ depth: -20 }); @@ -35,6 +36,7 @@ export default function MarketingRoute() { + diff --git a/app/components/TrendingRail.test.tsx b/app/components/TrendingRail.test.tsx new file mode 100644 index 00000000..9e59df46 --- /dev/null +++ b/app/components/TrendingRail.test.tsx @@ -0,0 +1,159 @@ +import React from "react" +import { render, screen, fireEvent } from "@testing-library/react" +import { TrendingRail, TRENDING_MARKETS } from "./TrendingRail" + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function mockOverflow(container: HTMLElement) { + Object.defineProperty(container, "scrollWidth", { + value: 3000, + configurable: true, + }) + Object.defineProperty(container, "clientWidth", { + value: 500, + configurable: true, + }) + Object.defineProperty(container, "scrollLeft", { + value: 0, + writable: true, + configurable: true, + }) + container.scrollTo = jest.fn( + ({ left }: { left: number }) => { + Object.defineProperty(container, "scrollLeft", { + value: left, + writable: true, + configurable: true, + }) + }, + ) as unknown as typeof container.scrollTo +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("TrendingRail", () => { + // ---- Rendering ---------------------------------------------------------- + + it("renders the heading", () => { + render() + expect( + screen.getByRole("heading", { name: /what's happening now/i }), + ).toBeInTheDocument() + }) + + it("renders all default trending markets", () => { + render() + TRENDING_MARKETS.forEach((market) => { + expect(screen.getByText(market.title)).toBeInTheDocument() + }) + }) + + it("renders each trending market as a link", () => { + render() + const links = screen.getAllByRole("link") + expect(links.length).toBe(TRENDING_MARKETS.length) + links.forEach((link) => expect(link).toHaveAttribute("href")) + }) + + // ---- Empty state -------------------------------------------------------- + + it("returns nothing when no markets are provided", () => { + const { container } = render() + expect(container.innerHTML).toBe("") + }) + + // ---- Hot markets -------------------------------------------------------- + + it("shows 'Hot' badge on markets with isHot flag", () => { + render() + const hotBadges = screen.getAllByText("Hot") + const hotMarkets = TRENDING_MARKETS.filter((m) => m.isHot) + expect(hotBadges.length).toBe(hotMarkets.length) + }) + + // ---- Volume display ----------------------------------------------------- + + it("displays 24h volume for each market", () => { + render() + TRENDING_MARKETS.forEach((market) => { + expect(screen.getByText(market.volume)).toBeInTheDocument() + }) + const volLabels = screen.getAllByText("24h vol") + expect(volLabels.length).toBe(TRENDING_MARKETS.length) + }) + + // ---- Accessibility ------------------------------------------------------ + + it("has a labelled carousel region", () => { + render() + expect( + screen.getByRole("region", { name: /trending markets carousel/i }), + ).toBeInTheDocument() + }) + + it("the carousel region is keyboard-focusable", () => { + render() + expect( + screen.getByRole("region", { name: /trending markets carousel/i }), + ).toHaveAttribute("tabIndex", "0") + }) + + // ---- Keyboard navigation ------------------------------------------------ + + it("scrolls right on ArrowRight when content overflows", () => { + render() + const region = screen.getByRole("region", { + name: /trending markets carousel/i, + }) + mockOverflow(region) + + fireEvent.scroll(region) + fireEvent.keyDown(region, { key: "ArrowRight" }) + expect(region.scrollTo).toHaveBeenCalledWith( + expect.objectContaining({ behavior: "smooth" }), + ) + }) + + it("scrolls left on ArrowLeft when content overflows", () => { + render() + const region = screen.getByRole("region", { + name: /trending markets carousel/i, + }) + mockOverflow(region) + // Simulate we've scrolled to the right + Object.defineProperty(region, "scrollLeft", { + value: 1000, + writable: true, + configurable: true, + }) + + fireEvent.scroll(region) + fireEvent.keyDown(region, { key: "ArrowLeft" }) + expect(region.scrollTo).toHaveBeenCalledWith( + expect.objectContaining({ behavior: "smooth" }), + ) + }) + + // ---- Custom markets ----------------------------------------------------- + + it("accepts a custom market list", () => { + const custom = [ + { + id: "custom-1", + title: "Custom Market", + category: "Test", + volume: "$1k", + participants: 10, + odds: 2.0, + href: "/test", + }, + ] + render() + expect(screen.getByText("Custom Market")).toBeInTheDocument() + expect(screen.queryByText(TRENDING_MARKETS[0].title)).not.toBeInTheDocument() + }) +}) diff --git a/app/components/TrendingRail.tsx b/app/components/TrendingRail.tsx new file mode 100644 index 00000000..8b5bd673 --- /dev/null +++ b/app/components/TrendingRail.tsx @@ -0,0 +1,301 @@ +"use client" + +import { useRef, useState, useEffect } from "react" +import Link from "next/link" +import { ChevronLeft, ChevronRight, Flame, TrendingUp, Users } from "lucide-react" +import { cn } from "@/lib/utils" + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface TrendingMarket { + id: string + title: string + category: string + /** 24h volume, e.g. "$42.1k" */ + volume: string + participants: number + odds: number + /** Visual marker: hot/trending if true */ + isHot?: boolean + href: string +} + +// --------------------------------------------------------------------------- +// Default trending markets (curated for the marketing home page) +// --------------------------------------------------------------------------- + +export const TRENDING_MARKETS: TrendingMarket[] = [ + { + id: "trending-btc", + title: "Will BTC close above $100k this month?", + category: "Crypto", + volume: "$42.1k", + participants: 2310, + odds: 1.6, + isHot: true, + href: "/events", + }, + { + id: "trending-eth", + title: "ETH ETF approval before year end?", + category: "Crypto", + volume: "$28.7k", + participants: 980, + odds: 2.9, + isHot: true, + href: "/events", + }, + { + id: "trending-ucl", + title: "Champions League: Real Madrid to reach the final?", + category: "Football", + volume: "$35.2k", + participants: 1190, + odds: 2.1, + href: "/events", + }, + { + id: "trending-senate", + title: "US Senate majority after the midterms?", + category: "Politics", + volume: "$19.8k", + participants: 670, + odds: 2.4, + href: "/events", + }, + { + id: "trending-nvda", + title: "Will NVDA beat its Q2 earnings estimate?", + category: "Stocks", + volume: "$15.3k", + participants: 530, + odds: 1.9, + href: "/events", + }, + { + id: "trending-lakers", + title: "Will the Lakers make the playoffs?", + category: "Sports", + volume: "$22.4k", + participants: 720, + odds: 1.7, + isHot: true, + href: "/events", + }, + { + id: "trending-ai", + title: "Will a major AI safety bill pass this quarter?", + category: "Politics", + volume: "$18.4k", + participants: 340, + odds: 3.5, + href: "/events", + }, + { + id: "trending-oscar", + title: "Best Picture winner at next year's Oscars?", + category: "Entertainment", + volume: "$8.9k", + participants: 305, + odds: 4.1, + href: "/events", + }, +] + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +interface TrendingRailProps { + /** Override the default trending markets. */ + markets?: TrendingMarket[] + className?: string +} + +/** + * A horizontally-scrollable rail of trending prediction markets for the + * marketing home page. Highlights markets with high 24h volume and activity. + * + * Features: + * - Responsive horizontal carousel with snap scrolling + * - Keyboard-navigable (ArrowLeft / ArrowRight) + * - Hot markets get a flame badge + * - Accessible: labelled region, ARIA controls + */ +export function TrendingRail({ + markets = TRENDING_MARKETS, + className, +}: TrendingRailProps) { + const scrollContainerRef = useRef(null) + const [canScrollLeft, setCanScrollLeft] = useState(false) + const [canScrollRight, setCanScrollRight] = useState(true) + + const updateScrollState = () => { + const el = scrollContainerRef.current + if (!el) return + setCanScrollLeft(el.scrollLeft > 4) + setCanScrollRight( + el.scrollLeft < el.scrollWidth - el.clientWidth - 4, + ) + } + + useEffect(() => { + updateScrollState() + }, [markets]) + + const scroll = (direction: "left" | "right") => { + const el = scrollContainerRef.current + if (!el) return + const amount = el.clientWidth * 0.75 + el.scrollTo({ + left: + direction === "left" + ? el.scrollLeft - amount + : el.scrollLeft + amount, + behavior: "smooth", + }) + } + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === "ArrowLeft" && canScrollLeft) { + event.preventDefault() + scroll("left") + } else if (event.key === "ArrowRight" && canScrollRight) { + event.preventDefault() + scroll("right") + } + } + + if (markets.length === 0) return null + + return ( +
+
+ {/* Header */} +
+
+
+
+ +

+ Trending markets with the most action right now +

+
+
+ + {/* Carousel */} +
+ {/* Scroll buttons */} + {canScrollLeft && ( + + )} + {canScrollRight && ( + + )} + +
+ {markets.map((market) => ( + + {/* Top row: category + hot badge */} +
+ + {market.category} + + {market.isHot && ( + + + )} +
+ + {/* Title */} +

+ {market.title} +

+ + {/* Volume */} +
+
+ + {/* Bottom row: participants + odds */} +
+ + + + {market.odds.toFixed(1)}x + +
+ + ))} +
+
+
+
+ ) +} diff --git a/docs/TRENDING_RAIL.md b/docs/TRENDING_RAIL.md new file mode 100644 index 00000000..ab19817f --- /dev/null +++ b/docs/TRENDING_RAIL.md @@ -0,0 +1,97 @@ +# Trending Rail — "What's Happening Now" + +The `TrendingRail` component displays a horizontally-scrollable carousel of trending prediction markets on the marketing home page. It highlights markets with high 24-hour trading volume and active participation. + +## Quick Start + +```tsx +import { TrendingRail } from "@/app/components/TrendingRail" + +// Render on the marketing page + +``` + +## Default Markets + +8 curated markets across Crypto, Football, Politics, Stocks, Sports, and Entertainment. Markets with the highest 24h volume are flagged as "Hot" with a flame badge. + +## Features + +- **Horizontal carousel** — snap-scrolling cards with keyboard navigation (ArrowLeft / ArrowRight) +- **Hot market badges** — markets with `isHot: true` display a flame badge +- **Volume indicators** — each card shows 24h trading volume with a trend icon +- **Scroll buttons** — appear on hover for desktop users +- **Responsive** — cards are 280px on mobile, 300px on desktop +- **Accessible** — labelled carousel region, keyboard-focusable, ARIA controls +- **Empty state** — returns nothing when no markets are provided + +## API + +```tsx +interface TrendingRailProps { + /** Override the default trending markets. */ + markets?: TrendingMarket[] + className?: string +} + +interface TrendingMarket { + id: string + title: string + category: string + volume: string // e.g. "$42.1k" + participants: number + odds: number + isHot?: boolean // Shows flame badge + href: string +} +``` + +### Custom Markets + +```tsx + +``` + +## Placement + +The rail is rendered on the marketing home page (`app/(marketing)/page.tsx`) between the Hero and KpiStrip sections: + +```tsx + + + +``` + +## Testing + +11 tests cover: + +- Rendering (heading, all markets, links) +- Empty state (no markets → nothing rendered) +- Hot market badges +- Volume display +- Keyboard navigation (ArrowLeft / ArrowRight) +- Accessibility (labelled region, keyboard-focusable) +- Custom market lists + +```bash +pnpm test -- app/components/TrendingRail.test.tsx +``` + +## Dependencies + +- `lucide-react` — icons (Flame, TrendingUp, Users, ChevronLeft, ChevronRight) +- `@/lib/utils` — `cn` class utility