From 2d6f1befa7d1c1a2be8d0c223437b039bc8e14a8 Mon Sep 17 00:00:00 2001 From: pq198363-ops <246611021+pq198363-ops@users.noreply.github.com> Date: Sat, 4 Jul 2026 00:02:28 +0800 Subject: [PATCH] feat: add compare markets modal --- .../events/event-page/EventDetailsClient.tsx | 53 ++++- app/components/CompareMarketsModal.tsx | 214 ++++++++++++++++++ .../__tests__/CompareMarketsModal.test.tsx | 83 +++++++ components/ui/dialog.tsx | 4 +- docs/compare-markets-modal.md | 27 +++ 5 files changed, 378 insertions(+), 3 deletions(-) create mode 100644 app/components/CompareMarketsModal.tsx create mode 100644 app/components/__tests__/CompareMarketsModal.test.tsx create mode 100644 docs/compare-markets-modal.md diff --git a/app/(dashboard)/events/event-page/EventDetailsClient.tsx b/app/(dashboard)/events/event-page/EventDetailsClient.tsx index 841063d6..658cb4ff 100644 --- a/app/(dashboard)/events/event-page/EventDetailsClient.tsx +++ b/app/(dashboard)/events/event-page/EventDetailsClient.tsx @@ -19,10 +19,12 @@ import { } from "@/components/ui/card"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { Separator } from "@/components/ui/separator"; -import { Clock, DollarSign, Users, BarChart2, Loader2, Share2 } from "lucide-react"; +import { Clock, DollarSign, Users, BarChart2, Loader2, Share2, GitCompareArrows } from "lucide-react"; import { formatDistanceToNowStrict, parseISO, isValid } from "date-fns"; import { MarketDetailTabs } from "@/components/market/MarketDetailTabs"; +import { ResolutionPreview } from "@/components/market/ResolutionPreview"; import { ShareSheet } from "@/app/components/ShareSheet"; +import { CompareMarketsModal, type CompareMarket } from "@/app/components/CompareMarketsModal"; import { useMediaQuery } from "@/hooks/use-media-query"; import { Drawer, @@ -85,6 +87,20 @@ const initialMockEventData: EventData = { ], }; +const comparisonMarket: CompareMarket = { + id: "comparison-afc-championship", + title: "AFC Championship Winner", + category: "Sports", + deadline: "2025-01-26T23:00:00Z", + totalPool: 9800, + participants: 842, + topOutcome: "Baltimore Ravens", + topOdds: 3.1, + resolutionCriteria: + "Resolves to the team officially declared winner of the AFC Championship game.", + status: "closing", +}; + const calculateTimeLeft = (deadlineISO: string | undefined): string => { if (!deadlineISO) return "No deadline set"; try { @@ -222,6 +238,32 @@ export default function EventDetailsClient() { : undefined; const potentialPayout = currentOdds && betAmount ? parseFloat(betAmount || "0") * currentOdds : 0; + const leadingOption = eventData.options.reduce( + (best, option) => { + if (!best) return option; + + return (eventData.odds[option.id] ?? Number.POSITIVE_INFINITY) < + (eventData.odds[best.id] ?? Number.POSITIVE_INFINITY) + ? option + : best; + }, + null + ); + const comparisonMarkets: CompareMarket[] = [ + { + id: eventData.id, + title: eventData.title, + category: eventData.category, + deadline: eventData.deadline, + totalPool: eventData.totalPool, + participants: eventData.participants, + topOutcome: leadingOption?.text ?? "No outcome available", + topOdds: leadingOption ? eventData.odds[leadingOption.id] ?? 0 : 0, + resolutionCriteria: eventData.description, + status: isEventClosed ? "closed" : "open", + }, + comparisonMarket, + ]; const overviewTab = (
@@ -478,6 +520,15 @@ export default function EventDetailsClient() { } /> + + + Compare + + } + />

{eventData.description}

diff --git a/app/components/CompareMarketsModal.tsx b/app/components/CompareMarketsModal.tsx new file mode 100644 index 00000000..d8bfa8e8 --- /dev/null +++ b/app/components/CompareMarketsModal.tsx @@ -0,0 +1,214 @@ +"use client" + +import * as React from "react" +import { + CalendarClock, + CircleDollarSign, + GitCompareArrows, + Trophy, + Users, +} from "lucide-react" + +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { cn } from "@/lib/utils" + +export interface CompareMarket { + id: string + title: string + category: string + deadline: string + totalPool: number + participants: number + topOutcome: string + topOdds: number + resolutionCriteria: string + status?: "open" | "closing" | "closed" +} + +export interface CompareMarketsModalProps { + markets: CompareMarket[] + trigger?: React.ReactNode + className?: string +} + +const statusLabel: Record, string> = { + open: "Open", + closing: "Closing soon", + closed: "Closed", +} + +const statusClassName: Record, string> = { + open: "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300", + closing: "border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300", + closed: "border-slate-500/30 bg-slate-500/10 text-slate-700 dark:text-slate-300", +} + +function formatCurrency(value: number) { + return value.toLocaleString(undefined, { + style: "currency", + currency: "USD", + maximumFractionDigits: 0, + }) +} + +function formatDeadline(value: string) { + const date = new Date(value) + + if (Number.isNaN(date.getTime())) { + return value + } + + return date.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + year: "numeric", + }) +} + +function MetricRow({ + icon, + label, + value, +}: { + icon: React.ReactNode + label: string + value: React.ReactNode +}) { + return ( +
+
+ {icon} + {label} +
+
{value}
+
+ ) +} + +function MarketPanel({ market, leader }: { market: CompareMarket; leader: boolean }) { + const status = market.status ?? "open" + + return ( +
+
+
+
+

+ {market.title} +

+
+ {market.category} + {statusLabel[status]} +
+
+ {leader && ( + + Higher pool + + )} +
+ +
+
+
+ +
+

+ Resolution criteria +

+

+ {market.resolutionCriteria} +

+
+
+ ) +} + +export function CompareMarketsModal({ + markets, + trigger, + className, +}: CompareMarketsModalProps) { + const comparedMarkets = markets.slice(0, 2) + const highestPool = Math.max(0, ...comparedMarkets.map((market) => market.totalPool)) + + return ( + + + {trigger ?? ( + + )} + + + + Compare markets + + Review two markets side by side before deciding where to predict. + + + + {comparedMarkets.length < 2 ? ( +
+ Select two markets to compare liquidity, odds, participation, and + resolution criteria. +
+ ) : ( +
+ {comparedMarkets.map((market) => ( + + ))} +
+ )} +
+
+ ) +} diff --git a/app/components/__tests__/CompareMarketsModal.test.tsx b/app/components/__tests__/CompareMarketsModal.test.tsx new file mode 100644 index 00000000..56d86e6d --- /dev/null +++ b/app/components/__tests__/CompareMarketsModal.test.tsx @@ -0,0 +1,83 @@ +import React from "react" +import { fireEvent, render, screen } from "@testing-library/react" + +import { CompareMarketsModal, type CompareMarket } from "../CompareMarketsModal" + +const markets: CompareMarket[] = [ + { + id: "market-1", + title: "Super Bowl Winner 2025", + category: "Sports", + deadline: "2025-06-09T12:00:00Z", + totalPool: 15780.5, + participants: 1245, + topOutcome: "Kansas City Chiefs", + topOdds: 2.5, + resolutionCriteria: + "Resolves to the team officially declared winner of Super Bowl LIX.", + status: "open", + }, + { + id: "market-2", + title: "AFC Championship Winner", + category: "Sports", + deadline: "2025-01-26T23:00:00Z", + totalPool: 9800, + participants: 842, + topOutcome: "Baltimore Ravens", + topOdds: 3.1, + resolutionCriteria: + "Resolves to the team officially declared winner of the AFC Championship game.", + status: "closing", + }, +] + +describe("CompareMarketsModal", () => { + it("renders the default compare trigger", () => { + render() + + expect( + screen.getByRole("button", { name: /compare markets/i }) + ).toBeInTheDocument() + }) + + it("opens an accessible dialog with both market panels", () => { + render() + + fireEvent.click(screen.getByRole("button", { name: /compare markets/i })) + + expect(screen.getByRole("dialog")).toBeInTheDocument() + expect( + screen.getByText(/review two markets side by side/i) + ).toBeInTheDocument() + expect(screen.getByText("Super Bowl Winner 2025")).toBeInTheDocument() + expect(screen.getByText("AFC Championship Winner")).toBeInTheDocument() + expect( + screen.getByText((content) => content.includes("15,781")) + ).toBeInTheDocument() + expect(screen.getByText("1,245")).toBeInTheDocument() + expect(screen.getByText(/Kansas City Chiefs/i)).toBeInTheDocument() + }) + + it("renders a custom trigger when provided", () => { + render( + Open comparison} + /> + ) + + fireEvent.click(screen.getByRole("button", { name: /open comparison/i })) + + expect(screen.getByRole("dialog")).toBeInTheDocument() + expect(screen.getByText("Compare markets")).toBeInTheDocument() + }) + + it("shows an empty state when fewer than two markets are supplied", () => { + render() + + fireEvent.click(screen.getByRole("button", { name: /compare markets/i })) + + expect(screen.getByText(/select two markets to compare/i)).toBeInTheDocument() + }) +}) diff --git a/components/ui/dialog.tsx b/components/ui/dialog.tsx index 1fe5c840..80142be9 100644 --- a/components/ui/dialog.tsx +++ b/components/ui/dialog.tsx @@ -136,7 +136,7 @@ const DialogContentWithFocusReturn = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, children, onOpenAutoFocus, ...props }, ref) => { - const triggerRef = useRef(null); + const triggerRef = React.useRef(null); // Store the trigger element when the dialog opens const handleOpenAutoFocus = (event: Event) => { @@ -197,4 +197,4 @@ export { DialogFooter, DialogTitle, DialogDescription, -}; \ No newline at end of file +}; diff --git a/docs/compare-markets-modal.md b/docs/compare-markets-modal.md new file mode 100644 index 00000000..c836398b --- /dev/null +++ b/docs/compare-markets-modal.md @@ -0,0 +1,27 @@ +# Compare Markets Modal + +Issue: `#368` + +## Visible Change + +The event detail header now includes a `Compare` action that opens an accessible +side-by-side market comparison dialog. The modal compares two markets across: + +- liquidity +- participant count +- top outcome and odds +- deadline +- resolution criteria + +## Accessibility + +- Uses the shared Radix dialog wrapper for focus trapping and Escape handling. +- Provides a dialog title and description for screen readers. +- Uses semantic `article`, `dl`, `dt`, and `dd` markup for comparison data. +- Maintains responsive stacked layout on narrow viewports and two columns on + wider screens. + +## Validation + +Focused tests cover the default trigger, custom trigger, populated comparison +state, and one-market empty state.