Skip to contentVibraUI

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

Install

npx shadcn@latest add @vibra/widget-finance-balances-currency-converter

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

Source

app/finance/balances/components/currency-converter.tsx
"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>
  )
}
app/finance/balances/components/balances-vocabulary.ts
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