Mikkel Manniche
Dansk English Deutsch

Widgets

Trade ticket

Kaufe oder verkaufe eines von zwei Ergebnissen, mit Betragsknöpfen und dem Gewinn, der beim Tippen berechnet wird.

Vorschau

Verwendung

trade-ticket-demo.tsx
import { useState } from 'react'
import { TradeTicket, type TradeOrder } from '@/components/trade-ticket'

export default function TradeTicketDemo() {
  const [last, setLast] = useState<TradeOrder | null>(null)

  return (
    <div className="flex flex-col items-center gap-3">
      <p className="max-w-sm text-center text-sm font-medium">Will the spring collection sell out before Friday?</p>
      <TradeTicket
        outcomes={[
          { id: 'yes', label: 'Yes', price: 0.62 },
          { id: 'no', label: 'No', price: 0.38 },
        ]}
        balance={250}
        onTrade={setLast}
      />
      <p className="min-h-5 text-sm text-muted-foreground" aria-live="polite">
        {last && `${last.side === 'buy' ? 'Bought' : 'Sold'} ${last.shares.toFixed(1)} × ${last.outcome} for $${last.amount}`}
      </p>
    </div>
  )
}

Quellcode

trade-ticket.tsx
import { LayoutGroup, MotionConfig, motion } from 'motion/react'
import { useId, useState, type SubmitEvent } from 'react'
import { cn } from '@/lib/utils'

export type Outcome = { id: string; label: string; /** Price per share from 0 to 1. A share pays 1 if the outcome happens. */ price: number }

export type TradeOrder = { side: 'buy' | 'sell'; outcome: string; amount: number; shares: number }

export type TradeTicketProps = {
  /** Two outcomes, usually yes and no. */
  outcomes: [Outcome, Outcome]
  /** Most someone can spend; the Max chip uses it. */
  balance: number
  currency?: string
  locale?: string
  onTrade: (order: TradeOrder) => void
  labels?: Partial<Record<'buy' | 'sell' | 'amount' | 'toWin' | 'toGet' | 'avg' | 'trade' | 'max', string>>
  className?: string
}

const EN = { buy: 'Buy', sell: 'Sell', amount: 'Amount', toWin: 'To win', toGet: 'You get', avg: 'Avg. price', trade: 'Trade', max: 'Max' }
const CHIPS = [1, 5, 10, 100]

/** A ticket for buying or selling one of two outcomes, with the payout worked out as you type. */
export function TradeTicket({ outcomes, balance, currency = 'USD', locale = 'en-US', onTrade, labels = {}, className }: TradeTicketProps) {
  const t = { ...EN, ...labels }
  const [side, setSide] = useState<'buy' | 'sell'>('buy')
  const [pick, setPick] = useState(outcomes[0].id)
  const [raw, setRaw] = useState('')
  const ids = useId()

  const money = new Intl.NumberFormat(locale, { style: 'currency', currency })
  const symbol = money.formatToParts(0).find((p) => p.type === 'currency')?.value ?? ''
  const cents = (n: number) => `${(n * 100).toFixed(1)}¢`
  const outcome = outcomes.find((o) => o.id === pick)!
  const amount = Math.min(balance, Math.max(0, Number(raw.replace(',', '.')) || 0))
  const shares = outcome.price > 0 ? amount / outcome.price : 0
  // Buying: each share pays out 1 if it happens. Selling: you get the price back now.
  const result = side === 'buy' ? shares : amount

  const add = (n: number) => setRaw(String(Math.min(balance, amount + n)))
  const submit = (e: SubmitEvent<HTMLFormElement>) => {
    e.preventDefault()
    if (amount > 0) onTrade({ side, outcome: pick, amount, shares })
  }

  return (
    <MotionConfig reducedMotion="user" transition={{ type: 'spring', bounce: 0, duration: 0.35 }}>
      <form onSubmit={submit} className={cn('w-full max-w-sm overflow-hidden rounded-3xl border bg-card text-card-foreground', className)}>
        <LayoutGroup id={`${ids}-side`}>
          <div role="group" aria-label={`${t.buy} / ${t.sell}`} className="flex gap-4 border-b px-4">
            {(['buy', 'sell'] as const).map((s) => (
              <button
                key={s}
                type="button"
                aria-pressed={side === s}
                onClick={() => setSide(s)}
                className={cn('relative min-h-12 text-lg font-semibold transition-colors duration-150', side === s ? 'text-foreground' : 'text-muted-foreground hover:text-foreground')}
              >
                {t[s]}
                {side === s && <motion.span layoutId="underline" className="absolute inset-x-0 -bottom-px h-0.5 rounded-full bg-foreground" />}
              </button>
            ))}
          </div>
        </LayoutGroup>

        <div className="space-y-3 p-3">
          <LayoutGroup id={`${ids}-outcome`}>
            <div role="group" aria-label="Outcome" className="grid grid-cols-2 gap-1 rounded-2xl bg-muted p-1">
              {outcomes.map((o, i) => {
                const on = pick === o.id
                return (
                  <button
                    key={o.id}
                    type="button"
                    aria-pressed={on}
                    onClick={() => setPick(o.id)}
                    className={cn(
                      'relative min-h-12 rounded-xl text-sm font-semibold transition-colors duration-150',
                      on ? (i === 0 ? 'text-success' : 'text-destructive') : 'text-muted-foreground hover:text-foreground',
                    )}
                  >
                    {on && (
                      <motion.span
                        layoutId="pill"
                        style={{ borderRadius: 12 }}
                        className={cn('absolute inset-0', i === 0 ? 'bg-success/15' : 'bg-destructive/15')}
                      />
                    )}
                    <span className="relative">
                      {o.label} {cents(o.price)}
                    </span>
                  </button>
                )
              })}
            </div>
          </LayoutGroup>

          <div className="rounded-2xl bg-muted/60 px-4 pt-4 pb-3 text-center">
            <label htmlFor={`${ids}-amount`} className="text-sm font-medium">
              {t.amount}
            </label>
            <div className="mt-1 flex items-baseline justify-center font-mono text-5xl font-semibold tabular-nums">
              <span className="text-muted-foreground">{symbol}</span>
              <input
                id={`${ids}-amount`}
                inputMode="decimal"
                autoComplete="off"
                placeholder="0"
                value={raw}
                onChange={(e) => setRaw(e.target.value.replace(/[^\d.,]/g, ''))}
                style={{ width: `${Math.max(1, raw.length || 1)}ch` }}
                className="min-w-[1ch] bg-transparent text-center outline-none placeholder:text-muted-foreground/50"
              />
            </div>
            <div className="mt-4 flex flex-wrap justify-center gap-1.5">
              {CHIPS.map((n) => (
                <button key={n} type="button" onClick={() => add(n)} className="min-h-10 rounded-xl bg-background px-3 text-sm font-semibold transition-[background-color,transform] duration-150 hover:bg-accent active:scale-[0.96]">
                  +{symbol}
                  {n}
                </button>
              ))}
              <button type="button" onClick={() => setRaw(String(balance))} className="min-h-10 rounded-xl bg-background px-3 text-sm font-semibold transition-[background-color,transform] duration-150 hover:bg-accent active:scale-[0.96]">
                {t.max}
              </button>
            </div>
          </div>
        </div>

        <div className="flex items-end justify-between gap-3 border-t px-4 pt-3">
          <div>
            <p className="font-semibold">{side === 'buy' ? t.toWin : t.toGet}</p>
            <p className="text-sm text-muted-foreground">
              {t.avg} {cents(outcome.price)}
            </p>
          </div>
          <output className={cn('font-mono text-3xl font-semibold tabular-nums', result > 0 ? 'text-success' : 'text-muted-foreground')}>
            {money.format(result)}
          </output>
        </div>
        <div className="p-4 pt-3">
          <button
            type="submit"
            disabled={amount <= 0}
            className="min-h-12 w-full rounded-2xl bg-foreground font-semibold text-background transition-[opacity,transform] duration-150 active:scale-[0.98] disabled:opacity-40"
          >
            {t.trade}
          </button>
        </div>
      </form>
    </MotionConfig>
  )
}

Installieren

npx shadcn@latest add https://mikkelmanniche.dk/lab/r/trade-ticket.json

Mit eingetragener Registry (components.json)

npx shadcn@latest add @manniche/trade-ticket

Abhängigkeiten

Dateien

  • trade-ticket.tsx