Currency converter
An amount in one currency in another at the latest rates, crossed through the dollar and converted as it is typed, a bad amount marked on its field; reads dollarRates().
Preview
"use client"
import * as React from "react"
import { ArrowLeftRightIcon } from "lucide-react"
import { formatCurrency } from "@/lib/format"
import { Button } from "@/components/ui/button"
import { Field, FieldError, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"
import { Widget } from "@/components/ui/widget"
import { CURRENCY_NAMES, quoteRate } from "./balances-vocabulary"
const CURRENCIES = ["USD", "EUR", "GBP", "JPY", "CHF"]
/** What the amount box holds, read: a number, or why it is not one. */
function read(text: string): { value: number } | { error: string } {
const cleaned = text.trim().replace(/,/g, "")
if (cleaned === "") return { error: "Enter an amount." }
if (!/^-?(\d+\.?\d*|\.\d+)$/.test(cleaned)) return { error: "Enter a number." }
const value = Number(cleaned)
if (value < 0) return { error: "Enter an amount of zero or more." }
return { value }
}
/**
* An amount in one currency, in another, at the latest rates. Every pair is
* crossed through the dollar — dollars per unit of each currency, from the
* store's own rate series — so this and the chart beside it can never
* disagree. It converts as you type; an amount that is not a number, or is
* below zero, is marked on the field itself and no result is shown.
*/
export function CurrencyConverter({ rates }: { rates: Record<string, number> }) {
const [amount, setAmount] = React.useState("1000")
const [from, setFrom] = React.useState("USD")
const [to, setTo] = React.useState("EUR")
const id = React.useId()
const parsed = read(amount)
const rate = rates[from] / rates[to]
return (
<Widget
data-widget="widget-finance-balances-currency-converter"
title="Convert"
description="At the latest rates, through the dollar"
className="h-full"
>
<div className="flex flex-col gap-4">
<Field data-invalid={"error" in parsed || undefined}>
<FieldLabel htmlFor={`${id}-amount`}>Amount</FieldLabel>
<Input
id={`${id}-amount`}
inputMode="decimal"
autoComplete="off"
value={amount}
onChange={(event) => setAmount(event.target.value)}
aria-invalid={"error" in parsed || undefined}
aria-describedby={"error" in parsed ? `${id}-error` : undefined}
className="tabular-nums"
/>
{"error" in parsed ? <FieldError id={`${id}-error`}>{parsed.error}</FieldError> : null}
</Field>
<div className="grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-end gap-2">
<Field>
<FieldLabel htmlFor={`${id}-from`}>From</FieldLabel>
<NativeSelect id={`${id}-from`} value={from} onChange={(event) => setFrom(event.target.value)} className="w-full">
{CURRENCIES.map((code) => (
<NativeSelectOption key={code} value={code}>
{code} · {CURRENCY_NAMES[code]}
</NativeSelectOption>
))}
</NativeSelect>
</Field>
<Button
type="button"
variant="outline"
size="icon"
aria-label="Swap currencies"
onClick={() => {
setFrom(to)
setTo(from)
}}
>
<ArrowLeftRightIcon aria-hidden="true" />
</Button>
<Field>
<FieldLabel htmlFor={`${id}-to`}>To</FieldLabel>
<NativeSelect id={`${id}-to`} value={to} onChange={(event) => setTo(event.target.value)} className="w-full">
{CURRENCIES.map((code) => (
<NativeSelectOption key={code} value={code}>
{code} · {CURRENCY_NAMES[code]}
</NativeSelectOption>
))}
</NativeSelect>
</Field>
</div>
<div className="flex flex-col gap-1 border-t border-rule pt-4" aria-live="polite">
{"value" in parsed ? (
<span data-slot="conversion-result" className="type-numeral text-2xl">
{formatCurrency(parsed.value * rate, to)}
</span>
) : (
<span className="type-numeral text-2xl text-muted-foreground">—</span>
)}
<span data-slot="conversion-rate" className="text-xs text-muted-foreground tabular-nums">
1 {from} = {quoteRate(rate, from, to)} {to}
</span>
</div>
</div>
</Widget>
)
}Install
$
npx shadcn@latest add @vibra/widget-finance-balances-currency-converterNeeds the @vibra registry in your components.json — set it up once.
Source
"use client"
import * as React from "react"
import { ArrowLeftRightIcon } from "lucide-react"
import { formatCurrency } from "@/lib/format"
import { Button } from "@/components/ui/button"
import { Field, FieldError, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"
import { Widget } from "@/components/ui/widget"
import { CURRENCY_NAMES, quoteRate } from "./balances-vocabulary"
const CURRENCIES = ["USD", "EUR", "GBP", "JPY", "CHF"]
/** What the amount box holds, read: a number, or why it is not one. */
function read(text: string): { value: number } | { error: string } {
const cleaned = text.trim().replace(/,/g, "")
if (cleaned === "") return { error: "Enter an amount." }
if (!/^-?(\d+\.?\d*|\.\d+)$/.test(cleaned)) return { error: "Enter a number." }
const value = Number(cleaned)
if (value < 0) return { error: "Enter an amount of zero or more." }
return { value }
}
/**
* An amount in one currency, in another, at the latest rates. Every pair is
* crossed through the dollar — dollars per unit of each currency, from the
* store's own rate series — so this and the chart beside it can never
* disagree. It converts as you type; an amount that is not a number, or is
* below zero, is marked on the field itself and no result is shown.
*/
export function CurrencyConverter({ rates }: { rates: Record<string, number> }) {
const [amount, setAmount] = React.useState("1000")
const [from, setFrom] = React.useState("USD")
const [to, setTo] = React.useState("EUR")
const id = React.useId()
const parsed = read(amount)
const rate = rates[from] / rates[to]
return (
<Widget
data-widget="widget-finance-balances-currency-converter"
title="Convert"
description="At the latest rates, through the dollar"
className="h-full"
>
<div className="flex flex-col gap-4">
<Field data-invalid={"error" in parsed || undefined}>
<FieldLabel htmlFor={`${id}-amount`}>Amount</FieldLabel>
<Input
id={`${id}-amount`}
inputMode="decimal"
autoComplete="off"
value={amount}
onChange={(event) => setAmount(event.target.value)}
aria-invalid={"error" in parsed || undefined}
aria-describedby={"error" in parsed ? `${id}-error` : undefined}
className="tabular-nums"
/>
{"error" in parsed ? <FieldError id={`${id}-error`}>{parsed.error}</FieldError> : null}
</Field>
<div className="grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-end gap-2">
<Field>
<FieldLabel htmlFor={`${id}-from`}>From</FieldLabel>
<NativeSelect id={`${id}-from`} value={from} onChange={(event) => setFrom(event.target.value)} className="w-full">
{CURRENCIES.map((code) => (
<NativeSelectOption key={code} value={code}>
{code} · {CURRENCY_NAMES[code]}
</NativeSelectOption>
))}
</NativeSelect>
</Field>
<Button
type="button"
variant="outline"
size="icon"
aria-label="Swap currencies"
onClick={() => {
setFrom(to)
setTo(from)
}}
>
<ArrowLeftRightIcon aria-hidden="true" />
</Button>
<Field>
<FieldLabel htmlFor={`${id}-to`}>To</FieldLabel>
<NativeSelect id={`${id}-to`} value={to} onChange={(event) => setTo(event.target.value)} className="w-full">
{CURRENCIES.map((code) => (
<NativeSelectOption key={code} value={code}>
{code} · {CURRENCY_NAMES[code]}
</NativeSelectOption>
))}
</NativeSelect>
</Field>
</div>
<div className="flex flex-col gap-1 border-t border-rule pt-4" aria-live="polite">
{"value" in parsed ? (
<span data-slot="conversion-result" className="type-numeral text-2xl">
{formatCurrency(parsed.value * rate, to)}
</span>
) : (
<span className="type-numeral text-2xl text-muted-foreground">—</span>
)}
<span data-slot="conversion-rate" className="text-xs text-muted-foreground tabular-nums">
1 {from} = {quoteRate(rate, from, to)} {to}
</span>
</div>
</div>
</Widget>
)
}import { ArrowLeftRightIcon, LandmarkIcon, ReceiptIcon, type LucideIcon } from "lucide-react"
import { formatCurrency } from "@/lib/format"
import { type StatusVariant } from "@/components/ui/status-badge"
/**
* The words, marks and money formatting this page uses. Vocabulary, not
* data: it lives beside the islands that print it, because `data.ts` reads
* `db` and must never reach the browser.
*/
export const CURRENCY_NAMES: Record<string, string> = {
USD: "US dollar",
EUR: "Euro",
GBP: "British pound",
JPY: "Japanese yen",
CHF: "Swiss franc",
}
/** Minor units in a major one: a hundred cents, or one yen — which has no minor unit. */
const MINOR: Record<string, number> = { USD: 100, EUR: 100, GBP: 100, JPY: 1, CHF: 100 }
/** An amount held in minor units, in its own currency: 1234567 USD cents → "$12,345.67"; 480000 yen → "¥480,000". */
export function money(minor: number, currency: string): string {
return formatCurrency(minor / (MINOR[currency] ?? 100), currency)
}
/** A movement with its direction: "+$1.00" in, "−$1.00" out (a true minus), "$0.00" for none. */
export function signed(minor: number, currency: string): string {
if (minor === 0) return money(0, currency)
return `${minor < 0 ? "−" : "+"}${money(Math.abs(minor), currency)}`
}
/**
* A rate as a desk quotes it: to four places, or to two when it is a price
* in yen — 144.21 yen to the dollar — and to four figures when a single yen
* is the unit, which two places would round away to nothing.
*/
export function quoteRate(rate: number, base: string, quote: string): string {
if (quote === "JPY") return rate.toFixed(2)
if (base === "JPY") return rate.toPrecision(4)
return rate.toFixed(4)
}
export const KIND_LABELS: Record<string, string> = {
charge: "Charge",
payout: "Payout",
refund: "Refund",
transfer: "Transfer",
fee: "Fee",
}
/** The glyph for a counterparty that is not a person: a bank for a payout, a receipt for a fee, arrows for a transfer. */
export const KIND_ICONS: Record<string, LucideIcon> = {
payout: LandmarkIcon,
fee: ReceiptIcon,
transfer: ArrowLeftRightIcon,
}
export const STATUS_LABELS: Record<string, string> = {
completed: "Completed",
failed: "Failed",
pending: "Pending",
scheduled: "Scheduled",
}
export const STATUS_MAP: Record<string, StatusVariant> = {
completed: "success",
failed: "danger",
pending: "warning",
scheduled: "primary",
}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 Balances page