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
53 changes: 52 additions & 1 deletion app/(dashboard)/events/event-page/EventDetailsClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -222,6 +238,32 @@ export default function EventDetailsClient() {
: undefined;
const potentialPayout =
currentOdds && betAmount ? parseFloat(betAmount || "0") * currentOdds : 0;
const leadingOption = eventData.options.reduce<EventOption | null>(
(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 = (
<div className="space-y-6">
Expand Down Expand Up @@ -478,6 +520,15 @@ export default function EventDetailsClient() {
</Button>
}
/>
<CompareMarketsModal
markets={comparisonMarkets}
trigger={
<Button variant="outline" size="sm" className="gap-2" aria-label="Compare markets">
<GitCompareArrows className="h-4 w-4" />
Compare
</Button>
}
/>
</div>
</div>
<p className="text-muted-foreground">{eventData.description}</p>
Expand Down
214 changes: 214 additions & 0 deletions app/components/CompareMarketsModal.tsx
Original file line number Diff line number Diff line change
@@ -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<NonNullable<CompareMarket["status"]>, string> = {
open: "Open",
closing: "Closing soon",
closed: "Closed",
}

const statusClassName: Record<NonNullable<CompareMarket["status"]>, 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 (
<div className="flex items-start justify-between gap-4 rounded-md border bg-muted/30 px-3 py-2">
<dt className="flex min-w-0 items-center gap-2 text-sm text-muted-foreground">
{icon}
<span>{label}</span>
</dt>
<dd className="text-right text-sm font-medium text-foreground">{value}</dd>
</div>
)
}

function MarketPanel({ market, leader }: { market: CompareMarket; leader: boolean }) {
const status = market.status ?? "open"

return (
<article
aria-label={`${market.title} comparison details`}
className={cn(
"flex h-full flex-col gap-4 p-4",
leader && "bg-primary/[0.03]"
)}
>
<div className="space-y-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 space-y-2">
<h3 className="text-base font-semibold leading-snug text-foreground">
{market.title}
</h3>
<div className="flex flex-wrap gap-2">
<Badge variant="outline">{market.category}</Badge>
<Badge className={statusClassName[status]}>{statusLabel[status]}</Badge>
</div>
</div>
{leader && (
<Badge variant="secondary" className="shrink-0">
Higher pool
</Badge>
)}
</div>

<dl className="space-y-2">
<MetricRow
icon={<CircleDollarSign className="h-4 w-4" aria-hidden="true" />}
label="Liquidity"
value={formatCurrency(market.totalPool)}
/>
<MetricRow
icon={<Users className="h-4 w-4" aria-hidden="true" />}
label="Participants"
value={market.participants.toLocaleString()}
/>
<MetricRow
icon={<Trophy className="h-4 w-4" aria-hidden="true" />}
label="Top outcome"
value={
<span>
{market.topOutcome}{" "}
<span className="text-muted-foreground">
({market.topOdds.toFixed(1)}x)
</span>
</span>
}
/>
<MetricRow
icon={<CalendarClock className="h-4 w-4" aria-hidden="true" />}
label="Deadline"
value={formatDeadline(market.deadline)}
/>
</dl>
</div>

<div className="mt-auto rounded-md border border-dashed p-3">
<p className="text-xs font-medium uppercase text-muted-foreground">
Resolution criteria
</p>
<p className="mt-1 text-sm leading-6 text-foreground">
{market.resolutionCriteria}
</p>
</div>
</article>
)
}

export function CompareMarketsModal({
markets,
trigger,
className,
}: CompareMarketsModalProps) {
const comparedMarkets = markets.slice(0, 2)
const highestPool = Math.max(0, ...comparedMarkets.map((market) => market.totalPool))

return (
<Dialog>
<DialogTrigger asChild>
{trigger ?? (
<Button variant="outline" className={cn("gap-2", className)}>
<GitCompareArrows className="h-4 w-4" aria-hidden="true" />
Compare markets
</Button>
)}
</DialogTrigger>
<DialogContent className="max-h-[90vh] w-[calc(100vw-2rem)] max-w-4xl overflow-y-auto">
<DialogHeader>
<DialogTitle>Compare markets</DialogTitle>
<DialogDescription>
Review two markets side by side before deciding where to predict.
</DialogDescription>
</DialogHeader>

{comparedMarkets.length < 2 ? (
<div className="rounded-md border border-dashed p-6 text-sm text-muted-foreground">
Select two markets to compare liquidity, odds, participation, and
resolution criteria.
</div>
) : (
<div className="grid overflow-hidden rounded-md border sm:grid-cols-2 sm:divide-x">
{comparedMarkets.map((market) => (
<MarketPanel
key={market.id}
market={market}
leader={market.totalPool === highestPool}
/>
))}
</div>
)}
</DialogContent>
</Dialog>
)
}
83 changes: 83 additions & 0 deletions app/components/__tests__/CompareMarketsModal.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<CompareMarketsModal markets={markets} />)

expect(
screen.getByRole("button", { name: /compare markets/i })
).toBeInTheDocument()
})

it("opens an accessible dialog with both market panels", () => {
render(<CompareMarketsModal markets={markets} />)

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(
<CompareMarketsModal
markets={markets}
trigger={<button type="button">Open comparison</button>}
/>
)

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(<CompareMarketsModal markets={[markets[0]]} />)

fireEvent.click(screen.getByRole("button", { name: /compare markets/i }))

expect(screen.getByText(/select two markets to compare/i)).toBeInTheDocument()
})
})
4 changes: 2 additions & 2 deletions components/ui/dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ const DialogContentWithFocusReturn = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, onOpenAutoFocus, ...props }, ref) => {
const triggerRef = useRef<HTMLElement | null>(null);
const triggerRef = React.useRef<HTMLElement | null>(null);

// Store the trigger element when the dialog opens
const handleOpenAutoFocus = (event: Event) => {
Expand Down Expand Up @@ -197,4 +197,4 @@ export {
DialogFooter,
DialogTitle,
DialogDescription,
};
};
Loading