import * as React from 'react'; import { Input } from '@/components/ui/input'; import { useLocale } from '@/hooks/use-locale'; import { cn } from '@/lib/utils'; import { __ } from '@/utils/i18n'; interface AmountInputProps { value: number; onChange: (valueInCents: number) => void; currencyCode: string; disabled?: boolean; required?: boolean; placeholder?: string; id?: string; className?: string; allowNegative?: boolean; } const getCurrencyInfo = ( currencyCode: string, locale: string, ): { symbol: string; position: 'prefix' | 'suffix' } => { const parts = new Intl.NumberFormat(locale, { style: 'currency', currency: currencyCode, }).formatToParts(1); const symbolPart = parts.find((p) => p.type === 'currency'); const symbol = symbolPart?.value ?? currencyCode; const symbolIndex = parts.findIndex((p) => p.type === 'currency'); const literalIndex = parts.findIndex((p) => p.type === 'integer'); const position = symbolIndex < literalIndex ? 'prefix' : 'suffix'; return { symbol, position }; }; const formatCurrency = (value: number, locale: string): string => { const amount = value / 100; return new Intl.NumberFormat(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2, }).format(amount); }; const parseInputValue = (input: string): number => { const isNegative = input.trim().startsWith('-'); const cleaned = input.replace(/[^\d.,]/g, ''); if (!cleaned) { return 0; } const lastComma = cleaned.lastIndexOf(','); const lastDot = cleaned.lastIndexOf('.'); let normalized: string; if (lastComma > lastDot) { normalized = cleaned.replace(/\./g, '').replace(',', '.'); } else if (lastDot > lastComma) { normalized = cleaned.replace(/,/g, ''); } else { normalized = cleaned.replace(',', '.'); } const parsed = parseFloat(normalized); if (isNaN(parsed)) { return 0; } const cents = Math.round(parsed * 100); return isNegative ? -cents : cents; }; const evaluateMathExpression = (input: string): number | null => { // Check for leading minus (negative result) const trimmed = input.trim(); const isNegativeResult = trimmed.startsWith('-'); const withoutLeadingMinus = isNegativeResult ? trimmed.substring(1) : trimmed; // Check if input contains math operators (excluding leading minus) if (!/[+\-*/]/.test(withoutLeadingMinus)) { return null; // No math operation found } try { // Remove spaces const cleaned = withoutLeadingMinus.replace(/\s/g, ''); // Helper to convert parsed cents to dollars for calculation const parseToDollars = (str: string): number => { return parseInputValue(str) / 100; }; // Split into tokens (numbers and operators) const tokens: (number | string)[] = []; let currentNumber = ''; for (let i = 0; i < cleaned.length; i++) { const char = cleaned[i]; if (['+', '-', '*', '/'].includes(char) && currentNumber) { tokens.push(parseToDollars(currentNumber)); tokens.push(char); currentNumber = ''; } else { currentNumber += char; } } if (currentNumber) { tokens.push(parseToDollars(currentNumber)); } if (tokens.length < 3) { return null; // Need at least: number operator number } // Handle multiplication and division first (operator precedence) let i = 1; while (i < tokens.length) { if (tokens[i] === '*' || tokens[i] === '/') { const left = tokens[i - 1] as number; const op = tokens[i] as string; const right = tokens[i + 1] as number; const result = op === '*' ? left * right : left / right; tokens.splice(i - 1, 3, result); } else { i += 2; } } // Handle addition and subtraction let result = tokens[0] as number; for (let i = 1; i < tokens.length; i += 2) { const op = tokens[i] as string; const right = tokens[i + 1] as number; if (op === '+') { result += right; } else if (op === '-') { result -= right; } } // Apply negative sign if the input started with minus if (isNegativeResult) { result = -result; } return Math.round(result * 100); } catch { return null; } }; const resolveCents = (input: string): number => evaluateMathExpression(input) ?? parseInputValue(input); export const AmountInput = React.forwardRef( ( { value, onChange, currencyCode, disabled = false, required = false, placeholder = '0.00', id, className = '', allowNegative = false, }, ref, ) => { const locale = useLocale(); const [displayValue, setDisplayValue] = React.useState(''); const [isFocused, setIsFocused] = React.useState(false); React.useEffect(() => { if (!isFocused) { if (value === 0) { setDisplayValue(''); } else { setDisplayValue(formatCurrency(value, locale)); } } }, [value, isFocused, locale]); const handleFocus = () => { setIsFocused(true); if (value !== 0) { const amount = (value / 100).toFixed(2); setDisplayValue(amount); } else if (!displayValue.startsWith('-')) { // Keep a lone '-' the user set via the toggle before typing. setDisplayValue(''); } }; const handleBlur = () => { setIsFocused(false); onChange(resolveCents(displayValue)); }; const handleChange = (e: React.ChangeEvent) => { setDisplayValue(e.target.value); }; const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter') { onChange(resolveCents(displayValue)); } }; // iOS numeric keypads (inputMode="decimal") have no minus key, so // negative amounts need an explicit toggle. onClick handles both pointer // and keyboard; onPointerDown preventDefault keeps the input focused, // avoiding the blur/reformat race with the effect above. const toggleSign = () => { const next = displayValue.trim().startsWith('-') ? displayValue.replace('-', '') : `-${displayValue}`; setDisplayValue(next); onChange(resolveCents(next)); }; const isNegative = displayValue.trim().startsWith('-'); const { symbol: currencySymbol, position: symbolPosition } = getCurrencyInfo(currencyCode, locale); return (
{symbolPosition === 'prefix' && ( {currencySymbol} )} {symbolPosition === 'suffix' && ( {currencySymbol} )} {allowNegative && ( )}
); }, ); AmountInput.displayName = 'AmountInput';