Prediction Marketsv0.3.0
Resolution & Rules Card
A status timeline, rules and a dispute countdown.
Installation
CLI
npx shadcn@latest add https://hantui-theta.vercel.app/r/resolution-card.jsonManual
Install the dependencies
npm install clsx lucide-react motion tailwind-mergeAdd the shared helpers
Components use
cn()fromlib/utils.tsand the motion tokens below. See Installation if you have not set them up yet.lib/markets/format.tsimport { FULL } from "@/lib/markets/math"; const usd = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }); // minimumFractionDigits: 0 matters: currency defaults to 2, and Node and browsers clamp it differently ("$412.0K" vs "$412K"). const usdCompact = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", notation: "compact", minimumFractionDigits: 0, maximumFractionDigits: 1 }); /** "64%" (or "64.2%" with decimals). */ export const formatProbability = (bps: number, decimals = 0) => `${(bps / 100).toFixed(decimals)}%`; /** "64¢". */ export const formatPrice = (bps: number) => `${Math.round(bps / 100)}¢`; /** "$1,234.56", or "$1.2M" compact. Input in cents. */ export const formatUsd = (cents: number, compact = false) => (compact ? usdCompact.format(cents / 100) : usd.format(cents / 100)); /** Shares from hundredths: "156.25". */ export const formatShares = (hundredths: number) => (hundredths / 100).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); /** "▲ 4%" / "▼ 2.5%" from a basis-point change (never color alone: the arrow carries the direction). */ export function formatChange(bps: number) { const value = Math.abs(bps) / 100; return `${bps >= 0 ? "▲" : "▼"} ${value % 1 === 0 ? value.toFixed(0) : value.toFixed(1)}%`; } /** Signed percent from basis points: "+56%". */ export const formatReturn = (bps: number) => `${bps >= 0 ? "+" : "−"}${(Math.abs(bps) / 100).toFixed(bps % 100 === 0 ? 0 : 1)}%`; /** "Ends in 3d 4h", "Ends in 42m" or "Ended". */ export function timeLeft(endsAt: number, now: number) { const ms = endsAt - now; if (ms <= 0) return "Ended"; const minutes = Math.floor(ms / 60000); const days = Math.floor(minutes / 1440); const hours = Math.floor((minutes % 1440) / 60); if (days > 0) return `Ends in ${days}d ${hours}h`; if (hours > 0) return `Ends in ${hours}h ${minutes % 60}m`; return `Ends in ${Math.max(1, minutes)}m`; } /** A fixed, locale-stable date: "Mar 14, 2027". */ export const formatDate = (ms: number) => new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" }).format(ms); export { FULL };lib/markets/math.ts/** * Prediction market math on integers only: prices in basis points (10000 = $1.00 = 100%), money in * cents and shares in hundredths. A share pays $1 (100 cents) if its outcome wins. */ export const FULL = 10000; const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)); /** The other side of a binary price: Yes + No = 100¢. */ export const complement = (price: number) => FULL - price; /** Shares (in hundredths) that `amount` cents buys at `price` basis points. */ export const sharesFor = (amount: number, price: number) => (price > 0 ? Math.floor((amount * FULL) / price) : 0); /** Payout in cents if those shares win: each share pays 100 cents, so hundredths of a share map 1:1 to cents. */ export const payoutFor = (shares: number) => shares; /** Value in cents of shares (hundredths) at a price. */ export const valueOf = (shares: number, price: number) => Math.floor((shares * price) / FULL); export interface BuyQuote { /** Average fill price, basis points. */ averagePrice: number; /** Price after the trade, basis points. */ newPrice: number; /** Price impact, basis points (newPrice − price). */ impact: number; /** Shares received, hundredths. */ shares: number; /** Payout if it wins, cents. */ payout: number; /** Return if it wins, basis points of the amount (5600 = +56%). */ returnBps: number; } /** * A simulated buy against a constant-product style curve: the more of the pool you take, the further * the price moves toward $1. Good enough to show impact and average price in a demo. */ export function quoteBuy(price: number, amount: number, liquidity: number): BuyQuote { const p = clamp(Math.round(price), 1, FULL - 1); if (amount <= 0) return { averagePrice: p, newPrice: p, impact: 0, shares: 0, payout: 0, returnBps: 0 }; const take = Math.floor((amount * FULL) / (amount + Math.max(1, liquidity))); const newPrice = clamp(p + Math.floor(((FULL - p) * take) / FULL), 1, FULL - 1); const averagePrice = Math.floor((p + newPrice) / 2); const shares = sharesFor(amount, averagePrice); const payout = payoutFor(shares); return { averagePrice, newPrice, impact: newPrice - p, shares, payout, returnBps: Math.floor(((payout - amount) * FULL) / amount) }; } /** A simulated limit buy: fills entirely at the limit price, no impact. */ export function quoteLimit(limitPrice: number, amount: number): BuyQuote { const p = clamp(Math.round(limitPrice), 1, FULL - 1); const shares = sharesFor(Math.max(0, amount), p); return { averagePrice: p, newPrice: p, impact: 0, shares, payout: shares, returnBps: amount > 0 ? Math.floor(((shares - amount) * FULL) / amount) : 0 }; } /** Proceeds in cents from selling shares (hundredths), with the price sliding toward 0 by the same curve. */ export function quoteSell(price: number, shares: number, liquidity: number) { const p = clamp(Math.round(price), 1, FULL - 1); const gross = valueOf(shares, p); const take = Math.floor((gross * FULL) / (gross + Math.max(1, liquidity))); const newPrice = clamp(p - Math.floor((p * take) / FULL), 1, FULL - 1); const averagePrice = Math.floor((p + newPrice) / 2); return { proceeds: valueOf(shares, averagePrice), averagePrice, newPrice, impact: newPrice - p }; } /** Profit and loss in cents for shares bought at an average price, marked at the current price. */ export function profitAndLoss(shares: number, averagePrice: number, currentPrice: number) { const cost = valueOf(shares, averagePrice); const value = valueOf(shares, currentPrice); return { cost, value, pnl: value - cost, pnlBps: cost ? Math.floor(((value - cost) * FULL) / cost) : 0 }; } /** Scales positive weights so they sum to exactly 10000 (rounding error goes to the largest). */ export function normalize(prices: number[]) { const total = prices.reduce((sum, p) => sum + Math.max(0, p), 0) || 1; const scaled = prices.map((p) => Math.floor((Math.max(0, p) * FULL) / total)); const rest = FULL - scaled.reduce((sum, p) => sum + p, 0); const largest = scaled.indexOf(Math.max(...scaled)); if (largest >= 0) scaled[largest] += rest; return scaled; }lib/markets/types.ts/** Prices and probabilities are integer basis points (0–10000: 6400 = 64% = 64¢), money is integer cents. */ export type MarketStatus = "open" | "closing" | "resolved"; export interface MarketOutcome { id: string; name: string; /** Price in basis points (0–10000). Binary markets: Yes + No always sum to 10000. */ price: number; /** 24h change in basis points (positive = up). */ change24h?: number; /** Avatar or icon image for the outcome. */ image?: string; /** Volume traded on this outcome, in cents. */ volume?: number; } export interface PricePoint { /** Timestamp, ms. */ t: number; /** Price per outcome, in basis points, same order as outcomes. */ prices: number[]; } export interface Market { id: string; question: string; /** Topic label, e.g. "Tech". */ topic: string; /** Market image URL; a generated icon is used without one. */ image?: string; type: "binary" | "multi"; /** Binary markets: [Yes, No]. Multi-outcome: probabilities normalized to 10000. */ outcomes: MarketOutcome[]; /** Total volume, cents. */ volume: number; /** Simulated liquidity used for price impact, cents. */ liquidity: number; traders: number; /** Names of a few traders, for the avatar group. */ traderNames?: string[]; /** End time, ms since epoch. */ endsAt: number; status: MarketStatus; /** Winning outcome once resolved. */ resolvedOutcomeId?: string; comments?: number; history?: PricePoint[]; rules?: string; resolutionSource?: { label: string; href: string }; } export type TradeSide = "buy" | "sell";lib/markets/use-now.tsimport { useSyncExternalStore } from "react"; let now = 0; const listeners = new Set<() => void>(); let timer = 0; function subscribe(listener: () => void) { listeners.add(listener); // Refresh on every subscribe, so a component that mounts while the timer is already running // does not start with a timestamp up to 15 s old. now = Date.now(); if (!timer) { timer = window.setInterval(() => { now = Date.now(); listeners.forEach((l) => l()); }, 15000); } return () => { listeners.delete(listener); if (!listeners.size) { window.clearInterval(timer); timer = 0; } }; } /** * The current time, refreshed every 15 s. It is 0 on the server and during hydration, so countdowns * render a placeholder first and never mismatch. */ export function useNow() { return useSyncExternalStore( subscribe, () => now || Date.now(), () => 0, ); } const noop = () => () => {}; /** * False on the server and during hydration, true after. Gate text derived from timestamps (axis * labels, dates) with it when the data may be generated relative to page load. */ export function useHydrated() { return useSyncExternalStore( noop, () => true, () => false, ); }Add the theme tokens
This component uses --success and --success-foreground, which shadcn does not define by default. Add them to your global CSS:
app/globals.css:root { --success: #15803d; --success-foreground: #ffffff; } .dark { --success: #4ade80; --success-foreground: #052e16; } @theme inline { --color-success: var(--success); --color-success-foreground: var(--success-foreground); }Copy the source
components/ui/resolution-card/resolution-card.tsx"use client"; import { useEffect, useState } from "react"; import { motion, useReducedMotion } from "motion/react"; import { ChevronDown, ExternalLink } from "lucide-react"; import { cn } from "@/lib/utils"; import { RollingDigits } from "@/components/ui/rolling-digits/rolling-digits"; import { formatDate } from "@/lib/markets/format"; import type { Market } from "@/lib/markets/types"; import { useNow } from "@/lib/markets/use-now"; export type ResolutionStage = "open" | "closed" | "resolving" | "resolved"; export interface ResolutionCardProps { /** The market (rules, resolution source, end time and the winning outcome once resolved). */ market: Market; /** Where the market is in its lifecycle. @default "open" */ stage?: ResolutionStage; /** When the dispute window closes, ms since epoch (shown while resolving). */ disputeEndsAt?: number; /** Expected resolution time, ms since epoch. @default the end time plus a day */ resolvesAt?: number; /** Classes for the card. */ className?: string; } const STEPS: { id: ResolutionStage; label: string }[] = [ { id: "open", label: "Open" }, { id: "closed", label: "Trading closed" }, { id: "resolving", label: "Resolving" }, { id: "resolved", label: "Resolved" }, ]; /** * Status timeline (Open → Trading closed → Resolving → Resolved), expandable rules, the resolution * source and dates, a dispute countdown, and an outcome stamp with a glowing border on resolve. */ export function ResolutionCard({ market, stage = "open", disputeEndsAt, resolvesAt, className }: ResolutionCardProps) { const reduceMotion = useReducedMotion(); const now = useNow(); const [rulesOpen, setRulesOpen] = useState(false); const [glow, setGlow] = useState(false); const current = STEPS.findIndex((s) => s.id === stage); const winner = market.outcomes.find((o) => o.id === market.resolvedOutcomeId); // A brief glow when the card reaches "resolved" (state set from timers, not during render). useEffect(() => { if (stage !== "resolved" || reduceMotion) return; const on = window.setTimeout(() => setGlow(true), 0); const off = window.setTimeout(() => setGlow(false), 1600); return () => { window.clearTimeout(on); window.clearTimeout(off); }; }, [stage, reduceMotion]); const remaining = disputeEndsAt && now ? Math.max(0, disputeEndsAt - now) : 0; const days = Math.floor(remaining / 86400_000); const hours = Math.floor((remaining % 86400_000) / 3600_000); const minutes = Math.floor((remaining % 3600_000) / 60_000); return ( <section aria-label="Resolution and rules" className={cn("relative w-full rounded-2xl border bg-background p-4 transition-[border-color,box-shadow] duration-700", glow ? "border-success shadow-[0_0_0_4px_color-mix(in_oklab,var(--success)_20%,transparent)]" : "border-border", className)}> <p className="text-sm font-medium text-foreground">Resolution</p> <ol className="mt-3 grid grid-cols-4 gap-1"> {STEPS.map((step, i) => { const done = i < current || (stage === "resolved" && i === current); const active = i === current && stage !== "resolved"; return ( <li key={step.id} aria-current={i === current ? "step" : undefined} className="flex flex-col items-center gap-1.5 text-center"> <span className="relative flex w-full items-center"> <span className={cn("h-0.5 flex-1", i === 0 ? "opacity-0" : i <= current ? "bg-success" : "bg-border")} /> <span className={cn("relative grid size-6 shrink-0 place-items-center rounded-full border-2", done ? "border-success bg-success text-success-foreground" : active ? "border-primary bg-background" : "border-border bg-background")}> {active ? <span className="absolute inset-0 animate-ping rounded-full border-2 border-primary opacity-60 motion-reduce:animate-none" /> : null} {done ? ( <svg viewBox="0 0 24 24" className="size-3.5" fill="none" stroke="currentColor" strokeWidth="3.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"> <motion.path d="M5 12.5l4.5 4.5L19 7.5" initial={reduceMotion ? false : { pathLength: 0 }} animate={{ pathLength: 1 }} transition={{ duration: 0.4, delay: i * 0.08 }} /> </svg> ) : ( <span className={cn("size-1.5 rounded-full", active ? "bg-primary" : "bg-border")} /> )} </span> <span className={cn("h-0.5 flex-1", i === STEPS.length - 1 ? "opacity-0" : i < current ? "bg-success" : "bg-border")} /> </span> <span className={cn("text-[11px] leading-tight", i <= current ? "font-medium text-foreground" : "text-muted-foreground")}>{step.label}</span> </li> ); })} </ol> {stage === "resolved" ? ( <div className="mt-4 flex items-center justify-between rounded-xl bg-success/8 px-3 py-2.5"> <p className="text-[13px] text-foreground">Final outcome</p> <motion.span initial={reduceMotion ? false : { scale: 1.7, rotate: -16, opacity: 0 }} animate={{ scale: 1, rotate: -6, opacity: 1 }} transition={{ type: "spring", stiffness: 380, damping: 17, delay: 0.2 }} className="rounded-md border-2 border-success px-2 py-0.5 text-[12px] font-bold tracking-wide text-[color-mix(in_oklab,var(--success)_75%,var(--foreground))] uppercase" > Resolved: {winner?.name ?? "—"} </motion.span> </div> ) : null} {stage === "resolving" && disputeEndsAt ? ( <div className="mt-4 rounded-xl border border-border bg-surface px-3 py-2.5"> <p className="text-[12px] text-muted-foreground">Dispute window closes in</p> <p className="mt-0.5 flex items-baseline gap-1 text-lg font-semibold text-foreground tabular-nums" aria-label={now ? `${days} ${days === 1 ? "day" : "days"}, ${hours} ${hours === 1 ? "hour" : "hours"}, ${minutes} ${minutes === 1 ? "minute" : "minutes"}` : undefined}> {now ? ( <> <RollingDigits value={days} decimals={0} /> <span className="text-[12px] font-normal text-muted-foreground">d</span> <RollingDigits value={hours} decimals={0} /> <span className="text-[12px] font-normal text-muted-foreground">h</span> <RollingDigits value={minutes} decimals={0} /> <span className="text-[12px] font-normal text-muted-foreground">m</span> </> ) : ( " " )} </p> </div> ) : null} <dl className="mt-4 grid grid-cols-2 gap-3 text-[12px]"> <div> <dt className="text-muted-foreground">Trading ends</dt> <dd className="font-medium text-foreground tabular-nums">{now ? formatDate(market.endsAt) : "\u00a0"}</dd> </div> <div> <dt className="text-muted-foreground">Resolves</dt> <dd className="font-medium text-foreground tabular-nums">{now ? formatDate(resolvesAt ?? market.endsAt + 86400_000) : "\u00a0"}</dd> </div> </dl> {market.rules ? ( <div className="mt-4 border-t border-border pt-3"> <button type="button" aria-expanded={rulesOpen} onClick={() => setRulesOpen((o) => !o)} className="flex w-full cursor-pointer items-center justify-between rounded-md text-sm font-medium text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring"> Rules <ChevronDown className={cn("size-4 text-muted-foreground transition-transform", rulesOpen && "rotate-180")} aria-hidden="true" /> </button> <div className={cn("grid transition-[grid-template-rows] duration-300 ease-out motion-reduce:transition-none", rulesOpen ? "grid-rows-[1fr]" : "grid-rows-[0fr]")}> <div className="overflow-hidden" inert={!rulesOpen}> <p className="pt-2 text-[13px] leading-6 text-muted-foreground">{market.rules}</p> {market.resolutionSource ? ( <a href={market.resolutionSource.href} className="mt-2 inline-flex items-center gap-1 rounded-sm text-[13px] font-medium text-foreground underline-offset-4 outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring"> {market.resolutionSource.label} <ExternalLink className="size-3.5" aria-hidden="true" /> </a> ) : null} </div> </div> </div> ) : null} </section> ); }
Usage
import { ResolutionCard } from "@/components/ui/resolution-card/resolution-card";
<ResolutionCard
market={market} // rules, resolutionSource, endsAt, resolvedOutcomeId
stage="resolving"
disputeEndsAt={Date.UTC(2026, 10, 6, 18)}
/>API reference
| Prop | Type | Default | Description |
|---|---|---|---|
market* | Market | – | The market (rules, resolution source, end time and the winning outcome once resolved). |
stage | ResolutionStage | "open" | Where the market is in its lifecycle. |
disputeEndsAt | number | – | When the dispute window closes, ms since epoch (shown while resolving). |
resolvesAt | number | the end time plus a day | Expected resolution time, ms since epoch. |
className | string | – | Classes for the card. |
- Stages: open → closed → resolving → resolved. Moving to resolved draws the remaining checks, stamps the winning outcome and briefly glows the border.
- Dates use a fixed en-US format in UTC and render after hydration, as does the countdown (from a shared 15-second clock).
Accessibility
- The timeline is an ordered list; the current step has aria-current="step" and every step is labelled in text.
- The Rules toggle reports aria-expanded, and the collapsed content is inert so it is skipped by Tab.
- The dispute countdown has a full spoken label ("2 days, 4 hours, 10 minutes"); the final outcome is plain text inside the stamp.
- With reduced motion the pulse, drawn checks, stamp and border glow are skipped.