/finance/cryptoCrypto dashboard
The wallets: an overview of every holding added up, each wallet on one line with its share of the balance, a buy-and-sell desk that converts the amount to dollars as it is typed, the ledger newest first, the three flows by month, and every holding in a table.
Installs at /finance/crypto. The page is a server component inside AppShell: it reads every holding through db.holdings and every movement through db.trades, and everything on it is a rule over those rows — the balance is amount times price across the wallets, the day's move is each holding's value times its own 24-hour change, each wallet's line is its holdings added up with the largest named, the three totals are the ledger by kind, and the seven-month chart is the same three flows by month. No coin carries a logo: an initial in an ink circle stands for each asset. The trade form and the activity list share one ledger in a client island, so an order the server settles through placeOrder is the first row of the ledger before the form has cleared; the form converts the amount to dollars and back from the holding's own price, and the action reads that price again rather than trusting the form. An order for nothing, a sale of more than the wallets hold, or an asset they do not hold is refused in an alert beside the fields. The holdings table under the desk lists every asset in every wallet, largest first, and has no state, so it renders on the server. Composes AppShell, PageHeader, DashboardGrid, Card, MetricDelta, PercentageBar, Tabs, Select, NumberInput, Label, AsyncButton, Callout, DataList, StatusBadge, StatCardGroup, StatCard, LineChart, Widget, SimpleTable, Badge and Button.
Preview
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { DashboardGrid, DashboardGridItem } from "@/components/ui/dashboard-grid"
import { PageHeader } from "@/components/ui/page-header"
import { signOut } from "./actions"
import { BalanceSummaryPanel } from "./components/balance-summary"
import { CryptoOverview } from "./components/crypto-overview"
import { HoldingsTable } from "./components/holdings-table"
import { TradingDesk } from "./components/trading-desk"
import { WalletsList } from "./components/wallets-list"
import {
balanceSummary,
coins,
currentUser,
flowByMonth,
holdings,
overview,
recentTrades,
shellNotifications,
walletSummaries,
} from "./data"
import { NAV, ROUTES } from "@/lib/dashboards/finance/nav"
/**
* The wallets. The page is a server component: it reads every holding and
* every movement through `db`, adds them up, and hands plain rows to the
* islands that need state — the overview's Buy button, the trade form and
* the ledger it prepends to. The holdings table under the desk has no state
* at all, so it stays on the server beside the wallet card.
*/
export default function CryptoPage() {
const summary = overview()
return (
<AppShell
nav={NAV}
activeHref={ROUTES.crypto}
user={currentUser()}
notifications={shellNotifications()}
now={REFERENCE_DATE}
onSignOut={signOut}
>
<PageHeader
title="Crypto"
description="Every wallet, every movement, and a desk to trade from."
meta={`${summary.transactions} transactions across ${summary.wallets} wallets`}
/>
<DashboardGrid>
<TradingDesk
trades={recentTrades()}
coins={coins()}
overview={<CryptoOverview overview={summary} linked />}
wallets={<WalletsList wallets={walletSummaries()} />}
summary={<BalanceSummaryPanel summary={balanceSummary()} flow={flowByMonth()} />}
/>
<DashboardGridItem colSpan={12}>
<HoldingsTable rows={holdings()} />
</DashboardGridItem>
</DashboardGrid>
</AppShell>
)
}Install
npx shadcn@latest add @vibra/finance-cryptoNeeds the @vibra registry in your components.json — set it up once.
Source
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { DashboardGrid, DashboardGridItem } from "@/components/ui/dashboard-grid"
import { PageHeader } from "@/components/ui/page-header"
import { signOut } from "./actions"
import { BalanceSummaryPanel } from "./components/balance-summary"
import { CryptoOverview } from "./components/crypto-overview"
import { HoldingsTable } from "./components/holdings-table"
import { TradingDesk } from "./components/trading-desk"
import { WalletsList } from "./components/wallets-list"
import {
balanceSummary,
coins,
currentUser,
flowByMonth,
holdings,
overview,
recentTrades,
shellNotifications,
walletSummaries,
} from "./data"
import { NAV, ROUTES } from "@/lib/dashboards/finance/nav"
/**
* The wallets. The page is a server component: it reads every holding and
* every movement through `db`, adds them up, and hands plain rows to the
* islands that need state — the overview's Buy button, the trade form and
* the ledger it prepends to. The holdings table under the desk has no state
* at all, so it stays on the server beside the wallet card.
*/
export default function CryptoPage() {
const summary = overview()
return (
<AppShell
nav={NAV}
activeHref={ROUTES.crypto}
user={currentUser()}
notifications={shellNotifications()}
now={REFERENCE_DATE}
onSignOut={signOut}
>
<PageHeader
title="Crypto"
description="Every wallet, every movement, and a desk to trade from."
meta={`${summary.transactions} transactions across ${summary.wallets} wallets`}
/>
<DashboardGrid>
<TradingDesk
trades={recentTrades()}
coins={coins()}
overview={<CryptoOverview overview={summary} linked />}
wallets={<WalletsList wallets={walletSummaries()} />}
summary={<BalanceSummaryPanel summary={balanceSummary()} flow={flowByMonth()} />}
/>
<DashboardGridItem colSpan={12}>
<HoldingsTable rows={holdings()} />
</DashboardGridItem>
</DashboardGrid>
</AppShell>
)
}/**
* 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 }
}"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
}"use client"
import * as React from "react"
import { formatCurrency, formatNumber } from "@/lib/format"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { DataList, DataListItem } from "@/components/ui/data-list"
import { StatusBadge } from "@/components/ui/status-badge"
import { type TradeRow } from "../data"
import { KIND_LABELS, KIND_MAP, WHEN } from "./crypto-vocabulary"
import { AssetInitial } from "./asset-initial"
const SHOWN = 6
/**
* The ledger, newest first: six rows, or all of them once "View all" is
* pressed. The rows are the caller's — a settled order lands here from the
* trade form — so this list only decides how many to show.
*/
export function ActivityList({ trades }: { trades: TradeRow[] }) {
const [all, setAll] = React.useState(false)
const rows = all ? trades : trades.slice(0, SHOWN)
return (
<Card
data-widget="widget-finance-crypto-activity-list"
role="region"
aria-label="Recent activity"
className="h-full"
>
<CardHeader>
<CardTitle>Recent activity</CardTitle>
<CardDescription>Every movement across the wallets.</CardDescription>
</CardHeader>
<CardContent className="p-0">
<DataList divided>
{rows.map((row) => (
<DataListItem
key={row.id}
leading={<AssetInitial initial={row.initial} asset={row.asset} />}
title={
<span className="flex items-center gap-2">
<span>{row.asset}</span>
<StatusBadge status={row.kind} label={KIND_LABELS[row.kind]} map={KIND_MAP} size="sm" />
{row.status === "pending" ? (
<StatusBadge status="pending" label="Pending" size="sm" />
) : null}
</span>
}
description={WHEN.format(row.at)}
meta={
<span className="flex flex-col items-end gap-0.5">
<span className="text-sm font-medium text-foreground">{`${formatNumber(row.amount, { maximumFractionDigits: 6 })} ${row.symbol}`}</span>
<span>{formatCurrency(row.valueCents / 100)}</span>
</span>
}
/>
))}
</DataList>
</CardContent>
{trades.length > SHOWN ? (
<CardFooter className="justify-center">
<Button variant="outline" size="sm" onClick={() => setAll((current) => !current)}>
{all ? "Show fewer" : "View all"}
</Button>
</CardFooter>
) : null}
</Card>
)
}/** The letter that stands for an asset, in an ink circle, in place of the coin's mark. */
export function AssetInitial({ initial, asset }: { initial: string; asset: string }) {
return (
<span
data-slot="asset-initial"
aria-hidden="true"
title={asset}
className="flex size-8 shrink-0 items-center justify-center rounded-full bg-foreground text-sm font-semibold text-background"
>
{initial}
</span>
)
}"use client"
import { formatCurrency } from "@/lib/format"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { LineChart } from "@/components/ui/line-chart"
import { StatCard } from "@/components/ui/stat-card"
import { StatCardGroup } from "@/components/ui/stat-card-group"
import { type BalanceSummary, type FlowPoint } from "../data"
const compact = (cents: number) => formatCurrency(cents / 100, "USD", { maximumFractionDigits: 0 })
/**
* What came in, what went out and what was sold, as three tiles over the
* same three flows by month. An island only because the chart draws in the
* browser; the numbers arrive computed from the server page.
*/
export function BalanceSummaryPanel({ summary, flow }: { summary: BalanceSummary; flow: FlowPoint[] }) {
return (
<Card
data-widget="widget-finance-crypto-balance-summary"
role="region"
aria-label="Balance summary"
className="h-full"
>
<CardHeader>
<CardTitle>Balance summary</CardTitle>
<CardDescription>The three flows over the last seven months.</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<StatCardGroup columns={3} divided>
<StatCard size="sm" label="Received" value={compact(summary.receivedCents)} description="from other wallets" />
<StatCard size="sm" label="Sent" value={compact(summary.sentCents)} description="to other wallets" />
<StatCard size="sm" label="Withdrawn" value={compact(summary.withdrawnCents)} description="sold for cash" />
</StatCardGroup>
<LineChart
data={flow}
index="month"
series={[
{ key: "received", label: "Received" },
{ key: "sent", label: "Sent" },
{ key: "withdrawn", label: "Withdrawn" },
]}
valueFormatter={(value) => formatCurrency(value, "USD", { compact: true })}
height={260}
/>
</CardContent>
</Card>
)
}"use client"
import { ArrowRightIcon } from "lucide-react"
import { formatCurrency, formatNumber } from "@/lib/format"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { MetricDelta } from "@/components/ui/metric-delta"
import { type Overview } from "../data"
import { TRADE_AMOUNT_ID } from "./crypto-vocabulary"
const money = (cents: number) => formatCurrency(cents / 100, "USD", { maximumFractionDigits: 0 })
/** A signed sum: a leading "+" or a real minus, never a bare figure. */
const signedMoney = (cents: number) => (cents < 0 ? `−${money(-cents)}` : `+${money(cents)}`)
export type CryptoOverviewProps = {
overview: Overview
/**
* The trade form is on the page too, so the Buy button can put the reader
* in it. The page draws both and says so; a card drawn alone does not, and
* draws no button that would press into a form that is not there.
*/
linked?: boolean
}
/**
* The headline card: every wallet added up, with the day's move under the
* balance. Three bands — the counts, the balance, the move with the Buy
* button — each with an equal share of the sheet and a rule between them,
* so the card fills the row it shares with the wallets and the desk instead
* of pooling its spare height in one gap. An island only for the Buy button,
* which puts the reader in the trade form rather than navigating anywhere.
*/
export function CryptoOverview({ overview, linked = false }: CryptoOverviewProps) {
return (
<Card data-widget="widget-finance-crypto-crypto-overview" role="region" aria-label="Overview" className="h-full">
<CardHeader>
<CardTitle>Overview</CardTitle>
<CardDescription>{"Every wallet added up, at today's prices."}</CardDescription>
</CardHeader>
<CardContent className="flex flex-col divide-y divide-rule py-0">
<dl className="grid flex-1 grid-cols-2 content-center gap-3 py-4">
<div className="flex flex-col gap-0.5">
<dt className="type-eyebrow">Transactions</dt>
<dd className="type-numeral text-2xl">{formatNumber(overview.transactions)}</dd>
</div>
<div className="flex flex-col gap-0.5">
<dt className="type-eyebrow">Wallets</dt>
<dd className="type-numeral text-2xl">{formatNumber(overview.wallets)}</dd>
</div>
</dl>
<div className="flex flex-1 flex-col justify-center gap-0.5 py-4">
<span className="type-numeral text-3xl">{money(overview.balanceCents)}</span>
<span className="text-xs text-muted-foreground">Current balance</span>
</div>
<div className="flex flex-1 flex-wrap items-center justify-between gap-3 py-4">
<span className="flex items-center gap-2">
<span className="text-lg font-semibold tabular-nums">
{signedMoney(overview.move24hCents)}
<span className="ml-1.5 text-xs font-normal text-muted-foreground">today</span>
</span>
<MetricDelta value={overview.change24h} variant="pill" size="sm" />
</span>
{linked ? (
<Button
variant="ink"
size="sm"
onClick={() => document.getElementById(TRADE_AMOUNT_ID)?.focus()}
>
Buy
<ArrowRightIcon data-icon="inline-end" />
</Button>
) : null}
</div>
</CardContent>
</Card>
)
}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",
})import { formatCurrency, formatNumber } from "@/lib/format"
import { Badge } from "@/components/ui/badge"
import { MetricDelta } from "@/components/ui/metric-delta"
import { SimpleTable, type SimpleTableColumn } from "@/components/ui/simple-table"
import { Widget } from "@/components/ui/widget"
import { type HoldingRow } from "../data"
import { AssetInitial } from "./asset-initial"
const money = (cents: number, digits = 0) => formatCurrency(cents / 100, "USD", { maximumFractionDigits: digits })
const COLUMNS: SimpleTableColumn<HoldingRow>[] = [
{
key: "asset",
header: "Asset",
cell: (row) => (
<span className="flex items-center gap-3">
<AssetInitial initial={row.initial} asset={row.asset} />
<span className="flex min-w-0 flex-col">
<span className="truncate font-medium">{row.asset}</span>
<span className="text-xs text-muted-foreground">{row.symbol}</span>
</span>
</span>
),
},
{
key: "wallet",
header: "Wallet",
cell: (row) => <Badge variant="outline">{row.wallet}</Badge>,
},
{
key: "amount",
header: "Amount",
align: "right",
cell: (row) => `${formatNumber(row.amount, { maximumFractionDigits: 6 })} ${row.symbol}`,
},
{
key: "priceCents",
header: "Price",
align: "right",
cell: (row) => money(row.priceCents, 2),
},
{
key: "change24h",
header: "24h",
align: "right",
cell: (row) => <MetricDelta value={row.change24h} size="sm" />,
},
{
key: "valueCents",
header: "Value",
align: "right",
className: "font-medium",
cell: (row) => money(row.valueCents),
},
]
/** Every holding in every wallet, largest first — the rows the wallet card adds up. */
export function HoldingsTable({ rows }: { rows: HoldingRow[] }) {
return (
<Widget
data-widget="widget-finance-crypto-holdings-table"
title="Holdings"
description="Every asset in every wallet, largest first"
contentClassName="px-0"
footer="Value is the amount at today's price; a price is quoted once per asset, whichever wallet holds it."
>
<SimpleTable columns={COLUMNS} rows={rows} rowKey="id" size="sm" hoverable />
</Widget>
)
}"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>
)
}"use client"
import * as React from "react"
import { DashboardGridItem } from "@/components/ui/dashboard-grid"
import { type Coin, type TradeRow } from "../data"
import { ActivityList } from "./activity-list"
import { TradeForm } from "./trade-form"
export type TradingDeskProps = {
trades: TradeRow[]
coins: Coin[]
/** The overview card, first in the top row. */
overview: React.ReactNode
/** The wallet card, rendered on the server and slotted between the overview and the form. */
wallets: React.ReactNode
/** The balance summary, likewise a server slot, beside the ledger in the second row. */
summary: React.ReactNode
}
/**
* The trade form and the activity list read the same ledger, so an order the
* server settles has to reach both — one piece of state here, shared by the
* two, is what a `router.refresh()` cannot be for a framed preview's static
* data. The overview, the wallets and the summary sit around them in the
* grid; they arrive as slots from the server page, so the wallets and the
* summary stay server components instead of riding into the client bundle.
*
* Two rows of three: overview, wallets and the form across the top, then the
* ledger beside the summary, which takes the wider share for its chart.
*/
export function TradingDesk({ trades: initial, coins, overview, wallets, summary }: TradingDeskProps) {
const [trades, setTrades] = React.useState(initial)
return (
<>
<DashboardGridItem colSpan={{ base: 12, md: 6, lg: 4 }}>{overview}</DashboardGridItem>
<DashboardGridItem colSpan={{ base: 12, md: 6, lg: 4 }}>{wallets}</DashboardGridItem>
<DashboardGridItem colSpan={{ base: 12, lg: 4 }}>
<TradeForm coins={coins} onPlaced={(trade) => setTrades((current) => [trade, ...current])} />
</DashboardGridItem>
<DashboardGridItem colSpan={{ base: 12, lg: 4 }}>
<ActivityList trades={trades} />
</DashboardGridItem>
<DashboardGridItem colSpan={{ base: 12, lg: 8 }}>{summary}</DashboardGridItem>
</>
)
}import { WalletIcon } from "lucide-react"
import { formatCurrency, formatPercent } from "@/lib/format"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { MetricDelta } from "@/components/ui/metric-delta"
import { PercentageBar } from "@/components/ui/percentage-bar"
import { type WalletSummary } from "../data"
const money = (cents: number) => formatCurrency(cents / 100, "USD", { maximumFractionDigits: 0 })
/**
* The three wallets, one line each: what each is worth, how many assets it
* holds and which one leads, with the day's move on the right and the split
* of the balance between them as a bar underneath. The holdings themselves
* are the table further down; this card is the sum. No state, so it renders
* on the server.
*/
export function WalletsList({ wallets }: { wallets: WalletSummary[] }) {
return (
<Card data-widget="widget-finance-crypto-wallets-list" role="region" aria-label="Wallets" className="h-full">
<CardHeader>
<CardTitle>Wallets</CardTitle>
<CardDescription>{"What each wallet is worth, at today's prices."}</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<ul className="flex flex-col divide-y divide-rule">
{wallets.map((wallet) => (
<li key={wallet.wallet} data-slot="wallet-row" className="flex items-center gap-3 py-3 first:pt-0 last:pb-0">
<span
aria-hidden="true"
className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground [&_svg]:size-4"
>
<WalletIcon />
</span>
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<h2 className="truncate text-sm font-medium">{`${wallet.wallet} wallet`}</h2>
<span className="truncate text-xs text-muted-foreground">
{`${wallet.assets} ${wallet.assets === 1 ? "asset" : "assets"} · ${wallet.top.asset} ${formatPercent(wallet.top.share, { maximumFractionDigits: 0 })}`}
</span>
</span>
<span className="flex shrink-0 flex-col items-end gap-0.5">
<span className="text-sm tabular-nums">{money(wallet.totalCents)}</span>
<MetricDelta value={wallet.change24h} size="sm" />
</span>
</li>
))}
</ul>
{/* The rows carry the sums; the legend only needs the shares. */}
<PercentageBar
segments={wallets.map((wallet) => ({ label: wallet.wallet, value: wallet.totalCents }))}
height="sm"
className="mt-auto border-t border-rule pt-4"
/>
</CardContent>
</Card>
)
}