Prediction Marketsv0.3.0
Trade Ticket
Buy and sell outcome shares with a live payout summary.
Installation
CLI
npx shadcn@latest add https://hantui-theta.vercel.app/r/trade-ticket.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/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> ); }lib/web3/types.ts/** Shapes the web3 components accept. Your own token and chain objects only need these fields. */ export interface Token { symbol: string; name?: string; /** Token decimals: 18 for ETH, 6 for USDC. Amounts are bigint in these units. */ decimals: number; address?: string; /** Logo URL. Without one, a generated avatar (colored circle and symbol) is shown. */ iconUrl?: string; /** Avatar color for the generated icon. Picked from the symbol when omitted. */ color?: string; /** USD price as a bigint with USD_DECIMALS (8) decimals. */ price?: bigint; } export interface Chain { id: number; name: string; shortName?: string; iconUrl?: string; color?: string; /** Block explorer base URL without a trailing slash, e.g. https://etherscan.io */ explorerUrl?: string; nativeSymbol?: string; } export interface WalletOption { id: string; name: string; /** Logo URL. Without one, a generated squircle with the wallet's initial is shown. */ iconUrl?: string; color?: string; /** Shows a "Recent" badge. */ recent?: boolean; /** The wallet is detected in this browser. */ installed?: boolean; /** Connects by scanning a QR code with a phone instead of a browser extension. */ qr?: boolean; /** Short helper line under the name. */ description?: string; } /** Lifecycle of anything the user signs: every state gets its own visual. */ export type TxStatus = "idle" | "awaiting-signature" | "pending" | "confirmed" | "failed"; /** Explorer URL for an address or transaction, or undefined without an explorer. */ export function explorerLink(explorerUrl: string | undefined, kind: "address" | "tx", value: string) { return explorerUrl ? `${explorerUrl.replace(/\/$/, "")}/${kind}/${value}` : undefined; }Add the theme tokens
This component uses --success, --success-foreground, and --warning, which shadcn does not define by default. Add them to your global CSS:
app/globals.css:root { --success: #15803d; --success-foreground: #ffffff; --warning: #b45309; } .dark { --success: #4ade80; --success-foreground: #052e16; --warning: #fbbf24; } @theme inline { --color-success: var(--success); --color-success-foreground: var(--success-foreground); --color-warning: var(--warning); }Copy the source
components/ui/trade-ticket/trade-ticket.tsx"use client"; import { useEffect, useId, useState } from "react"; import { AnimatePresence, LayoutGroup, motion, useAnimate, useReducedMotion } from "motion/react"; import { Minus, Plus, TriangleAlert, X } from "lucide-react"; import { cn } from "@/lib/utils"; import { TokenAmountInput } from "@/components/ui/token-amount-input/token-amount-input"; import { TransactionToastStack, type TransactionToastItem } from "@/components/ui/transaction-toast/transaction-toast"; import { formatPrice, formatReturn, formatShares, formatUsd } from "@/lib/markets/format"; import { FULL, complement, quoteBuy, quoteLimit, quoteSell } from "@/lib/markets/math"; import type { Market, TradeSide } from "@/lib/markets/types"; import { RollingValue } from "@/lib/web3/rolling-value"; import type { Token } from "@/lib/web3/types"; export interface TradeTicketProps { /** The market to trade. Everything is simulated. */ market: Market; /** Outcome to start on. @default the first outcome */ outcomeId?: string; /** For binary markets: buy Yes or No. @default "yes" */ choice?: "yes" | "no"; /** Buy or Sell tab to start on. @default "buy" */ defaultSide?: TradeSide; /** Simulated USDC balance, in cents. @default 50000 */ balance?: number; /** Shares held of the selected outcome (hundredths), for Sell. @default 0 */ position?: number; /** Shared layout id, so a pressed Market Card button morphs into the ticket. */ layoutId?: string; /** Simulate a failed order (the button shakes and an error shows). @default false */ simulateFailure?: boolean; /** Shows a close button. */ onClose?: () => void; /** Fires when a simulated order succeeds. */ onTrade?: (trade: { side: TradeSide; outcomeId: string; choice: "yes" | "no"; amount: number; shares: number; price: number }) => void; /** Classes for the ticket. */ className?: string; } const USDC: Token = { symbol: "USDC", name: "USD Coin (simulated)", decimals: 6, color: "#2563EB" }; const IMPACT_WARNING = 500; type Status = "idle" | "confirming" | "success" | "error"; const toCents = (amount: bigint | null) => (amount === null ? 0 : Number(amount / BigInt(10000))); /** * A simulated order ticket: Buy/Sell, Yes/No (or an outcome), an amount with quick chips, a live * summary (average price, shares, payout, return, price impact) and an optional limit price. * Integer math only; nothing is sent anywhere. */ export function TradeTicket({ market, outcomeId, choice: initialChoice = "yes", defaultSide = "buy", balance = 50000, position = 0, layoutId, simulateFailure = false, onClose, onTrade, className, }: TradeTicketProps) { const id = useId(); const reduceMotion = useReducedMotion(); const binary = market.type === "binary"; const [side, setSide] = useState<TradeSide>(defaultSide); const [choice, setChoice] = useState<"yes" | "no">(initialChoice); const [outcome, setOutcome] = useState(outcomeId ?? market.outcomes[0].id); const [value, setValue] = useState(""); const [amount, setAmount] = useState(0); const [limitMode, setLimitMode] = useState(false); const [status, setStatus] = useState<Status>("idle"); const [toasts, setToasts] = useState<TransactionToastItem[]>([]); const [scope, animate] = useAnimate(); const selected = market.outcomes.find((o) => o.id === outcome) ?? market.outcomes[0]; const basePrice = binary ? (choice === "yes" ? market.outcomes[0].price : complement(market.outcomes[0].price)) : selected.price; const [limitPrice, setLimitPrice] = useState(Math.round(basePrice / 100) * 100); const label = binary ? (choice === "yes" ? "Yes" : "No") : selected.name; // Buy: amount is dollars (cents); Sell: amount is shares (hundredths). const buyQuote = limitMode ? quoteLimit(limitPrice, amount) : quoteBuy(basePrice, amount, market.liquidity); const sellQuote = quoteSell(basePrice, amount, market.liquidity); const impact = side === "buy" ? buyQuote.impact : -sellQuote.impact; const over = side === "buy" ? amount > balance : amount > position; useEffect(() => { if (status !== "confirming") return; const timer = window.setTimeout(() => { if (simulateFailure) { setStatus("error"); if (!reduceMotion && scope.current) animate(scope.current, { x: [0, -8, 8, -6, 6, -2, 0] }, { duration: 0.42 }); setToasts((list) => [...list, { id: `${Date.now()}`, title: `Order for ${label} failed`, description: "Price moved past your limit (simulated)", status: "failed" }]); return; } setStatus("success"); const shares = side === "buy" ? buyQuote.shares : amount; onTrade?.({ side, outcomeId: selected.id, choice, amount: side === "buy" ? amount : sellQuote.proceeds, shares, price: side === "buy" ? buyQuote.averagePrice : sellQuote.averagePrice }); setToasts((list) => [ ...list, { id: `${Date.now()}`, title: side === "buy" ? `Bought ${formatShares(shares)} ${label}` : `Sold ${formatShares(shares)} ${label}`, description: side === "buy" ? `${formatUsd(amount)} at ${formatPrice(buyQuote.averagePrice)} avg (simulated)` : `Received ${formatUsd(sellQuote.proceeds)} (simulated)`, status: "confirmed", }, ]); }, 1400); return () => window.clearTimeout(timer); // The order is captured when it starts confirming. // eslint-disable-next-line react-hooks/exhaustive-deps }, [status]); const setAmountValue = (next: string, parsed: bigint | null) => { setValue(next); setAmount(side === "buy" ? toCents(parsed) : parsed === null ? 0 : Number(parsed)); if (status !== "confirming") setStatus("idle"); }; const addDollars = (dollars: number | "max") => { const cents = dollars === "max" ? balance : amount + dollars * 100; const capped = Math.min(cents, balance); setValue((capped / 100).toFixed(2).replace(/\.00$/, "")); setAmount(capped); setStatus("idle"); }; const switchSide = (next: TradeSide) => { setSide(next); setValue(""); setAmount(0); setStatus("idle"); }; const action = status === "confirming" ? "Confirming…" : status === "success" ? "Done" : amount <= 0 ? "Enter amount" : over ? (side === "buy" ? "Insufficient balance" : "Not enough shares") : `${side === "buy" ? "Buy" : "Sell"} ${label}`; const disabled = status === "confirming" || (status !== "success" && (amount <= 0 || over)); const submit = () => { if (status === "success") { setStatus("idle"); setValue(""); setAmount(0); return; } if (!disabled) setStatus("confirming"); }; const shareToken: Token = { symbol: label.slice(0, 8), name: `${label} shares`, decimals: 2, color: "#16A34A" }; return ( <motion.section layoutId={layoutId} aria-labelledby={`${id}-title`} className={cn("relative w-full max-w-sm rounded-2xl border border-border bg-background p-4 shadow-[0_1px_2px_rgb(0_0_0/0.05),0_16px_40px_-20px_rgb(0_0_0/0.3)]", className)} > <header className="flex items-start justify-between gap-3"> <div className="min-w-0"> <p className="flex items-center gap-2 text-[12px] text-muted-foreground"> Trade <span className="inline-flex h-5 items-center gap-1 rounded-full border border-warning/30 bg-warning/10 px-2 text-[11px] font-medium text-[color-mix(in_oklab,var(--warning)_75%,var(--foreground))]"> <span aria-hidden="true" className="size-1.5 rounded-full bg-warning" /> Simulated </span> </p> <p id={`${id}-title`} className="mt-0.5 line-clamp-2 text-sm font-medium text-foreground"> {market.question} </p> </div> {onClose ? ( <button type="button" aria-label="Close trade ticket" onClick={onClose} className="relative after:absolute after:-inset-0.5 -mt-1 -mr-1 grid size-9 shrink-0 cursor-pointer place-items-center rounded-full text-muted-foreground outline-none hover:bg-surface-2 focus-visible:ring-2 focus-visible:ring-ring"> <X className="size-4" aria-hidden="true" /> </button> ) : null} </header> <LayoutGroup id={`${id}-side`}> <div role="tablist" aria-label="Order side" className="mt-3 grid grid-cols-2 gap-0.5 rounded-full bg-surface-2 p-0.5"> {(["buy", "sell"] as const).map((s) => ( <button key={s} type="button" role="tab" aria-selected={side === s} onClick={() => switchSide(s)} className={cn("relative h-8 cursor-pointer rounded-full text-[13px] font-medium capitalize outline-none focus-visible:ring-2 focus-visible:ring-ring", side === s ? "text-foreground" : "text-[color-mix(in_oklab,var(--muted-foreground)_75%,var(--foreground))]")}> {side === s ? <motion.span layoutId="ticket-side" transition={reduceMotion ? { duration: 0 } : { type: "spring", stiffness: 500, damping: 34 }} className="absolute inset-0 rounded-full bg-background shadow-sm" /> : null} <span className="relative">{s}</span> </button> ))} </div> </LayoutGroup> {binary ? ( <div role="radiogroup" aria-label="Outcome" className="mt-3 grid grid-cols-2 gap-2"> {(["yes", "no"] as const).map((c) => { const price = c === "yes" ? market.outcomes[0].price : complement(market.outcomes[0].price); const active = choice === c; return ( <button key={c} type="button" role="radio" aria-checked={active} onClick={() => setChoice(c)} className={cn( "flex h-11 cursor-pointer items-center justify-between rounded-xl border px-3 text-sm font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring", active ? (c === "yes" ? "border-success bg-success/10 text-foreground" : "border-destructive bg-destructive/8 text-foreground") : "border-border text-muted-foreground hover:text-foreground", )} > <span>{c === "yes" ? "Yes" : "No"}</span> <span className="tabular-nums"> <RollingValue value={formatPrice(price)} trend={price} /> </span> </button> ); })} </div> ) : ( <label className="mt-3 block"> <span className="sr-only">Outcome</span> <select value={outcome} onChange={(event) => setOutcome(event.target.value)} className="h-11 w-full cursor-pointer rounded-xl border border-border bg-background px-3 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring"> {market.outcomes.map((o) => ( <option key={o.id} value={o.id}> {o.name} · {formatPrice(o.price)} </option> ))} </select> </label> )} <div className="mt-3"> <TokenAmountInput token={side === "buy" ? USDC : shareToken} value={value} onValueChange={setAmountValue} balance={side === "buy" ? BigInt(balance) * BigInt(10000) : BigInt(position)} label={side === "buy" ? "Amount" : "Shares"} percentages={side === "buy" ? [] : [25, 50, 100]} error={over ? (side === "buy" ? "More than your balance" : "More than you hold") : undefined} /> {side === "buy" ? ( <div className="mt-2 flex gap-1.5"> {([1, 10, 100, "max"] as const).map((chip) => ( <button key={chip} type="button" onClick={() => addDollars(chip)} className="h-8 flex-1 cursor-pointer rounded-full border border-border text-[12px] font-medium text-foreground outline-none hover:bg-surface-2 focus-visible:ring-2 focus-visible:ring-ring"> {chip === "max" ? "Max" : `+$${chip}`} </button> ))} </div> ) : null} </div> {side === "buy" ? ( <div className="mt-3 flex items-center justify-between"> <label className="flex cursor-pointer items-center gap-2 text-[13px] text-foreground"> <input type="checkbox" checked={limitMode} onChange={(event) => setLimitMode(event.target.checked)} className="size-4 accent-[var(--primary)]" /> Limit price </label> {limitMode ? ( <div className="flex items-center gap-1" role="group" aria-label="Limit price"> <button type="button" aria-label="Lower limit by 1 cent" onClick={() => setLimitPrice((p) => Math.max(100, p - 100))} className="relative after:absolute after:-inset-1 grid size-8 cursor-pointer place-items-center rounded-full border border-border outline-none hover:bg-surface-2 focus-visible:ring-2 focus-visible:ring-ring"> <Minus className="size-3.5" aria-hidden="true" /> </button> <span aria-live="polite" className="w-12 text-center text-sm font-medium tabular-nums"> {formatPrice(limitPrice)} </span> <button type="button" aria-label="Raise limit by 1 cent" onClick={() => setLimitPrice((p) => Math.min(FULL - 100, p + 100))} className="relative after:absolute after:-inset-1 grid size-8 cursor-pointer place-items-center rounded-full border border-border outline-none hover:bg-surface-2 focus-visible:ring-2 focus-visible:ring-ring"> <Plus className="size-3.5" aria-hidden="true" /> </button> </div> ) : null} </div> ) : null} <dl className="mt-3 space-y-1.5 rounded-xl bg-surface p-3 text-[13px]"> {side === "buy" ? ( <> <div className="flex justify-between"> <dt className="text-muted-foreground">Avg price</dt> <dd className="font-medium tabular-nums"> <RollingValue value={`${(buyQuote.averagePrice / 100).toFixed(1)}¢`} flash={false} /> </dd> </div> <div className="flex justify-between"> <dt className="text-muted-foreground">Est. shares</dt> <dd className="font-medium tabular-nums"> <RollingValue value={formatShares(buyQuote.shares)} flash={false} /> </dd> </div> <div className="flex justify-between"> <dt className="text-muted-foreground">Potential payout</dt> <dd className="font-medium tabular-nums"> <RollingValue value={formatUsd(buyQuote.payout)} flash={false} /> {amount > 0 ? <span className="ml-1.5 text-[color-mix(in_oklab,var(--success)_75%,var(--foreground))]">{formatReturn(buyQuote.returnBps)}</span> : null} </dd> </div> </> ) : ( <div className="flex justify-between"> <dt className="text-muted-foreground">You receive</dt> <dd className="font-medium tabular-nums"> <RollingValue value={formatUsd(sellQuote.proceeds)} flash={false} /> </dd> </div> )} <div className="flex justify-between"> <dt className="text-muted-foreground">Price impact</dt> <dd className={cn("inline-flex items-center gap-1 font-medium tabular-nums", Math.abs(impact) > IMPACT_WARNING && "text-[color-mix(in_oklab,var(--warning)_80%,var(--foreground))]")}> {Math.abs(impact) > IMPACT_WARNING ? <TriangleAlert className="size-3.5" aria-hidden="true" /> : null} {(Math.abs(impact) / 100).toFixed(1)}% {Math.abs(impact) > IMPACT_WARNING ? <span className="sr-only"> high price impact</span> : null} </dd> </div> </dl> <div ref={scope} className="relative mt-3"> <button type="button" onClick={submit} disabled={disabled} className={cn( "relative flex h-11 w-full cursor-pointer items-center justify-center gap-2 overflow-hidden rounded-xl text-sm font-semibold outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed", status === "success" ? "bg-[color-mix(in_oklab,var(--success)_78%,var(--foreground))] text-success-foreground" : disabled ? "bg-surface-2 text-muted-foreground" : side === "buy" && (!binary || choice === "yes") ? "bg-foreground text-background" : "bg-foreground text-background", )} > {status === "confirming" ? <span aria-hidden="true" className="size-4 animate-spin rounded-full border-2 border-current border-t-transparent motion-reduce:animate-none" /> : null} {status === "success" ? ( <svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="3" 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.35, ease: "easeOut" }} /> </svg> ) : null} <span aria-live="polite">{action}</span> </button> {/* A small confetti burst on success. */} <AnimatePresence> {status === "success" && !reduceMotion ? ( <span aria-hidden="true" className="pointer-events-none absolute inset-0"> {Array.from({ length: 14 }, (_, i) => { const angle = (i / 14) * Math.PI * 2; return ( <motion.span key={i} className="absolute top-1/2 left-1/2 size-1.5 rounded-[2px]" style={{ background: ["var(--success)", "var(--primary)", "var(--warning)"][i % 3] }} initial={{ x: 0, y: 0, opacity: 1, rotate: 0 }} animate={{ x: Math.cos(angle) * (40 + (i % 3) * 18), y: Math.sin(angle) * (22 + (i % 4) * 8) - 10, opacity: 0, rotate: 180 }} transition={{ duration: 0.9, ease: "easeOut" }} /> ); })} </span> ) : null} </AnimatePresence> </div> {status === "error" ? ( <p role="alert" className="mt-2 text-[13px] text-[color-mix(in_oklab,var(--destructive)_80%,var(--foreground))]"> Order rejected: the price moved past your limit. Try again. </p> ) : null} <TransactionToastStack toasts={toasts} onDismiss={(toastId) => setToasts((list) => list.filter((t) => t.id !== toastId))} dismissAfter={5000} /> </motion.section> ); }
Usage
import { TradeTicket } from "@/components/ui/trade-ticket/trade-ticket";
<TradeTicket
market={market}
choice="yes"
balance={50000} // cents, simulated
onTrade={(trade) => console.log(trade.shares, trade.price)}
/>API reference
| Prop | Type | Default | Description |
|---|---|---|---|
market* | Market | – | The market to trade. Everything is simulated. |
outcomeId | string | the first outcome | Outcome to start on. |
choice | "yes" | "no" | "yes" | For binary markets: buy Yes or No. |
defaultSide | TradeSide | "buy" | Buy or Sell tab to start on. |
balance | number | 50000 | Simulated USDC balance, in cents. |
position | number | 0 | Shares held of the selected outcome (hundredths), for Sell. |
layoutId | string | – | Shared layout id, so a pressed Market Card button morphs into the ticket. |
simulateFailure | boolean | false | Simulate a failed order (the button shakes and an error shows). |
onClose | () => void | – | Shows a close button. |
onTrade | (trade: { side: TradeSide; outcomeId: string; choice: "yes" | "no"; amount: number; shares: number; price: number }) => void | – | Fires when a simulated order succeeds. |
className | string | – | Classes for the ticket. |
- Everything is simulated: a constant-product style curve gives the average price and impact, shares are amount ÷ price and pay $1 each, all in integer cents, basis points and hundredths of a share (lib/markets/math).
- The amount field is Token Amount Input with a simulated USDC balance (bigint, 6 decimals); Sell switches it to shares with your position as the max.
- Success draws a check, bursts a little confetti and adds a Transaction Toast. Pass layoutId to morph from a Market Card button.
Accessibility
- Buy and Sell are tabs; Yes and No are a radio group whose labels include the price. The amount field, quick chips and limit stepper are all labelled controls.
- The submit button names the action ("Buy Yes", "Insufficient balance", "Confirming…") and announces changes politely. A failed order shows an alert with the reason.
- Price impact above 5% is flagged with an icon and a spoken "high price impact", not only with color.
- With reduced motion the check, confetti and shake are skipped; the states still change.