{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "author": "DesignX ", "name": "prediction-market", "type": "registry:component", "title": "Prediction Market", "description": "A market detail view with outcome odds and a trade panel.", "dependencies": [ "lucide-react@^0.577.0", "motion@^14.0.0" ], "registryDependencies": [ "@dx/lib-ease", "@dx/motion-button", "@dx/motion-tabs", "@dx/number-ticker", "@dx/utils" ], "files": [ { "path": "components/motion/prediction-market.tsx", "type": "registry:component", "target": "components/motion/prediction-market.tsx", "content": "import { Banknote, ChevronDown } from \"lucide-react\";\nimport {\n AnimatePresence,\n animate,\n motion,\n useReducedMotion,\n} from \"motion/react\";\nimport {\n useCallback,\n useEffect,\n useId,\n useMemo,\n useRef,\n useState,\n type CSSProperties,\n} from \"react\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\nimport { StatefulButton, type ButtonState } from \"./button/stateful\";\nimport { NumberTicker } from \"./number-ticker\";\nimport { Tabs, TabsList, TabsTrigger } from \"./tabs\";\n\nexport type PredictionMarketMode = \"buy\" | \"sell\";\n\nexport type PredictionMarketOutcome = {\n id: string;\n label: string;\n price: number;\n};\n\nexport type PredictionMarketOrderValue = {\n mode: PredictionMarketMode;\n outcomeId: string;\n amount: string;\n};\n\nexport type PredictionMarketQuote = {\n valid: boolean;\n amount: number;\n price: number;\n shares: number;\n payout: number;\n error?: string;\n};\n\nexport type PredictionMarketClassNames = {\n root?: string;\n header?: string;\n tabs?: string;\n outcomes?: string;\n amount?: string;\n chips?: string;\n footer?: string;\n action?: string;\n};\n\nexport interface PredictionMarketProps {\n outcomes?: PredictionMarketOutcome[];\n value?: PredictionMarketOrderValue;\n defaultValue?: Partial;\n onValueChange?: (value: PredictionMarketOrderValue) => void;\n onTrade?: (\n order: PredictionMarketOrderValue,\n quote: PredictionMarketQuote,\n ) => void;\n onSignIn?: () => void;\n authenticated?: boolean;\n orderTypeLabel?: string;\n balance?: number;\n positions?: Record;\n quickAmounts?: number[];\n minTrade?: number;\n className?: string;\n classNames?: PredictionMarketClassNames;\n}\n\nconst DEFAULT_OUTCOMES: PredictionMarketOutcome[] = [\n { id: \"up\", label: \"Up\", price: 0.09 },\n { id: \"down\", label: \"Down\", price: 0.91 },\n];\n\nconst MODES: { id: PredictionMarketMode; label: string }[] = [\n { id: \"buy\", label: \"Buy\" },\n { id: \"sell\", label: \"Sell\" },\n];\n\nconst DEFAULT_QUICK_AMOUNTS = [10, 50, 100, 500];\nconst DIGIT_TRANSITION = { duration: 0.18, ease: EASE_OUT } as const;\ntype AmountInputStyle = CSSProperties & { \"--amount-chars\": string };\n\nfunction useControllableOrder({\n value,\n defaultValue,\n outcomes,\n onValueChange,\n}: {\n value?: PredictionMarketOrderValue;\n defaultValue?: Partial;\n outcomes: PredictionMarketOutcome[];\n onValueChange?: (value: PredictionMarketOrderValue) => void;\n}) {\n const initialValue: PredictionMarketOrderValue = {\n mode: defaultValue?.mode ?? \"buy\",\n outcomeId: defaultValue?.outcomeId ?? outcomes[0]?.id ?? \"\",\n amount: defaultValue?.amount ?? \"\",\n };\n\n const [internalValue, setInternalValue] = useState(initialValue);\n const controlled = value !== undefined;\n const order = value ?? internalValue;\n\n const setOrder = useCallback(\n (next: PredictionMarketOrderValue) => {\n if (!controlled) {\n setInternalValue(next);\n }\n\n onValueChange?.(next);\n },\n [controlled, onValueChange],\n );\n\n return [order, setOrder] as const;\n}\n\nfunction sanitizeAmount(value: string) {\n const normalized = value.replace(/[^\\d.]/g, \"\");\n const [whole, ...decimalParts] = normalized.split(\".\");\n const decimal = decimalParts.join(\"\");\n if (decimalParts.length === 0) return whole;\n return `${whole}.${decimal.slice(0, 2)}`;\n}\n\nfunction parseAmount(value: string) {\n return Number(value) || 0;\n}\n\nfunction formatCurrency(value: number, maximumFractionDigits = 2) {\n return new Intl.NumberFormat(\"en-US\", {\n style: \"currency\",\n currency: \"USD\",\n maximumFractionDigits,\n }).format(value);\n}\n\nfunction formatCompactCurrency(value: number) {\n return value >= 100\n ? formatCurrency(value, 0)\n : formatCurrency(value, value % 1 === 0 ? 0 : 2);\n}\n\nfunction formatCents(value: number) {\n const cents = value * 100;\n const precision = Number.isInteger(cents) ? 0 : 1;\n return `${cents.toFixed(precision)}ยข`;\n}\n\nfunction buildQuote({\n order,\n outcome,\n balance,\n position,\n minTrade,\n}: {\n order: PredictionMarketOrderValue;\n outcome: PredictionMarketOutcome;\n balance: number;\n position: number;\n minTrade: number;\n}): PredictionMarketQuote {\n const amount = parseAmount(order.amount);\n const price = Math.max(0.01, Math.min(0.99, outcome.price));\n const shares = order.mode === \"buy\" ? amount / price : amount;\n const payout = order.mode === \"buy\" ? shares : amount * price;\n\n if (amount <= 0) {\n return {\n valid: false,\n amount,\n price,\n shares: 0,\n payout: 0,\n error: \"Enter an amount\",\n };\n }\n\n if (order.mode === \"buy\" && amount < minTrade) {\n return {\n valid: false,\n amount,\n price,\n shares,\n payout,\n error: `Minimum ${formatCompactCurrency(minTrade)}`,\n };\n }\n\n if (order.mode === \"buy\" && amount > balance) {\n return {\n valid: false,\n amount,\n price,\n shares,\n payout,\n error: \"Insufficient balance\",\n };\n }\n\n if (order.mode === \"sell\" && amount > position) {\n return {\n valid: false,\n amount,\n price,\n shares,\n payout,\n error: \"Not enough shares\",\n };\n }\n\n return {\n valid: true,\n amount,\n price,\n shares,\n payout,\n };\n}\n\nfunction keyedAmountChars(value: string) {\n const seen = new Map();\n return value.split(\"\").map((char) => {\n const count = seen.get(char) ?? 0;\n seen.set(char, count + 1);\n return { id: `${char}-${count}`, char };\n });\n}\n\nfunction amountInputSize(value: string) {\n const length = value.replace(/\\D/g, \"\").length;\n if (length >= 10) return \"text-3xl sm:text-4xl\";\n if (length >= 8) return \"text-4xl sm:text-5xl\";\n if (length >= 6) return \"text-[44px] sm:text-[56px]\";\n return \"text-5xl sm:text-6xl\";\n}\n\nfunction payoutTickerSize(value: number) {\n const length = formatCurrency(value).length;\n if (length >= 16) return \"text-xl sm:text-2xl\";\n if (length >= 13) return \"text-2xl\";\n if (length >= 10) return \"text-3xl\";\n return \"text-4xl\";\n}\n\nfunction AnimatedAmountInput({\n id,\n value,\n mode,\n inputSize,\n disabled,\n reduce,\n onChange,\n}: {\n id: string;\n value: string;\n mode: PredictionMarketMode;\n inputSize: string;\n disabled: boolean;\n reduce: boolean;\n onChange: (value: string) => void;\n}) {\n const displayValue = value || \"0\";\n const chars = keyedAmountChars(displayValue);\n const inputStyle = {\n \"--amount-chars\": String(chars.length),\n } as AmountInputStyle;\n const label = mode === \"buy\" ? \"Amount\" : \"Shares\";\n\n return (\n
\n {mode === \"buy\" ? (\n \n $\n \n ) : null}\n\n
\n onChange(sanitizeAmount(event.target.value))}\n placeholder=\"0\"\n inputMode=\"decimal\"\n aria-label={label}\n autoComplete=\"off\"\n className={cn(\n \"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\",\n \"caret-foreground transition-[font-size] duration-200 placeholder:text-transparent selection:bg-foreground/10 disabled:cursor-not-allowed\",\n inputSize,\n )}\n style={inputStyle}\n />\n \n \n {chars.map(({ id: charId, char }) => (\n \n {char}\n \n ))}\n \n
\n
\n \n );\n}\n\nexport function PredictionMarket({\n outcomes = DEFAULT_OUTCOMES,\n value,\n defaultValue,\n onValueChange,\n onTrade,\n onSignIn,\n authenticated = true,\n orderTypeLabel = \"Market\",\n balance = 500,\n positions = { up: 24, down: 16 },\n quickAmounts = DEFAULT_QUICK_AMOUNTS,\n minTrade = 1,\n className,\n classNames,\n}: PredictionMarketProps) {\n const inputId = useId();\n const reduce = useReducedMotion() ?? false;\n const amountRef = useRef(null);\n const timeoutRef = useRef | null>(null);\n const [status, setStatus] = useState<\"idle\" | \"placing\" | \"filled\">(\"idle\");\n const [shakeKey, setShakeKey] = useState(0);\n const [order, setOrder] = useControllableOrder({\n value,\n defaultValue,\n outcomes,\n onValueChange,\n });\n\n const selectedOutcome =\n outcomes.find((outcome) => outcome.id === order.outcomeId) ?? outcomes[0];\n const position = positions[selectedOutcome.id] ?? 0;\n const quote = useMemo(\n () =>\n buildQuote({\n order,\n outcome: selectedOutcome,\n balance,\n position,\n minTrade,\n }),\n [balance, minTrade, order, position, selectedOutcome],\n );\n\n const setOrderValue = useCallback(\n (next: Partial) => {\n if (timeoutRef.current) {\n clearTimeout(timeoutRef.current);\n timeoutRef.current = null;\n }\n\n setStatus(\"idle\");\n setOrder({ ...order, ...next });\n },\n [order, setOrder],\n );\n\n useEffect(() => {\n return () => {\n if (timeoutRef.current) clearTimeout(timeoutRef.current);\n };\n }, []);\n\n useEffect(() => {\n if (shakeKey === 0 || reduce || !amountRef.current) return;\n animate(\n amountRef.current,\n { x: [0, -5, 5, -3, 3, -1, 0] },\n { duration: 0.38, ease: EASE_OUT },\n );\n }, [reduce, shakeKey]);\n\n const addAmount = (increment: number) => {\n const next = parseAmount(order.amount) + increment;\n setOrderValue({ amount: String(next) });\n };\n\n const setMax = () => {\n if (order.mode === \"buy\") {\n setOrderValue({ amount: String(Math.floor(balance)) });\n return;\n }\n\n setOrderValue({ amount: position.toFixed(position % 1 === 0 ? 0 : 2) });\n };\n\n const submit = () => {\n if (!authenticated) {\n onSignIn?.();\n return;\n }\n\n if (!quote.valid) {\n setShakeKey((key) => key + 1);\n return;\n }\n\n setStatus(\"placing\");\n timeoutRef.current = setTimeout(() => {\n setStatus(\"filled\");\n onTrade?.(order, quote);\n }, 650);\n };\n\n const inputSize = amountInputSize(order.amount);\n const payoutSize = payoutTickerSize(quote.payout);\n const actionState: ButtonState =\n status === \"placing\"\n ? \"loading\"\n : status === \"filled\"\n ? \"success\"\n : quote.valid\n ? \"idle\"\n : \"error\";\n const showFooter = authenticated;\n\n return (\n \n \n
\n \n setOrderValue({\n mode: mode as PredictionMarketMode,\n amount: \"\",\n })\n }\n variant=\"underline\"\n className={cn(\"shrink-0\", classNames?.tabs)}\n >\n \n {MODES.map((mode) => (\n \n {mode.label}\n \n ))}\n \n \n\n \n {orderTypeLabel}\n \n \n
\n \n\n
\n setOrderValue({ outcomeId })}\n variant=\"pill\"\n className={classNames?.outcomes}\n >\n \n {outcomes.map((outcome) => {\n const selected = outcome.id === selectedOutcome.id;\n const isNo =\n outcome.label.toLowerCase() === \"no\" ||\n outcome.label.toLowerCase() === \"down\";\n\n return (\n \n {outcome.label} {formatCents(outcome.price)}\n \n );\n })}\n \n \n\n \n
\n \n {order.mode === \"buy\" ? \"Amount\" : \"Shares\"}\n \n\n
\n setOrderValue({ amount })}\n />\n
\n
\n\n \n {quickAmounts.map((amount) => (\n addAmount(amount)}\n className=\"h-9 rounded-xl bg-background px-3.5 text-sm font-semibold text-foreground transition-[background-color,transform] duration-150 active:scale-95 disabled:pointer-events-none disabled:opacity-50\"\n >\n +{order.mode === \"buy\" ? formatCompactCurrency(amount) : amount}\n \n ))}\n \n Max\n \n
\n \n \n\n {showFooter ? (\n \n
\n
\n
\n {order.mode === \"buy\" ? \"To win\" : \"To receive\"}\n \n
\n

\n Avg. Price {formatCents(quote.price)}\n

\n
\n formatCurrency(cents / 100)}\n />\n
\n\n \n Trade\n \n \n ) : (\n
\n \n Connect\n \n
\n )}\n \n );\n}\n" } ], "categories": [ "motion-kit" ], "meta": { "group": "motion", "docs": "https://dxuireact.com/docs/components/prediction-market" } }