Prediction Marketsv0.3.0
Positions Panel
Open, resolved and past positions with P&L and claims.
Installation
CLI
npx shadcn@latest add https://hantui-theta.vercel.app/r/positions-panel.jsonManual
Install the dependencies
npm install clsx 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/web3/rolling-value.tsx"use client"; import { useEffect, useState } from "react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { cn } from "@/lib/utils"; const DIGITS = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]; const REEL = { type: "spring", stiffness: 160, damping: 22, mass: 0.9 } as const; const MASK = "linear-gradient(to bottom, transparent, #000 22%, #000 78%, transparent)"; function Reel({ digit, reduceMotion }: { digit: number; reduceMotion: boolean }) { return ( <span className="relative inline-block [clip-path:inset(0)]" style={{ maskImage: MASK, WebkitMaskImage: MASK }}> <span className="invisible">0</span> <motion.span className="absolute inset-x-0 top-0 flex flex-col items-center" initial={false} animate={{ y: `${-digit * 10}%` }} transition={reduceMotion ? { duration: 0 } : REEL} > {DIGITS.map((n) => ( <span key={n}>{n}</span> ))} </motion.span> </span> ); } export interface RollingValueProps { /** Pre-formatted text, e.g. "$1,234.56" or "2.4815". Digits roll; everything else stays put. */ value: string; /** * The raw value behind the text (a bigint amount or a number). When it changes, the text * flashes green for an increase and red for a decrease. */ trend?: bigint | number; /** Flash on increases and decreases. @default true */ flash?: boolean; className?: string; } type Flash = { direction: "up" | "down"; key: number }; /** * Rolling digits for pre-formatted strings, so bigint amounts animate without ever becoming * floats. Uses tabular figures; screen readers get the plain text. */ export function RollingValue({ value, trend, flash = true, className }: RollingValueProps) { const reduceMotion = useReducedMotion() ?? false; const [previous, setPrevious] = useState(trend); const [pulse, setPulse] = useState<Flash | null>(null); if (trend !== previous) { setPrevious(trend); if (flash && trend !== undefined && previous !== undefined) { setPulse({ direction: trend > previous ? "up" : "down", key: (pulse?.key ?? 0) + 1 }); } } useEffect(() => { if (!pulse) return; const timer = window.setTimeout(() => setPulse(null), 900); return () => window.clearTimeout(timer); }, [pulse]); // Key characters by their distance from the end, so a given place value keeps its reel. const chars = [...value]; return ( <span className={cn( "relative inline-flex tabular-nums transition-colors duration-300", pulse?.direction === "up" && "text-[color-mix(in_oklab,var(--success)_75%,var(--foreground))]", pulse?.direction === "down" && "text-[color-mix(in_oklab,var(--destructive)_80%,var(--foreground))]", className, )} > <span className="sr-only">{value}</span> <AnimatePresence> {pulse && !reduceMotion ? ( <motion.span key={pulse.key} aria-hidden="true" className={cn( "pointer-events-none absolute -inset-x-1 inset-y-0 rounded-[4px]", pulse.direction === "up" ? "bg-success/12" : "bg-destructive/12", )} initial={{ opacity: 1 }} animate={{ opacity: 0 }} exit={{ opacity: 0 }} transition={{ duration: 0.9, ease: "easeOut" }} /> ) : null} </AnimatePresence> <span aria-hidden="true" className="relative inline-flex select-none"> {chars.map((char, index) => { const key = chars.length - index; return /\d/.test(char) ? <Reel key={key} digit={Number(char)} reduceMotion={reduceMotion} /> : <span key={`${key}${char}`}>{char}</span>; })} </span> </span> ); }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/positions-panel/positions-panel.tsx"use client"; import { useId, useState } from "react"; import { AnimatePresence, LayoutGroup, motion, useReducedMotion } from "motion/react"; import { cn } from "@/lib/utils"; import { formatPrice, formatShares, formatUsd } from "@/lib/markets/format"; import { profitAndLoss, valueOf } from "@/lib/markets/math"; import type { Market } from "@/lib/markets/types"; import { RollingValue } from "@/lib/web3/rolling-value"; export interface MarketPosition { id: string; market: Market; outcomeId: string; /** Shares held, in hundredths. */ shares: number; /** Average buy price, basis points. */ averagePrice: number; status: "open" | "won" | "lost" | "closed"; /** Realized P&L for closed positions, cents. */ realized?: number; } export interface PositionsPanelProps { /** Positions with their markets. Simulated data only. */ positions: MarketPosition[]; /** Cash balance included in the portfolio total, cents. @default 0 */ cash?: number; /** Portfolio value over time for the header sparkline, cents. */ valueHistory?: number[]; /** Fires when Sell is pressed on an open position. */ onSell?: (position: MarketPosition) => void; /** Fires when a winning position is claimed. */ onClaim?: (position: MarketPosition) => void; /** Fires from the empty state's call to action. */ onExplore?: () => void; /** Classes for the panel. */ className?: string; } type Tab = "open" | "resolved" | "history"; /** Arrow keys, Home and End move between tabs (roving tabindex). */ function onTabKey(event: React.KeyboardEvent<HTMLDivElement>) { if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return; const tabs = Array.from(event.currentTarget.querySelectorAll<HTMLButtonElement>('[role="tab"]')); const i = tabs.findIndex((t) => t === document.activeElement); const next = event.key === "Home" ? 0 : event.key === "End" ? tabs.length - 1 : (i + (event.key === "ArrowRight" ? 1 : -1) + tabs.length) % tabs.length; event.preventDefault(); tabs[next]?.focus(); tabs[next]?.click(); } function Spark({ values }: { values: number[] }) { if (values.length < 2) return null; const min = Math.min(...values); const max = Math.max(...values); const d = values.map((v, i) => `${i ? "L" : "M"}${((i / (values.length - 1)) * 96).toFixed(1)} ${(26 - ((v - min) / Math.max(1, max - min)) * 22).toFixed(1)}`).join(" "); return ( <svg viewBox="0 0 96 28" className="h-7 w-24" aria-hidden="true"> <path d={d} fill="none" stroke="var(--primary)" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" /> </svg> ); } function Coins() { return ( <span aria-hidden="true" className="pointer-events-none absolute inset-0"> {Array.from({ length: 10 }, (_, i) => { const angle = -Math.PI / 2 + ((i - 4.5) / 9) * 2.2; return ( <motion.span key={i} className="absolute top-1/2 left-1/2 size-2.5 rounded-full border border-[#B98A1E] bg-[#F2C14E]" initial={{ x: 0, y: 0, opacity: 1, scale: 0.6 }} animate={{ x: Math.cos(angle) * 56, y: Math.sin(angle) * 44 + 20, opacity: 0, scale: 1 }} transition={{ duration: 0.8, ease: "easeOut", delay: i * 0.015 }} /> ); })} </span> ); } /** * A portfolio of prediction market positions: Open (live P&L and Sell), Resolved (claim winnings * with a coin burst; losses dimmed) and History, under a portfolio total with a sparkline. */ export function PositionsPanel({ positions, cash = 0, valueHistory = [], onSell, onClaim, onExplore, className }: PositionsPanelProps) { const id = useId(); const reduceMotion = useReducedMotion(); const [tab, setTab] = useState<Tab>("open"); const [claimed, setClaimed] = useState<Record<string, boolean>>({}); const [burst, setBurst] = useState<string | null>(null); const [announce, setAnnounce] = useState(""); const outcomeOf = (p: MarketPosition) => p.market.outcomes.find((o) => o.id === p.outcomeId) ?? p.market.outcomes[0]; const open = positions.filter((p) => p.status === "open"); const resolved = positions.filter((p) => p.status === "won" || p.status === "lost"); const history = positions.filter((p) => p.status === "closed"); const claimedTotal = resolved.filter((p) => p.status === "won" && claimed[p.id]).reduce((sum, p) => sum + p.shares, 0); const total = cash + claimedTotal + open.reduce((sum, p) => sum + valueOf(p.shares, outcomeOf(p).price), 0); const lists: Record<Tab, MarketPosition[]> = { open, resolved, history }; const list = lists[tab]; const claim = (p: MarketPosition) => { setClaimed((c) => ({ ...c, [p.id]: true })); setAnnounce(`Claimed ${formatUsd(p.shares)}`); if (!reduceMotion) { setBurst(p.id); window.setTimeout(() => setBurst((b) => (b === p.id ? null : b)), 900); } onClaim?.(p); }; return ( <section aria-label="Positions" className={cn("w-full rounded-2xl border border-border bg-background", className)}> <header className="flex items-end justify-between gap-3 border-b border-border p-4"> <div> <p className="text-[12px] text-muted-foreground">Portfolio value</p> <p className="text-2xl font-semibold tracking-tight text-foreground tabular-nums"> <RollingValue value={formatUsd(total)} trend={total} /> </p> </div> <Spark values={valueHistory} /> </header> <LayoutGroup id={id}> <div role="tablist" aria-label="Positions" onKeyDown={onTabKey} className="mx-4 mt-3 grid grid-cols-3 gap-0.5 rounded-full bg-surface-2 p-0.5"> {(["open", "resolved", "history"] as const).map((t) => ( <button key={t} type="button" role="tab" id={`${id}-tab-${t}`} aria-controls={`${id}-panel`} aria-selected={tab === t} tabIndex={tab === t ? 0 : -1} onClick={() => setTab(t)} className={cn("relative h-8 cursor-pointer rounded-full text-[13px] font-medium capitalize outline-none focus-visible:ring-2 focus-visible:ring-ring", tab === t ? "text-foreground" : "text-[color-mix(in_oklab,var(--muted-foreground)_75%,var(--foreground))]")}> {tab === t ? <motion.span layoutId="positions-tab" transition={reduceMotion ? { duration: 0 } : { type: "spring", stiffness: 500, damping: 34 }} className="absolute inset-0 rounded-full bg-background shadow-sm" /> : null} <span className="relative"> {t} <span className="tabular-nums opacity-80">{lists[t].length}</span> </span> </button> ))} </div> </LayoutGroup> <div role="tabpanel" id={`${id}-panel`} aria-labelledby={`${id}-tab-${tab}`} className="p-2"> {list.length === 0 ? ( <div className="flex flex-col items-center px-6 py-10 text-center"> <svg viewBox="0 0 80 56" className="h-14 w-20 text-muted-foreground" aria-hidden="true" fill="none" stroke="currentColor" strokeWidth="2"> <rect x="8" y="10" width="64" height="40" rx="8" opacity="0.4" /> <path d="M18 38l10-10 8 6 14-14 12 10" strokeLinecap="round" strokeLinejoin="round" /> </svg> <p className="mt-3 text-sm font-medium text-foreground">No {tab === "open" ? "open positions" : tab === "resolved" ? "resolved markets" : "history"} yet</p> <p className="mt-1 text-[13px] text-muted-foreground">Make a prediction to see it here.</p> {onExplore ? ( <button type="button" onClick={onExplore} className="relative after:absolute after:inset-x-0 after:-inset-y-0.5 mt-4 h-9 cursor-pointer rounded-full bg-foreground px-4 text-sm font-medium text-background outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"> Explore markets </button> ) : null} </div> ) : ( <ul className="divide-y divide-border"> <AnimatePresence initial={false} mode="popLayout"> {list.map((p) => { const outcome = outcomeOf(p); const { value, pnl, pnlBps } = profitAndLoss(p.shares, p.averagePrice, outcome.price); const up = (p.status === "closed" ? (p.realized ?? 0) : pnl) >= 0; const lost = p.status === "lost"; return ( <motion.li key={p.id} layout="position" initial={reduceMotion ? false : { opacity: 0, y: 6 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} className={cn("flex items-center gap-3 px-2 py-3", lost && "opacity-50")}> <div className="min-w-0 flex-1"> <p className="truncate text-[13px] font-medium text-foreground">{p.market.question}</p> <p className="mt-0.5 flex flex-wrap items-center gap-x-2 text-[12px] text-muted-foreground tabular-nums"> <span className="rounded-full bg-surface-2 px-1.5 py-0.5 text-[11px] font-medium text-foreground">{outcome.name}</span> {formatShares(p.shares)} shares · {formatPrice(p.averagePrice)} → {p.status === "open" ? formatPrice(outcome.price) : p.status === "won" ? "100¢" : p.status === "lost" ? "0¢" : "sold"} </p> </div> {p.status === "open" ? ( <> <div className="text-right text-[12px] tabular-nums"> <p className="font-medium text-foreground"> <RollingValue value={formatUsd(value)} trend={value} /> </p> <p className={up ? "text-[color-mix(in_oklab,var(--success)_75%,var(--foreground))]" : "text-[color-mix(in_oklab,var(--destructive)_80%,var(--foreground))]"}> {up ? "▲" : "▼"} <RollingValue value={formatUsd(Math.abs(pnl))} trend={pnl} flash={false} /> ({(Math.abs(pnlBps) / 100).toFixed(1)}%) </p> </div> <button type="button" onClick={() => onSell?.(p)} className="h-8 shrink-0 cursor-pointer rounded-full border border-border px-3 text-[12px] font-medium text-foreground outline-none hover:bg-surface-2 focus-visible:ring-2 focus-visible:ring-ring"> Sell </button> </> ) : p.status === "won" ? ( <span className="relative"> <button type="button" disabled={claimed[p.id]} onClick={() => claim(p)} className="h-8 shrink-0 cursor-pointer rounded-full bg-[color-mix(in_oklab,var(--success)_78%,var(--foreground))] px-3 text-[12px] font-semibold text-success-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-default disabled:bg-surface-2 disabled:text-muted-foreground" > {claimed[p.id] ? "Claimed" : `Claim ${formatUsd(p.shares)}`} </button> {burst === p.id ? <Coins /> : null} </span> ) : p.status === "lost" ? ( <span className="text-[12px] text-muted-foreground">Lost {formatUsd(valueOf(p.shares, p.averagePrice))}</span> ) : ( <span className={cn("text-[12px] font-medium tabular-nums", up ? "text-[color-mix(in_oklab,var(--success)_75%,var(--foreground))]" : "text-[color-mix(in_oklab,var(--destructive)_80%,var(--foreground))]")}> {up ? "▲ +" : "▼ −"} {formatUsd(Math.abs(p.realized ?? 0))} </span> )} </motion.li> ); })} </AnimatePresence> </ul> )} </div> <p aria-live="polite" className="sr-only"> {announce} </p> </section> ); }
Usage
import { PositionsPanel } from "@/components/ui/positions-panel/positions-panel";
<PositionsPanel
positions={[{ id: "p1", market, outcomeId: "yes", shares: 15600, averagePrice: 5100, status: "open" }]}
cash={12850} // cents
onSell={(position) => openSellTicket(position)}
onClaim={(position) => claim(position)}
/>API reference
| Prop | Type | Default | Description |
|---|---|---|---|
positions* | MarketPosition[] | – | Positions with their markets. Simulated data only. |
cash | number | 0 | Cash balance included in the portfolio total, cents. |
valueHistory | number[] | – | Portfolio value over time for the header sparkline, cents. |
onSell | (position: MarketPosition) => void | – | Fires when Sell is pressed on an open position. |
onClaim | (position: MarketPosition) => void | – | Fires when a winning position is claimed. |
onExplore | () => void | – | Fires from the empty state's call to action. |
className | string | – | Classes for the panel. |
- Shares are hundredths and pay $1 each, so a won position's payout in cents equals its share count. Average price and current price are basis points.
- Open positions are valued at the live price, so the total rolls whenever the market moves; claiming adds the payout to the total.
- With no positions an empty state with an Explore markets action is shown.
Accessibility
- Open, Resolved and History are tabs with arrow-key support. Each position row names the market, outcome, shares, average price, value and P&L in text.
- Profit and loss show a sign and an arrow as well as color. Lost positions are dimmed and labelled "Lost".
- Claim announces the claimed amount politely; the coin burst is decorative and skipped with reduced motion.