"use client"; import { ChevronDownIcon } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { type ChangeEvent, type ReactNode, useId, useLayoutEffect, useMemo, useRef, useState } from "react"; import { Menu, MenuGroup, MenuGroupLabel, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "@/components/ui/menu"; import { cn } from "@/lib/utils"; export interface Currency { code: string; locale: string; name: string; } export const CURRENCIES: Currency[] = [ { code: "USD", locale: "en-US", name: "US dollar" }, { code: "EUR", locale: "de-DE", name: "Euro" }, { code: "GBP", locale: "en-GB", name: "British pound" }, { code: "JPY", locale: "ja-JP", name: "Japanese yen" }, { code: "INR", locale: "en-IN", name: "Indian rupee" }, ]; const TYPED_DECIMAL_MARK = "\u0000"; function describe(currency: Currency) { const nf = new Intl.NumberFormat(currency.locale, { style: "currency", currency: currency.code }); const parts = nf.formatToParts(1234567.89); return { symbol: parts.find((p) => p.type === "currency")?.value ?? currency.code, group: parts.find((p) => p.type === "group")?.value ?? ",", decimal: parts.find((p) => p.type === "decimal")?.value ?? ".", fraction: nf.resolvedOptions().maximumFractionDigits ?? 2, symbolAfter: parts.findIndex((p) => p.type === "currency") > parts.findIndex((p) => p.type === "integer"), }; } function display(raw: string, currency: Currency, d: ReturnType) { if (!raw) return ""; const [int, frac] = raw.split("."); const grouped = new Intl.NumberFormat(currency.locale, { maximumFractionDigits: 0 }).format(BigInt(int || "0")); return frac === undefined ? grouped : `${grouped}${d.decimal}${frac}`; } export function formatMoney(value: number, currency: Currency) { return new Intl.NumberFormat(currency.locale, { style: "currency", currency: currency.code }).format(value); } export interface MoneyInputProps { value?: number | null; defaultValue?: number | null; onValueChange?: (value: number | null) => void; currency?: string; defaultCurrency?: string; onCurrencyChange?: (code: string) => void; currencies?: Currency[]; min?: number; max?: number; label?: ReactNode; hintAction?: ReactNode; className?: string; } const toRaw = (value: number | null | undefined, fraction: number) => value === null || value === undefined ? "" : value.toFixed(fraction); export function MoneyInput({ value: valueProp, defaultValue = null, onValueChange, currency: currencyProp, defaultCurrency = "USD", onCurrencyChange, currencies = CURRENCIES, min, max, label = "Amount", hintAction, className, }: MoneyInputProps) { const [innerCode, setInnerCode] = useState(defaultCurrency); const code = currencyProp ?? innerCode; const currency = currencies.find((c) => c.code === code) ?? currencies[0]; const d = useMemo(() => describe(currency), [currency]); const [raw, setRaw] = useState(() => toRaw(valueProp ?? defaultValue, d.fraction)); const inputRef = useRef(null); const caret = useRef(null); const [focused, setFocused] = useState(false); const reduce = useReducedMotion(); const id = useId(); const rawNumber = raw ? Number(raw) : null; if (valueProp !== undefined) { const expected = toRaw(valueProp, d.fraction); if ((expected === "" ? null : Number(expected)) !== rawNumber) setRaw(expected); } const shown = display(raw, currency, d); useLayoutEffect(() => { if (caret.current === null || !inputRef.current) return; inputRef.current.setSelectionRange(caret.current, caret.current); caret.current = null; }); const commit = (next: string) => { setRaw(next); onValueChange?.(next === "" ? null : Number(next)); }; const onChange = (event: ChangeEvent) => { const el = event.target; let text = el.value; let at = el.selectionStart ?? text.length; const native = event.nativeEvent as InputEvent; const typed = native.data; const insertedAt = typed && typed.length === 1 ? at - 1 : -1; const typedAnyDecimalKey = typed === "." || typed === ","; if (typedAnyDecimalKey && d.fraction > 0 && !raw.includes(".")) { text = `${text.slice(0, at - 1)}${TYPED_DECIMAL_MARK}${text.slice(at)}`; } let int = ""; let frac: string | null = null; let before = 0; for (let i = 0; i < text.length; i++) { const ch = text[i]; const isDecimal = ch === TYPED_DECIMAL_MARK || (ch === d.decimal && i !== insertedAt); if (/\d/.test(ch)) { if (frac === null) int += ch; else if (frac.length < d.fraction) frac += ch; else { if (i < at) at -= 1; continue; } if (i < at) before++; } else if (isDecimal && frac === null && d.fraction > 0) { frac = ""; if (i < at) before++; } } const trimmed = int.replace(/^0+(?=\d)/, ""); if (trimmed.length < int.length) before = Math.max(0, before - (int.length - trimmed.length)); int = trimmed; if (int.length > 13) return; let next = frac === null ? int : `${int || "0"}.${frac}`; const amountUnchanged = next === raw; if (amountUnchanged && native.inputType === "deleteContentBackward" && before > 0) { next = next.slice(0, before - 1) + next.slice(before); before -= 1; } else if (amountUnchanged && native.inputType === "deleteContentForward") { next = next.slice(0, before) + next.slice(before + 1); } next = next.replace(/^0+(?=\d)/, "").replace(/^\./, "0."); const formatted = display(next, currency, d); let pos = 0; let seen = 0; while (pos < formatted.length && seen < before) { if (/\d/.test(formatted[pos]) || formatted[pos] === d.decimal) seen++; pos++; } caret.current = pos; commit(next); }; const switchCurrency = (next: string) => { const target = currencies.find((c) => c.code === next); if (!target) return; const fraction = describe(target).fraction; if (raw.includes(".") && fraction === 0) commit(raw.split(".")[0]); else if (raw.includes(".")) commit(`${raw.split(".")[0]}.${raw.split(".")[1].slice(0, fraction)}`); if (currencyProp === undefined) setInnerCode(next); onCurrencyChange?.(next); }; const amount = raw ? Number(raw) : null; const over = amount !== null && max !== undefined && amount > max; const under = !focused && amount !== null && amount > 0 && min !== undefined && amount < min; const hint = over ? `That's over the ${formatMoney(max, currency)} limit` : under ? `The minimum is ${formatMoney(min, currency)}` : [min !== undefined && `Min ${formatMoney(min, currency)}`, max !== undefined && `Max ${formatMoney(max, currency)}`] .filter(Boolean) .join(" ยท "); const symbol = ( {d.symbol} ); return (
{!d.symbolAfter && symbol} setFocused(true)} onBlur={() => { setFocused(false); if (!raw.includes(".")) return; const [int, frac] = raw.split("."); const tidy = frac ? `${int}.${frac.padEnd(d.fraction, "0")}` : int; if (tidy !== raw) setRaw(tidy); }} onChange={onChange} placeholder={d.fraction ? `0${d.decimal}${"0".repeat(d.fraction)}` : "0"} ref={inputRef} value={shown} /> {d.symbolAfter && symbol}
{currency.code} Currency switchCurrency(String(v))} value={currency.code}> {currencies.map((c) => ( {c.name} {c.code} ))}

{hint}

{hintAction}
); }