{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "author": "Lopifi (https://lopifi.com)",
  "categories": [
    "swaps-fx",
    "finance",
    "accounting"
  ],
  "dependencies": [],
  "description": "From/to with the flip. The signature component.",
  "files": [
    {
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { useReducedMotion } from \"@/hooks/ledger/use-media-query\";\nimport { cn } from \"@/lib/ledger/cn\";\nimport { currencySymbol, formatMoneyInput, parseMoneyInput } from \"@/lib/ledger/format-money\";\nimport type { MoneyValue } from \"@/lib/ledger/money.types\";\nimport { Button } from \"@/components/ledger/button\";\nimport { ConversionBreakdown } from \"@/components/ledger/conversion-breakdown\";\nimport { CurrencySelect } from \"@/components/ledger/currency-select\";\nimport { ArrowsUpDownIcon } from \"@/components/ledger/icons\";\nimport { Money } from \"@/components/ledger/money\";\nimport { RateLine } from \"@/components/ledger/rate-line\";\n\nexport interface SwapWidgetProps {\n  balance: MoneyValue;\n  className?: string;\n  /**\n   * When the rates were quoted. Supplied by the caller, never invented here —\n   * a rate the component timestamps itself is a rate nobody can verify, and\n   * a `new Date()` at render time also differs between server and client.\n   */\n  rateAsOf: Date;\n  rates: Record<string, number>;\n}\n\n/**\n * The signature component. From/to with the flip.\n *\n * The fields move; the numbers don't animate. That distinction is the whole\n * doctrine in one component — motion explains what happened structurally,\n * and never renders a false value.\n */\nexport function SwapWidget({ balance, rates, rateAsOf, className }: SwapWidgetProps) {\n  const [from, setFrom] = React.useState(\"USD\");\n  const [to, setTo] = React.useState(\"EUR\");\n  const [sendMinor, setSendMinor] = React.useState<number | null>(100_000);\n  const [editing, setEditing] = React.useState<\"send\" | \"receive\">(\"send\");\n  const [flipping, setFlipping] = React.useState(false);\n  /* The arrow keeps turning the same way on every swap. Rotating to 180° and\n     back would read as the swap being undone. */\n  const [turns, setTurns] = React.useState(0);\n  const [refreshing, setRefreshing] = React.useState(false);\n  const reduced = useReducedMotion();\n\n  const rate = (rates[`${from}/${to}`] ?? 1 / (rates[`${to}/${from}`] ?? 1)) || 1;\n  const feeMinor = 450;\n  const convertibleMinor = (sendMinor ?? 0) - feeMinor;\n  const receiveMinor = Math.max(0, Math.round(convertibleMinor * rate));\n\n  const insufficient = sendMinor != null && sendMinor > Number(balance.amount);\n\n  const flip = () => {\n    // The amounts swap instantly. The fields cross-translate.\n    setTurns((t) => t + 1);\n    if (!reduced) {\n      setFlipping(true);\n      setTimeout(() => setFlipping(false), 180);\n    }\n    setFrom(to);\n    setTo(from);\n    setSendMinor(receiveMinor);\n  };\n\n  return (\n    <div className={cn(\"rounded-xl border border-border bg-surface p-6\", className)}>\n      <div className=\"relative\">\n        <Panel\n          balance={balance}\n          currency={from}\n          derived={false}\n          error={insufficient ? \"That’s more than your available balance.\" : undefined}\n          label=\"You send\"\n          minorUnits={sendMinor}\n          onCurrencyChange={setFrom}\n          onMax={() => setSendMinor(Number(balance.amount))}\n          onMinorUnitsChange={(v) => {\n            setEditing(\"send\");\n            setSendMinor(v);\n          }}\n          translate={flipping ? 1 : 0}\n        />\n\n        <div className=\"relative z-sticky flex justify-center py-2\">\n          <button\n            aria-label={`Swap ${from} and ${to}`}\n            className={cn(\n              \"grid size-10 place-items-center rounded-full border border-border bg-surface\",\n              \"text-text-secondary shadow-elevation-1\",\n              \"hover:bg-surface-hover\",\n            )}\n            onClick={flip}\n            style={{\n              transform: `rotate(${turns * 180}deg)`,\n              /* No overshoot: a control that bounces beside two amounts of money\n                 reads as the amounts being unsettled. */\n              transition: reduced ? \"none\" : \"transform var(--duration-4) var(--ease-in-out)\",\n            }}\n            type=\"button\"\n          >\n            <ArrowsUpDownIcon aria-hidden=\"true\" className=\"size-4\" />\n          </button>\n        </div>\n\n        <Panel\n          currency={to}\n          derived={editing === \"send\"}\n          label=\"Recipient gets\"\n          minorUnits={receiveMinor}\n          onCurrencyChange={setTo}\n          onMinorUnitsChange={() => setEditing(\"receive\")}\n          refreshing={refreshing}\n          translate={flipping ? -1 : 0}\n        />\n      </div>\n\n      <div className=\"mt-4 flex items-center justify-between gap-3\">\n        <RateLine asOf={rateAsOf} from={from} live rate={rate} to={to} />\n        <Button\n          onClick={() => {\n            setRefreshing(true);\n            setTimeout(() => setRefreshing(false), 300);\n          }}\n          size=\"sm\"\n          variant=\"ghost\"\n        >\n          Refresh\n        </Button>\n      </div>\n\n      <div className=\"mt-4 border-t border-border-subtle pt-4\">\n        <ConversionBreakdown\n          amount={{ amount: sendMinor ?? 0, currency: from }}\n          converted={{ amount: convertibleMinor, currency: from }}\n          fees={[{ amount: { amount: feeMinor, currency: from }, label: \"Our fee\" }]}\n          from={from}\n          rate={rate}\n          rateAsOf={rateAsOf}\n          recipientGets={{ amount: receiveMinor, currency: to }}\n          to={to}\n        />\n      </div>\n    </div>\n  );\n}\n\nfunction Panel({\n  label,\n  currency,\n  onCurrencyChange,\n  minorUnits,\n  onMinorUnitsChange,\n  derived,\n  refreshing,\n  balance,\n  onMax,\n  error,\n  translate,\n}: {\n  label: string;\n  currency: string;\n  onCurrencyChange: (c: string) => void;\n  minorUnits: number | null;\n  onMinorUnitsChange: (v: number | null) => void;\n  derived: boolean;\n  refreshing?: boolean;\n  balance?: MoneyValue;\n  onMax?: () => void;\n  error?: string;\n  translate: number;\n}) {\n  const [draft, setDraft] = React.useState(\"\");\n  const [focused, setFocused] = React.useState(false);\n  const reduced = useReducedMotion();\n\n  React.useEffect(() => {\n    if (focused) return;\n    setDraft(minorUnits == null ? \"\" : formatMoneyInput(minorUnits, currency));\n  }, [minorUnits, currency, focused]);\n\n  return (\n    <div\n      className=\"rounded-md bg-surface-sunken p-4\"\n      style={{\n        transform: `translateY(${translate * 6}px)`,\n        transition: reduced ? \"none\" : \"transform var(--duration-3) var(--ease-in-out)\",\n      }}\n    >\n      <span className=\"block text-label font-medium text-text-secondary\">{label}</span>\n      <div className=\"mt-2 flex items-center gap-3\">\n        <span aria-hidden=\"true\" className=\"numeric text-figure-lg text-text-tertiary\">\n          {currencySymbol(currency)}\n        </span>\n        <input\n          aria-label={`${label} amount in ${currency}`}\n          className={cn(\n            \"numeric min-w-0 flex-1 bg-transparent text-figure-lg font-medium outline-none\",\n            // The computed field is visibly derived.\n            derived && \"text-text-secondary\",\n            refreshing && \"opacity-60\",\n          )}\n          inputMode=\"decimal\"\n          onBlur={() => {\n            setFocused(false);\n            const parsed = parseMoneyInput(draft, currency);\n            onMinorUnitsChange(parsed);\n          }}\n          onChange={(e) => {\n            setDraft(e.target.value);\n            onMinorUnitsChange(parseMoneyInput(e.target.value, currency));\n          }}\n          onFocus={() => setFocused(true)}\n          type=\"text\"\n          value={draft}\n        />\n        <CurrencySelect onValueChange={onCurrencyChange} recents={[\"USD\", \"EUR\", \"GBP\"]} value={currency} />\n      </div>\n      <div className=\"mt-2 flex items-center justify-between gap-3\">\n        {balance ? (\n          <span className=\"text-caption text-text-secondary\">\n            Balance <Money size=\"xs\" tone=\"inherit\" value={balance} />\n          </span>\n        ) : (\n          <span />\n        )}\n        {onMax ? (\n          <Button onClick={onMax} size=\"sm\" variant=\"ghost\">\n            Max\n          </Button>\n        ) : null}\n      </div>\n      {error ? (\n        <p className=\"mt-1 text-caption text-negative\" role=\"alert\">\n          {error}\n        </p>\n      ) : null}\n    </div>\n  );\n}\n",
      "path": "components/ledger/swap-widget.tsx",
      "target": "components/ledger/swap-widget.tsx",
      "type": "registry:block"
    }
  ],
  "name": "swap-widget",
  "registryDependencies": [
    "@lopifi/theme",
    "@lopifi/button",
    "@lopifi/cn",
    "@lopifi/conversion-breakdown",
    "@lopifi/currency-select",
    "@lopifi/format-money",
    "@lopifi/icons",
    "@lopifi/money",
    "@lopifi/money.types",
    "@lopifi/rate-line",
    "@lopifi/use-media-query"
  ],
  "title": "SwapWidget",
  "type": "registry:block"
}
