CurrencyInputField.tsx raw

   1  import * as React from "react";
   2  import { toast } from "sonner";
   3  import {
   4    Field,
   5    FieldDescription,
   6    FieldError,
   7    FieldLabel,
   8  } from "src/components/ui/field";
   9  import {
  10    InputGroup,
  11    InputGroupAddon,
  12    InputGroupButton,
  13    InputGroupInput,
  14  } from "src/components/ui/input-group";
  15  import { Skeleton } from "src/components/ui/skeleton";
  16  import { BITCOIN_DISPLAY_FORMAT_BIP177 } from "src/constants";
  17  import { useBitcoinRate } from "src/hooks/useBitcoinRate";
  18  import { useInfo } from "src/hooks/useInfo";
  19  import { cn } from "src/lib/utils";
  20  
  21  type CurrencyInputMode = "bitcoin" | "fiat";
  22  type BitcoinDenomination = "sats" | "btc";
  23  
  24  export type CurrencyInputContextRow = {
  25    label: string;
  26    amountSat?: number | null;
  27    value?: React.ReactNode;
  28  };
  29  
  30  type CurrencyInputFieldProps = Omit<
  31    React.ComponentProps<typeof InputGroupInput>,
  32    "max" | "min" | "onChange" | "step" | "type" | "value"
  33  > & {
  34    contextRows?: CurrencyInputContextRow[];
  35    description?: React.ReactNode;
  36    error?: React.ReactNode;
  37    label?: React.ReactNode;
  38    maxSat?: number;
  39    minSat?: number;
  40    onValueSatChange: (valueSat: string) => void;
  41    valueSat: string;
  42  };
  43  
  44  const SATS_PER_BTC = 100_000_000;
  45  
  46  function getNumericValue(value: string | number | null | undefined) {
  47    const parsed = Number(value);
  48    return Number.isFinite(parsed) ? parsed : 0;
  49  }
  50  
  51  function getCurrencyFractionDigits(currency: string) {
  52    try {
  53      return new Intl.NumberFormat("en-US", {
  54        currency,
  55        style: "currency",
  56      }).resolvedOptions().maximumFractionDigits;
  57    } catch {
  58      return 2;
  59    }
  60  }
  61  
  62  function getCurrencySymbol(currency: string) {
  63    try {
  64      return (
  65        new Intl.NumberFormat("en-US", {
  66          currency,
  67          style: "currency",
  68        })
  69          .formatToParts(0)
  70          .find((part) => part.type === "currency")?.value || currency
  71      );
  72    } catch {
  73      return currency;
  74    }
  75  }
  76  
  77  function formatFiatValue(
  78    amountSat: string | number | undefined,
  79    rate: number | undefined,
  80    currency: string | undefined
  81  ) {
  82    if (!rate || !currency) {
  83      return null;
  84    }
  85  
  86    return new Intl.NumberFormat("en-US", {
  87      currency,
  88      style: "currency",
  89    }).format((getNumericValue(amountSat) / SATS_PER_BTC) * rate);
  90  }
  91  
  92  function formatFiatInput(amountSat: string, rate: number, currency: string) {
  93    const fractionDigits = getCurrencyFractionDigits(currency);
  94    const amountFiat = (getNumericValue(amountSat) / SATS_PER_BTC) * rate;
  95  
  96    if (!amountFiat) {
  97      return "";
  98    }
  99  
 100    return amountFiat.toFixed(fractionDigits);
 101  }
 102  
 103  function formatBitcoinValue(
 104    amountSat: string | number | null | undefined,
 105    displayFormat: string | undefined,
 106    denomination: BitcoinDenomination = "sats"
 107  ) {
 108    const { amount, unit } = formatBitcoinValueParts(
 109      amountSat,
 110      displayFormat,
 111      denomination
 112    );
 113  
 114    if (unit === "₿") {
 115      return `${unit}${amount}`;
 116    }
 117  
 118    return `${amount} ${unit}`;
 119  }
 120  
 121  function formatBitcoinValueParts(
 122    amountSat: string | number | null | undefined,
 123    displayFormat: string | undefined,
 124    denomination: BitcoinDenomination = "sats"
 125  ) {
 126    if (denomination === "btc") {
 127      return {
 128        amount: formatBtcDisplay(amountSat),
 129        unit: "BTC",
 130      };
 131    }
 132  
 133    const formattedAmount = new Intl.NumberFormat().format(
 134      Math.floor(getNumericValue(amountSat))
 135    );
 136  
 137    if (displayFormat === BITCOIN_DISPLAY_FORMAT_BIP177) {
 138      return {
 139        amount: formattedAmount,
 140        unit: "₿",
 141      };
 142    }
 143  
 144    return {
 145      amount: formattedAmount,
 146      unit: "sats",
 147    };
 148  }
 149  
 150  function BitcoinValueText({
 151    amountSat,
 152    denomination,
 153    displayFormat,
 154  }: {
 155    amountSat: string | number | null | undefined;
 156    denomination: BitcoinDenomination;
 157    displayFormat: string | undefined;
 158  }) {
 159    const { amount, unit } = formatBitcoinValueParts(
 160      amountSat,
 161      displayFormat,
 162      denomination
 163    );
 164  
 165    return (
 166      <span className="inline-flex min-w-0 items-center justify-end gap-1">
 167        {unit === "₿" && <span>{unit}</span>}
 168        <span className="min-w-0 truncate">{amount}</span>
 169        {unit !== "₿" && <span>{unit}</span>}
 170      </span>
 171    );
 172  }
 173  
 174  function formatBtcDisplay(amountSat: string | number | null | undefined) {
 175    return (getNumericValue(amountSat) / SATS_PER_BTC).toFixed(8);
 176  }
 177  
 178  function formatBtcInput(amountSat: string | number | null | undefined) {
 179    const amount = getNumericValue(amountSat);
 180  
 181    if (!amount) {
 182      return "";
 183    }
 184  
 185    return (amount / SATS_PER_BTC).toFixed(8);
 186  }
 187  
 188  export function CurrencyInputField({
 189    className,
 190    contextRows,
 191    description,
 192    disabled,
 193    error,
 194    id,
 195    label = "Amount",
 196    maxSat,
 197    minSat,
 198    onValueSatChange,
 199    required,
 200    valueSat,
 201    ...props
 202  }: CurrencyInputFieldProps) {
 203    const generatedId = React.useId();
 204    const { data: info } = useInfo();
 205    const { data: bitcoinRate, error: bitcoinRateError } = useBitcoinRate(
 206      info?.currency
 207    );
 208    const [mode, setMode] = React.useState<CurrencyInputMode>("bitcoin");
 209    const [fiatValue, setFiatValue] = React.useState("");
 210    const [bitcoinDenomination, setBitcoinDenomination] =
 211      React.useState<BitcoinDenomination>("sats");
 212    const [btcValue, setBtcValue] = React.useState("");
 213  
 214    const currency = info?.currency || "USD";
 215    const rate = bitcoinRate?.rate_float;
 216    const canUseFiat = currency !== "SATS" && !!rate && !bitcoinRateError;
 217    const bitcoinUnit =
 218      info?.bitcoinDisplayFormat === BITCOIN_DISPLAY_FORMAT_BIP177 ? "₿" : "sats";
 219    const invalid =
 220      props["aria-invalid"] === true ||
 221      props["aria-invalid"] === "true" ||
 222      !!error;
 223    const inputId = id || generatedId;
 224    const isFiatMode = mode === "fiat";
 225    const isBtcDenominated = bitcoinDenomination === "btc";
 226    const inputValue = isFiatMode
 227      ? fiatValue
 228      : isBtcDenominated
 229        ? btcValue
 230        : valueSat;
 231    const alternateBitcoinValue = formatBitcoinValueParts(
 232      valueSat,
 233      info?.bitcoinDisplayFormat,
 234      bitcoinDenomination
 235    );
 236    const alternateValue = isFiatMode
 237      ? formatBitcoinValue(
 238          valueSat,
 239          info?.bitcoinDisplayFormat,
 240          bitcoinDenomination
 241        )
 242      : formatFiatValue(valueSat, rate, currency);
 243  
 244    React.useEffect(() => {
 245      if (mode === "fiat" && !valueSat) {
 246        setFiatValue("");
 247      }
 248    }, [mode, valueSat]);
 249  
 250    React.useEffect(() => {
 251      if (mode === "bitcoin" && isBtcDenominated && !valueSat) {
 252        setBtcValue("");
 253      }
 254    }, [isBtcDenominated, mode, valueSat]);
 255  
 256    function handleToggleMode() {
 257      if (disabled) {
 258        return;
 259      }
 260  
 261      if (mode === "bitcoin") {
 262        if (!canUseFiat) {
 263          return;
 264        }
 265  
 266        setFiatValue(formatFiatInput(valueSat, rate, currency));
 267        setMode("fiat");
 268        return;
 269      }
 270  
 271      if (isBtcDenominated) {
 272        setBtcValue(formatBtcInput(valueSat));
 273      }
 274  
 275      setMode("bitcoin");
 276    }
 277  
 278    function handleAlternateValueClick() {
 279      if (disabled || isFiatMode || !canUseFiat) {
 280        return;
 281      }
 282  
 283      handleToggleMode();
 284    }
 285  
 286    function handleToggleBitcoinDenomination() {
 287      if (disabled) {
 288        return;
 289      }
 290  
 291      if (isBtcDenominated) {
 292        setBitcoinDenomination("sats");
 293        return;
 294      }
 295  
 296      setBtcValue(formatBtcInput(valueSat));
 297      setBitcoinDenomination("btc");
 298    }
 299  
 300    function handleChangeMode(event: React.ChangeEvent<HTMLInputElement>) {
 301      // clear any custom validity set via onInvalid so the field re-validates
 302      // on the next submit
 303      event.currentTarget.setCustomValidity("");
 304  
 305      const nextValue = event.target.value.trim();
 306  
 307      if (mode === "bitcoin") {
 308        if (!isBtcDenominated && nextValue.includes(".")) {
 309          setBitcoinDenomination("btc");
 310          setBtcValue(nextValue);
 311          toast("Switched to BTC for decimal amount");
 312  
 313          if (!nextValue) {
 314            onValueSatChange("");
 315            return;
 316          }
 317  
 318          const amountBtc = Number(nextValue);
 319          if (!Number.isFinite(amountBtc)) {
 320            onValueSatChange("");
 321            return;
 322          }
 323  
 324          onValueSatChange(
 325            Math.max(0, Math.round(amountBtc * SATS_PER_BTC)).toString()
 326          );
 327          return;
 328        }
 329  
 330        if (isBtcDenominated) {
 331          setBtcValue(nextValue);
 332  
 333          if (!nextValue) {
 334            onValueSatChange("");
 335            return;
 336          }
 337  
 338          const amountBtc = Number(nextValue);
 339          if (!Number.isFinite(amountBtc)) {
 340            onValueSatChange("");
 341            return;
 342          }
 343  
 344          onValueSatChange(
 345            Math.max(0, Math.round(amountBtc * SATS_PER_BTC)).toString()
 346          );
 347          return;
 348        }
 349  
 350        onValueSatChange(nextValue);
 351        return;
 352      }
 353  
 354      setFiatValue(nextValue);
 355  
 356      if (!nextValue || !rate) {
 357        onValueSatChange("");
 358        return;
 359      }
 360  
 361      const amountFiat = Number(nextValue);
 362      if (!Number.isFinite(amountFiat)) {
 363        onValueSatChange("");
 364        return;
 365      }
 366  
 367      onValueSatChange(
 368        Math.max(0, Math.round((amountFiat / rate) * SATS_PER_BTC)).toString()
 369      );
 370    }
 371  
 372    function getModeBound(amountSat: number | undefined) {
 373      if (amountSat === undefined) {
 374        return undefined;
 375      }
 376  
 377      if (!isFiatMode) {
 378        if (isBtcDenominated) {
 379          return amountSat / SATS_PER_BTC;
 380        }
 381  
 382        return amountSat;
 383      }
 384  
 385      if (!rate) {
 386        return amountSat;
 387      }
 388  
 389      return ((amountSat / SATS_PER_BTC) * rate).toFixed(
 390        getCurrencyFractionDigits(currency)
 391      );
 392    }
 393  
 394    return (
 395      <Field
 396        className={cn("w-full min-w-0", className)}
 397        data-disabled={disabled || undefined}
 398        data-invalid={invalid || undefined}
 399      >
 400        {label && <FieldLabel htmlFor={inputId}>{label}</FieldLabel>}
 401        <InputGroup className="h-9 min-w-0 overflow-hidden has-[>[data-align=inline-start]]:[&>input]:pl-1">
 402          <InputGroupInput
 403            {...props}
 404            id={inputId}
 405            aria-invalid={invalid || undefined}
 406            autoComplete="off"
 407            className={cn(
 408              "sensitive slashed-zero min-w-0 [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
 409            )}
 410            disabled={disabled}
 411            inputMode="decimal"
 412            max={getModeBound(maxSat)}
 413            min={getModeBound(minSat)}
 414            onChange={handleChangeMode}
 415            placeholder={
 416              isFiatMode ? "0.00" : isBtcDenominated ? "0.00000000" : "0"
 417            }
 418            required={required}
 419            step={isFiatMode ? "any" : isBtcDenominated ? 0.00000001 : 1}
 420            type="number"
 421            value={inputValue}
 422          />
 423          <InputGroupAddon align="inline-start">
 424            {isFiatMode ? (
 425              <InputGroupButton
 426                aria-label="Enter amount in bitcoin"
 427                disabled={disabled}
 428                onClick={handleToggleMode}
 429                size="xs"
 430                className="h-full rounded-none bg-transparent pl-2 pr-0 text-muted-foreground hover:bg-transparent hover:text-foreground focus-visible:text-foreground focus-visible:ring-0"
 431                title="Enter amount in bitcoin"
 432              >
 433                {getCurrencySymbol(currency)}
 434              </InputGroupButton>
 435            ) : (
 436              <InputGroupButton
 437                aria-label={
 438                  isBtcDenominated
 439                    ? "Display bitcoin amounts in satoshis"
 440                    : "Display bitcoin amounts in BTC"
 441                }
 442                aria-pressed={isBtcDenominated}
 443                disabled={disabled}
 444                onClick={handleToggleBitcoinDenomination}
 445                size="xs"
 446                className="h-full rounded-none bg-transparent pl-2 pr-0 text-muted-foreground hover:bg-transparent hover:text-foreground focus-visible:text-foreground focus-visible:ring-0"
 447                title={
 448                  isBtcDenominated
 449                    ? "Display bitcoin amounts in satoshis"
 450                    : "Display bitcoin amounts in BTC"
 451                }
 452              >
 453                {isBtcDenominated ? "BTC" : bitcoinUnit}
 454              </InputGroupButton>
 455            )}
 456          </InputGroupAddon>
 457          <InputGroupAddon
 458            align="inline-end"
 459            className="mr-0 min-w-0 self-stretch py-0 pr-4"
 460          >
 461            {isFiatMode ? (
 462              <InputGroupButton
 463                aria-label={
 464                  isBtcDenominated
 465                    ? "Display bitcoin amounts in satoshis"
 466                    : "Display bitcoin amounts in BTC"
 467                }
 468                aria-pressed={isBtcDenominated}
 469                disabled={disabled}
 470                onClick={handleToggleBitcoinDenomination}
 471                size="xs"
 472                className="sensitive slashed-zero h-full min-w-0 justify-end truncate rounded-none bg-transparent px-0.5 text-muted-foreground tabular-nums hover:bg-transparent hover:text-foreground focus-visible:text-foreground focus-visible:ring-0"
 473                title={
 474                  isBtcDenominated
 475                    ? "Display bitcoin amounts in satoshis"
 476                    : "Display bitcoin amounts in BTC"
 477                }
 478              >
 479                {alternateBitcoinValue.unit === "₿" && (
 480                  <span>{alternateBitcoinValue.unit}</span>
 481                )}
 482                <span className="min-w-0 truncate">
 483                  {alternateBitcoinValue.amount}
 484                </span>
 485                {alternateBitcoinValue.unit !== "₿" && (
 486                  <span>{alternateBitcoinValue.unit}</span>
 487                )}
 488              </InputGroupButton>
 489            ) : (
 490              <InputGroupButton
 491                aria-label="Enter amount in fiat"
 492                disabled={disabled || !canUseFiat}
 493                onClick={handleAlternateValueClick}
 494                size="xs"
 495                className="sensitive slashed-zero h-full min-w-0 max-w-28 justify-end truncate rounded-none bg-transparent px-1 text-muted-foreground tabular-nums hover:bg-transparent hover:text-foreground focus-visible:text-foreground focus-visible:ring-0 sm:max-w-none"
 496                title="Enter amount in fiat"
 497              >
 498                {alternateValue ?? <Skeleton className="h-4 w-16" />}
 499              </InputGroupButton>
 500            )}
 501          </InputGroupAddon>
 502        </InputGroup>
 503        {!!contextRows?.length && (
 504          <div className="flex min-w-0 cursor-default flex-col gap-1 text-sm text-muted-foreground">
 505            {contextRows.map((row) => (
 506              <div
 507                className="flex min-w-0 items-center justify-between gap-3"
 508                key={row.label}
 509              >
 510                <span className="truncate">{row.label}:</span>
 511                <span className="sensitive slashed-zero min-w-0 max-w-[55%] truncate text-right tabular-nums">
 512                  {row.value ?? (
 513                    <BitcoinValueText
 514                      amountSat={row.amountSat}
 515                      displayFormat={info?.bitcoinDisplayFormat}
 516                      denomination={bitcoinDenomination}
 517                    />
 518                  )}
 519                </span>
 520              </div>
 521            ))}
 522          </div>
 523        )}
 524        {description && <FieldDescription>{description}</FieldDescription>}
 525        {error && <FieldError>{error}</FieldError>}
 526      </Field>
 527    );
 528  }
 529