Skip to contentVibraUI

Trade

Buy or sell a coin by its amount or by dollars, each field rewriting the other from the coin's price, the order placed through the server; reads coins().

Preview

Install

npx shadcn@latest add @vibra/widget-finance-crypto-trade-form

Needs the @vibra registry in your components.json — set it up once.

Source

app/finance/crypto/components/trade-form.tsx
"use client"

import * as React from "react"

import { formatCurrency } from "@/lib/format"
import { AsyncButton } from "@/components/ui/async-button"
import { Callout } from "@/components/ui/callout"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Label } from "@/components/ui/label"
import { NumberInput } from "@/components/ui/number-input"
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select"
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"

import { placeOrder } from "../actions"
import { type Coin, type TradeRow } from "../data"

import { TRADE_AMOUNT_ID } from "./crypto-vocabulary"

export type TradeFormProps = {
  coins: Coin[]
  /**
   * An order the server settled; the caller owns the ledger, this form does
   * not. Drawn alone there is no ledger beside it, and the order still lands
   * in the store and in this form's status line.
   */
  onPlaced?: (trade: TradeRow) => void
}

type Kind = "buy" | "sell"

const AMOUNT_DECIMALS = 6

/**
 * Buy or sell. The two amount fields are one number read two ways: typing in
 * either rewrites the other from the coin's price, and each is rounded to its
 * own precision so a figure typed into one never comes back reworded in the
 * other. The server's answer is printed here — a refusal in an alert beside
 * the fields, a success in the status line — and the settled order is handed
 * up to whoever keeps the activity list.
 */
export function TradeForm({ coins, onPlaced }: TradeFormProps) {
  const [kind, setKind] = React.useState<Kind>("buy")
  const [symbol, setSymbol] = React.useState(coins[0]?.symbol ?? "")
  const [amount, setAmount] = React.useState<number | null>(null)
  const [usd, setUsd] = React.useState<number | null>(null)
  const [refusal, setRefusal] = React.useState("")
  const [notice, setNotice] = React.useState("")
  const [pending, setPending] = React.useState(false)

  const coin = coins.find((entry) => entry.symbol === symbol) ?? coins[0]
  const price = coin ? coin.priceCents / 100 : 0

  function fromAmount(next: number | null) {
    setAmount(next)
    setUsd(next === null ? null : Math.round(next * price * 100) / 100)
  }

  function fromUsd(next: number | null) {
    setUsd(next)
    setAmount(next === null || price === 0 ? null : Math.round((next / price) * 10 ** AMOUNT_DECIMALS) / 10 ** AMOUNT_DECIMALS)
  }

  function pickCoin(next: string) {
    setSymbol(next)
    // The dollars follow the coin: the same amount of a different asset is a
    // different sum.
    const chosen = coins.find((entry) => entry.symbol === next)
    if (chosen && amount !== null) setUsd(Math.round((amount * chosen.priceCents) / 100 * 100) / 100)
  }

  async function submit(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault()
    setRefusal("")
    setNotice("")
    setPending(true)
    const result = await placeOrder({ symbol, kind, amount: amount ?? 0 })
    setPending(false)
    if (!result.ok) return setRefusal(result.error.message)
    onPlaced?.(result.data)
    const verb = result.data.kind === "buy" ? "Bought" : "Sold"
    setNotice(
      `${verb} ${result.data.amount} ${result.data.symbol} for ${formatCurrency(result.data.valueCents / 100, "USD", { maximumFractionDigits: 0 })}.`
    )
    setAmount(null)
    setUsd(null)
  }

  return (
    <Card data-widget="widget-finance-crypto-trade-form" role="region" aria-label="Trade" className="h-full">
      <CardHeader>
        <CardTitle>Trade</CardTitle>
        <CardDescription>{coin ? `${coin.asset} at ${formatCurrency(price)}` : "Pick a coin."}</CardDescription>
      </CardHeader>
      <CardContent className="flex flex-col">
        <form onSubmit={submit} noValidate className="flex flex-1 flex-col gap-4">
          <Tabs value={kind} onValueChange={(next) => setKind(next as Kind)}>
            <TabsList className="w-full" aria-label="Order kind">
              <TabsTrigger value="buy">Buy</TabsTrigger>
              <TabsTrigger value="sell">Sell</TabsTrigger>
            </TabsList>
          </Tabs>

          <div className="flex flex-col gap-1.5">
            <Label htmlFor="crypto-trade-coin">Coin</Label>
            <Select
              value={symbol}
              onValueChange={(next) => pickCoin(String(next))}
              items={coins.map((entry) => ({ value: entry.symbol, label: `${entry.asset} · ${entry.symbol}` }))}
            >
              <SelectTrigger id="crypto-trade-coin" aria-label="Coin" className="w-full">
                <SelectValue />
              </SelectTrigger>
              <SelectContent>
                {coins.map((entry) => (
                  <SelectItem key={entry.symbol} value={entry.symbol}>
                    {`${entry.asset} · ${entry.symbol}`}
                  </SelectItem>
                ))}
              </SelectContent>
            </Select>
          </div>

          <div className="grid grid-cols-2 gap-3">
            <div className="flex flex-col gap-1.5">
              <Label htmlFor={TRADE_AMOUNT_ID}>{`Amount (${symbol})`}</Label>
              <NumberInput
                id={TRADE_AMOUNT_ID}
                aria-label={`Amount (${symbol})`}
                value={amount}
                onValueChange={fromAmount}
                min={0}
                step={0.01}
                precision={AMOUNT_DECIMALS}
                hideControls
                placeholder="0"
                aria-invalid={refusal ? true : undefined}
              />
            </div>
            <div className="flex flex-col gap-1.5">
              <Label htmlFor="crypto-trade-usd">Amount (USD)</Label>
              <NumberInput
                id="crypto-trade-usd"
                aria-label="Amount (USD)"
                value={usd}
                onValueChange={fromUsd}
                min={0}
                step={10}
                precision={2}
                prefix="$"
                hideControls
                placeholder="0"
              />
            </div>
          </div>

          {refusal ? (
            <Callout variant="danger" role="alert">
              {refusal}
            </Callout>
          ) : null}

          <AsyncButton type="submit" loading={pending} className="mt-auto w-full">
            Place order
          </AsyncButton>

          <p role="status" aria-live="polite" className="min-h-4 text-xs text-muted-foreground">
            {notice}
          </p>
        </form>
      </CardContent>
    </Card>
  )
}
app/finance/crypto/data.ts
/**
 * What this page reads. Every holding comes from `db.holdings` and every
 * movement from `db.trades`; the balance, the wallet groups, the three
 * totals and the seven-month flow are rules over those rows, so nothing here
 * is a literal. "Now" is `REFERENCE_DATE`.
 *
 * The client islands import only the types below. The words the page puts on
 * a trade's kind live beside them in `components/crypto-vocabulary.ts`, so
 * nothing that reaches `db` is ever pulled into the browser.
 */
import { getInitials } from "@/lib/format"
import { REFERENCE_DATE, db, type Holding, type Member, type Trade } from "@/lib/sample-data"

export type TradeKind = Trade["kind"]
export type Wallet = Holding["wallet"]

/** One holding as the holdings table prints it. */
export type HoldingRow = {
  id: string
  asset: string
  symbol: string
  /** The letter in the tinted circle, in place of the coin's mark. */
  initial: string
  amount: number
  priceCents: number
  change24h: number
  wallet: Wallet
  /** What the holding is worth now, in cents. */
  valueCents: number
}

/** One movement as the activity list prints it. Exported because the order returns one. */
export type TradeRow = {
  id: string
  asset: string
  symbol: string
  initial: string
  kind: TradeKind
  amount: number
  priceCents: number
  valueCents: number
  at: Date
  status: Trade["status"]
}

// Read per call, never held at module scope: a holding bought into since the
// server started names its trades on the next render.
const assetsBySymbol = () => new Map(db.holdings.all().map((row) => [row.symbol, row.asset]))

const initialOf = (asset: string) => asset.charAt(0).toUpperCase()

export function toHoldingRow(row: Holding): HoldingRow {
  return {
    id: row.id,
    asset: row.asset,
    symbol: row.symbol,
    initial: initialOf(row.asset),
    amount: row.amount,
    priceCents: row.priceCents,
    change24h: row.change24h,
    wallet: row.wallet,
    valueCents: Math.round(row.amount * row.priceCents),
  }
}

export function toTradeRow(row: Trade, assets = assetsBySymbol()): TradeRow {
  const asset = assets.get(row.symbol) ?? row.symbol
  return {
    id: row.id,
    asset,
    symbol: row.symbol,
    initial: initialOf(asset),
    kind: row.kind,
    amount: row.amount,
    priceCents: row.priceCents,
    valueCents: Math.round(row.amount * row.priceCents),
    at: row.at,
    status: row.status,
  }
}

export type Overview = {
  transactions: number
  wallets: number
  balanceCents: number
  /** What the last day's prices did to the balance, in cents: each holding's value times its own move. */
  move24hCents: number
  /** The same move as a share of the balance — the last day's change, weighted by value. */
  change24h: number
}

/** The headline card: how many movements, how many wallets, and what it all adds up to. */
export function overview(): Overview {
  const holdings = db.holdings.all()
  const balanceCents = holdings.reduce((sum, row) => sum + row.amount * row.priceCents, 0)
  const move24hCents = holdings.reduce((sum, row) => sum + row.amount * row.priceCents * row.change24h, 0)
  return {
    transactions: db.trades.all().length,
    wallets: new Set(holdings.map((row) => row.wallet)).size,
    balanceCents,
    move24hCents,
    change24h: balanceCents === 0 ? 0 : move24hCents / balanceCents,
  }
}

export type WalletGroup = { wallet: Wallet; rows: HoldingRow[]; totalCents: number }

const WALLET_ORDER: Wallet[] = ["Main", "Savings", "Trading"]

/** Every holding under its wallet, the largest holding first inside each. */
export function walletGroups(): WalletGroup[] {
  const rows = db.holdings.all().map(toHoldingRow)
  return WALLET_ORDER.filter((wallet) => rows.some((row) => row.wallet === wallet)).map((wallet) => {
    const inWallet = rows.filter((row) => row.wallet === wallet).sort((a, b) => b.valueCents - a.valueCents)
    return { wallet, rows: inWallet, totalCents: inWallet.reduce((sum, row) => sum + row.valueCents, 0) }
  })
}

export type WalletSummary = {
  wallet: Wallet
  /** How many assets the wallet holds. */
  assets: number
  totalCents: number
  /** The wallet's last-day move, weighted by value. */
  change24h: number
  /** The largest holding in the wallet, and its share of the wallet. */
  top: { asset: string; share: number }
}

/** Each wallet on one line: what it holds, what that is worth, and how the day treated it. */
export function walletSummaries(): WalletSummary[] {
  return walletGroups().map((group) => {
    const [top] = group.rows
    const weighted = group.rows.reduce((sum, row) => sum + row.valueCents * row.change24h, 0)
    return {
      wallet: group.wallet,
      assets: group.rows.length,
      totalCents: group.totalCents,
      change24h: group.totalCents === 0 ? 0 : weighted / group.totalCents,
      top: {
        asset: top?.asset ?? "",
        share: top && group.totalCents > 0 ? top.valueCents / group.totalCents : 0,
      },
    }
  })
}

/** Every holding in every wallet, the largest first — the table under the desk. */
export function holdings(): HoldingRow[] {
  return db.holdings
    .all()
    .map(toHoldingRow)
    .sort((a, b) => b.valueCents - a.valueCents)
}

export type Coin = { symbol: string; asset: string; priceCents: number }

/** The assets the trade form offers: one entry per symbol, at today's price. */
export function coins(): Coin[] {
  const seen = new Map<string, Coin>()
  for (const row of db.holdings.all()) {
    if (!seen.has(row.symbol)) seen.set(row.symbol, { symbol: row.symbol, asset: row.asset, priceCents: row.priceCents })
  }
  return [...seen.values()]
}

/** Every movement, newest first. */
export function recentTrades(): TradeRow[] {
  const assets = assetsBySymbol()
  return db.trades
    .all()
    .sort((a, b) => b.at.getTime() - a.at.getTime())
    .map((row) => toTradeRow(row, assets))
}

export type BalanceSummary = { receivedCents: number; sentCents: number; withdrawnCents: number }

const valueOf = (rows: Trade[], kind: TradeKind) =>
  rows.filter((row) => row.kind === kind).reduce((sum, row) => sum + row.amount * row.priceCents, 0)

/** What came in from other wallets, what went out to them, and what was sold for cash. */
export function balanceSummary(): BalanceSummary {
  const rows = db.trades.all()
  return {
    receivedCents: valueOf(rows, "receive"),
    sentCents: valueOf(rows, "send"),
    withdrawnCents: valueOf(rows, "sell"),
  }
}

const MONTH = new Intl.DateTimeFormat("en-US", { month: "short", timeZone: "UTC" })

/** The first of the month `back` months before the reference month. */
function monthStart(back: number): Date {
  return new Date(Date.UTC(REFERENCE_DATE.getUTCFullYear(), REFERENCE_DATE.getUTCMonth() - back, 1))
}

export type FlowPoint = { month: string; received: number; sent: number; withdrawn: number }

/** The three flows by month over the last seven months, in dollars, oldest first. */
export function flowByMonth(): FlowPoint[] {
  const rows = db.trades.all()
  return Array.from({ length: 7 }, (_, index) => 6 - index).map((back) => {
    const start = monthStart(back)
    const end = monthStart(back - 1)
    const inMonth = rows.filter((row) => row.at >= start && row.at < end)
    return {
      month: MONTH.format(start),
      received: Math.round(valueOf(inMonth, "receive") / 100),
      sent: Math.round(valueOf(inMonth, "send") / 100),
      withdrawn: Math.round(valueOf(inMonth, "sell") / 100),
    }
  })
}

/** The bell's contents: the newest notifications, unread first in the panel. */
export function shellNotifications() {
  return db.notifications
    .all()
    .sort((a, b) => b.at.getTime() - a.at.getTime())
    .slice(0, 6)
    .map(({ id, title, description, at, read, href }) => ({ id, title, description, at, read, href }))
}

function ownerRow(): Member {
  return db.members.all().find((member) => member.role === "owner") ?? db.members.all()[0]
}

/** The person looking at the page: whoever owns this workspace. */
export function currentUser() {
  const owner = ownerRow()
  return { name: owner.name, email: owner.email, initials: getInitials(owner.name), avatarUrl: owner.avatarUrl }
}
app/finance/crypto/actions.ts
"use server"

import { mockAuthAdapter } from "@/lib/auth-adapter"
import { REFERENCE_DATE, db, fields, isAmount, oneOf, type Result } from "@/lib/sample-data"

import { toTradeRow, type TradeRow } from "./data"

/**
 * The two things this page changes. Server actions, so the page can stay a
 * server component and still hand its client islands something to call, and
 * `Result` so every caller reads one success-or-error shape.
 */

export async function signOut(): Promise<Result<{ signedOut: true }>> {
  await mockAuthAdapter.signOut()
  return { ok: true, data: { signedOut: true } }
}

export type OrderInput = { symbol: string; kind: "buy" | "sell"; amount: number }

// The wallet a buy lands in, and the first one a sale is taken from.
const TRADING_WALLET = "Trading"

/** The most one order can move, in cents: a desk's ticket, not a number past what a balance can print. */
const MAX_ORDER_CENTS = 100_000_000

/**
 * Buys or sells an asset the wallets hold, at the holding's own price — the
 * form's idea of the price is never read. A buy lands in the trading wallet;
 * a sale comes out of the trading wallet first and the others after it, and
 * is refused when the wallets do not hold enough between them.
 */
export async function placeOrder(input: OrderInput): Promise<Result<TradeRow>> {
  const { symbol: asked, kind, amount: sent } = fields(input)
  // Six places, as a holding is kept; an amount that rounds to nothing is none.
  const amount = isAmount(sent, 0, Number.MAX_SAFE_INTEGER) ? round(sent) : 0
  if (amount <= 0) {
    return { ok: false, error: { code: "invalid", message: "Enter an amount above zero.", field: "amount" } }
  }
  if (!oneOf(kind, ["buy", "sell"] as const)) {
    return { ok: false, error: { code: "invalid", message: "An order is a buy or a sell.", field: "kind" } }
  }

  const held = typeof asked === "string" ? db.holdings.all().filter((row) => row.symbol === asked) : []
  if (held.length === 0) {
    return {
      ok: false,
      error: { code: "not_found", message: "No wallet holds that asset.", field: "symbol" },
    }
  }
  const { priceCents, symbol } = held[0]
  const total = held.reduce((sum, row) => sum + row.amount, 0)

  // Bounded by what it is worth: 1e308 units once made the balance Infinity.
  if (amount * priceCents > MAX_ORDER_CENTS) {
    return {
      ok: false,
      error: { code: "too_large", message: "One order moves at most $1,000,000. Split it into smaller ones.", field: "amount" },
    }
  }

  if (kind === "sell" && amount > total) {
    return {
      ok: false,
      error: {
        code: "insufficient",
        message: `You hold ${total} ${symbol} across the wallets, so ${amount} cannot be sold.`,
        field: "amount",
      },
    }
  }

  if (kind === "buy") {
    const target = held.find((row) => row.wallet === TRADING_WALLET) ?? held[0]
    const updated = await db.holdings.update(target.id, { amount: round(target.amount + amount) })
    if (!updated.ok) return updated
  } else {
    // Drain the trading wallet first, then whichever holdings are left.
    let remaining = amount
    const order = [...held].sort((a, b) => (a.wallet === TRADING_WALLET ? -1 : b.wallet === TRADING_WALLET ? 1 : 0))
    for (const row of order) {
      if (remaining <= 0) break
      const taken = Math.min(row.amount, remaining)
      const updated = await db.holdings.update(row.id, { amount: round(row.amount - taken) })
      if (!updated.ok) return updated
      remaining = round(remaining - taken)
    }
  }

  const created = await db.trades.create({
    symbol,
    kind,
    amount,
    priceCents,
    at: REFERENCE_DATE,
    status: "settled",
  })
  if (!created.ok) return created
  return { ok: true, data: toTradeRow(created.data) }
}

/** Kept to six places, so a holding never carries float dust after a sale. */
function round(value: number): number {
  return Math.round(value * 1e6) / 1e6
}
app/finance/crypto/components/crypto-vocabulary.ts
import { type StatusVariant } from "@/components/ui/status-badge"

/**
 * The words this page puts on a trade. Vocabulary, not data — it lives beside
 * the islands that print it rather than in `data.ts`, which reads `db` and
 * must never reach the browser.
 */
export const KIND_LABELS: Record<string, string> = {
  buy: "Buy",
  sell: "Sell",
  send: "Send",
  receive: "Receive",
}

export const KIND_MAP: Record<string, StatusVariant> = {
  buy: "success",
  sell: "info",
  send: "warning",
  receive: "neutral",
}

/** The id the trade form's amount field carries, so the overview's Buy button can focus it. */
export const TRADE_AMOUNT_ID = "crypto-trade-amount"

export const WHEN = new Intl.DateTimeFormat("en-US", {
  month: "short",
  day: "numeric",
  year: "numeric",
  hour: "numeric",
  minute: "2-digit",
  timeZone: "UTC",
})

Its page

On its page the card sits among the rest of the dashboard and shares its range and its data with them.

From the Crypto dashboard page