"use client"; import { Banknote, ChevronDown } from "lucide-react"; import { AnimatePresence, animate, motion, useReducedMotion, } from "motion/react"; import { useCallback, useEffect, useId, useMemo, useRef, useState, type CSSProperties, } from "react"; import { EASE_OUT } from "@/lib/ease"; import { cn } from "@/lib/utils"; import { StatefulButton, type ButtonState } from "./button/stateful"; import { NumberTicker } from "./number-ticker"; import { Tabs, TabsList, TabsTrigger } from "./tabs"; export type PredictionMarketMode = "buy" | "sell"; export type PredictionMarketOutcome = { id: string; label: string; price: number; }; export type PredictionMarketOrderValue = { mode: PredictionMarketMode; outcomeId: string; amount: string; }; export type PredictionMarketQuote = { valid: boolean; amount: number; price: number; shares: number; payout: number; error?: string; }; export type PredictionMarketClassNames = { root?: string; header?: string; tabs?: string; outcomes?: string; amount?: string; chips?: string; footer?: string; action?: string; }; export interface PredictionMarketProps { outcomes?: PredictionMarketOutcome[]; value?: PredictionMarketOrderValue; defaultValue?: Partial; onValueChange?: (value: PredictionMarketOrderValue) => void; onTrade?: ( order: PredictionMarketOrderValue, quote: PredictionMarketQuote, ) => void; onSignIn?: () => void; authenticated?: boolean; orderTypeLabel?: string; balance?: number; positions?: Record; quickAmounts?: number[]; minTrade?: number; className?: string; classNames?: PredictionMarketClassNames; } const DEFAULT_OUTCOMES: PredictionMarketOutcome[] = [ { id: "up", label: "Up", price: 0.09 }, { id: "down", label: "Down", price: 0.91 }, ]; const MODES: { id: PredictionMarketMode; label: string }[] = [ { id: "buy", label: "Buy" }, { id: "sell", label: "Sell" }, ]; const DEFAULT_QUICK_AMOUNTS = [10, 50, 100, 500]; const DIGIT_TRANSITION = { duration: 0.18, ease: EASE_OUT } as const; type AmountInputStyle = CSSProperties & { "--amount-chars": string }; function useControllableOrder({ value, defaultValue, outcomes, onValueChange, }: { value?: PredictionMarketOrderValue; defaultValue?: Partial; outcomes: PredictionMarketOutcome[]; onValueChange?: (value: PredictionMarketOrderValue) => void; }) { const initialValue: PredictionMarketOrderValue = { mode: defaultValue?.mode ?? "buy", outcomeId: defaultValue?.outcomeId ?? outcomes[0]?.id ?? "", amount: defaultValue?.amount ?? "", }; const [internalValue, setInternalValue] = useState(initialValue); const controlled = value !== undefined; const order = value ?? internalValue; const setOrder = useCallback( (next: PredictionMarketOrderValue) => { if (!controlled) { setInternalValue(next); } onValueChange?.(next); }, [controlled, onValueChange], ); return [order, setOrder] as const; } function sanitizeAmount(value: string) { const normalized = value.replace(/[^\d.]/g, ""); const [whole, ...decimalParts] = normalized.split("."); const decimal = decimalParts.join(""); if (decimalParts.length === 0) return whole; return `${whole}.${decimal.slice(0, 2)}`; } function parseAmount(value: string) { return Number(value) || 0; } function formatCurrency(value: number, maximumFractionDigits = 2) { return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits, }).format(value); } function formatCompactCurrency(value: number) { return value >= 100 ? formatCurrency(value, 0) : formatCurrency(value, value % 1 === 0 ? 0 : 2); } function formatCents(value: number) { const cents = value * 100; const precision = Number.isInteger(cents) ? 0 : 1; return `${cents.toFixed(precision)}ยข`; } function buildQuote({ order, outcome, balance, position, minTrade, }: { order: PredictionMarketOrderValue; outcome: PredictionMarketOutcome; balance: number; position: number; minTrade: number; }): PredictionMarketQuote { const amount = parseAmount(order.amount); const price = Math.max(0.01, Math.min(0.99, outcome.price)); const shares = order.mode === "buy" ? amount / price : amount; const payout = order.mode === "buy" ? shares : amount * price; if (amount <= 0) { return { valid: false, amount, price, shares: 0, payout: 0, error: "Enter an amount", }; } if (order.mode === "buy" && amount < minTrade) { return { valid: false, amount, price, shares, payout, error: `Minimum ${formatCompactCurrency(minTrade)}`, }; } if (order.mode === "buy" && amount > balance) { return { valid: false, amount, price, shares, payout, error: "Insufficient balance", }; } if (order.mode === "sell" && amount > position) { return { valid: false, amount, price, shares, payout, error: "Not enough shares", }; } return { valid: true, amount, price, shares, payout, }; } function keyedAmountChars(value: string) { const seen = new Map(); return value.split("").map((char) => { const count = seen.get(char) ?? 0; seen.set(char, count + 1); return { id: `${char}-${count}`, char }; }); } function amountInputSize(value: string) { const length = value.replace(/\D/g, "").length; if (length >= 10) return "text-3xl sm:text-4xl"; if (length >= 8) return "text-4xl sm:text-5xl"; if (length >= 6) return "text-[44px] sm:text-[56px]"; return "text-5xl sm:text-6xl"; } function payoutTickerSize(value: number) { const length = formatCurrency(value).length; if (length >= 16) return "text-xl sm:text-2xl"; if (length >= 13) return "text-2xl"; if (length >= 10) return "text-3xl"; return "text-4xl"; } function AnimatedAmountInput({ id, value, mode, inputSize, disabled, reduce, onChange, }: { id: string; value: string; mode: PredictionMarketMode; inputSize: string; disabled: boolean; reduce: boolean; onChange: (value: string) => void; }) { const displayValue = value || "0"; const chars = keyedAmountChars(displayValue); const inputStyle = { "--amount-chars": String(chars.length), } as AmountInputStyle; const label = mode === "buy" ? "Amount" : "Shares"; return (
{mode === "buy" ? ( $ ) : null}
onChange(sanitizeAmount(event.target.value))} placeholder="0" inputMode="decimal" aria-label={label} autoComplete="off" className={cn( "w-[calc((var(--amount-chars)+1)*0.62em)] min-w-[0.8em] max-w-[260px] bg-transparent text-left font-semibold leading-none tracking-normal text-transparent outline-none tabular-nums", "caret-foreground transition-[font-size] duration-200 placeholder:text-transparent selection:bg-foreground/10 disabled:cursor-not-allowed", inputSize, )} style={inputStyle} />
{chars.map(({ id: charId, char }) => ( {char} ))}
); } export function PredictionMarket({ outcomes = DEFAULT_OUTCOMES, value, defaultValue, onValueChange, onTrade, onSignIn, authenticated = true, orderTypeLabel = "Market", balance = 500, positions = { up: 24, down: 16 }, quickAmounts = DEFAULT_QUICK_AMOUNTS, minTrade = 1, className, classNames, }: PredictionMarketProps) { const inputId = useId(); const reduce = useReducedMotion() ?? false; const amountRef = useRef(null); const timeoutRef = useRef | null>(null); const [status, setStatus] = useState<"idle" | "placing" | "filled">("idle"); const [shakeKey, setShakeKey] = useState(0); const [order, setOrder] = useControllableOrder({ value, defaultValue, outcomes, onValueChange, }); const selectedOutcome = outcomes.find((outcome) => outcome.id === order.outcomeId) ?? outcomes[0]; const position = positions[selectedOutcome.id] ?? 0; const quote = useMemo( () => buildQuote({ order, outcome: selectedOutcome, balance, position, minTrade, }), [balance, minTrade, order, position, selectedOutcome], ); const setOrderValue = useCallback( (next: Partial) => { if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null; } setStatus("idle"); setOrder({ ...order, ...next }); }, [order, setOrder], ); useEffect(() => { return () => { if (timeoutRef.current) clearTimeout(timeoutRef.current); }; }, []); useEffect(() => { if (shakeKey === 0 || reduce || !amountRef.current) return; animate( amountRef.current, { x: [0, -5, 5, -3, 3, -1, 0] }, { duration: 0.38, ease: EASE_OUT }, ); }, [reduce, shakeKey]); const addAmount = (increment: number) => { const next = parseAmount(order.amount) + increment; setOrderValue({ amount: String(next) }); }; const setMax = () => { if (order.mode === "buy") { setOrderValue({ amount: String(Math.floor(balance)) }); return; } setOrderValue({ amount: position.toFixed(position % 1 === 0 ? 0 : 2) }); }; const submit = () => { if (!authenticated) { onSignIn?.(); return; } if (!quote.valid) { setShakeKey((key) => key + 1); return; } setStatus("placing"); timeoutRef.current = setTimeout(() => { setStatus("filled"); onTrade?.(order, quote); }, 650); }; const inputSize = amountInputSize(order.amount); const payoutSize = payoutTickerSize(quote.payout); const actionState: ButtonState = status === "placing" ? "loading" : status === "filled" ? "success" : quote.valid ? "idle" : "error"; const showFooter = authenticated; return (
setOrderValue({ mode: mode as PredictionMarketMode, amount: "", }) } variant="underline" className={cn("shrink-0", classNames?.tabs)} > {MODES.map((mode) => ( {mode.label} ))}
setOrderValue({ outcomeId })} variant="pill" className={classNames?.outcomes} > {outcomes.map((outcome) => { const selected = outcome.id === selectedOutcome.id; const isNo = outcome.label.toLowerCase() === "no" || outcome.label.toLowerCase() === "down"; return ( {outcome.label} {formatCents(outcome.price)} ); })}
setOrderValue({ amount })} />
{quickAmounts.map((amount) => ( ))}
{showFooter ? (
{order.mode === "buy" ? "To win" : "To receive"}

Avg. Price {formatCents(quote.price)}

formatCurrency(cents / 100)} />
Trade
) : (
Sign In
)}
); }