Skip to content

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.json

Manual

  1. Install the dependencies

    npm install clsx motion tailwind-merge
  2. Add the shared helpers

    Components use cn() from lib/utils.ts and the motion tokens below. See Installation if you have not set them up yet.

    lib/markets/format.ts
    import { 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>
      );
    }
  3. 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);
    }
  4. 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

PropTypeDefaultDescription
positions*MarketPosition[]–Positions with their markets. Simulated data only.
cashnumber0Cash balance included in the portfolio total, cents.
valueHistorynumber[]–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.
classNamestring–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.