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