298 lines
9.9 KiB
TypeScript
298 lines
9.9 KiB
TypeScript
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<HTMLInputElement, AmountInputProps>(
|
||
(
|
||
{
|
||
value,
|
||
onChange,
|
||
currencyCode,
|
||
disabled = false,
|
||
required = false,
|
||
placeholder = '0.00',
|
||
id,
|
||
className = '',
|
||
allowNegative = false,
|
||
},
|
||
ref,
|
||
) => {
|
||
const locale = useLocale();
|
||
const [displayValue, setDisplayValue] = React.useState<string>('');
|
||
const [isFocused, setIsFocused] = React.useState<boolean>(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<HTMLInputElement>) => {
|
||
setDisplayValue(e.target.value);
|
||
};
|
||
|
||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||
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 (
|
||
<div className="relative">
|
||
{symbolPosition === 'prefix' && (
|
||
<span
|
||
className={cn([
|
||
'-translate-y-1/2 absolute top-1/2 text-muted-foreground text-sm',
|
||
allowNegative ? 'left-10' : 'left-3',
|
||
])}
|
||
>
|
||
{currencySymbol}
|
||
</span>
|
||
)}
|
||
<Input
|
||
ref={ref}
|
||
id={id}
|
||
type="text"
|
||
inputMode="decimal"
|
||
value={displayValue}
|
||
onChange={handleChange}
|
||
onFocus={handleFocus}
|
||
onBlur={handleBlur}
|
||
onKeyDown={handleKeyDown}
|
||
placeholder={placeholder}
|
||
disabled={disabled}
|
||
required={required}
|
||
className={cn([
|
||
'bg-background',
|
||
symbolPosition === 'suffix' && 'pr-9',
|
||
allowNegative
|
||
? symbolPosition === 'prefix'
|
||
? 'pl-14'
|
||
: 'pl-11'
|
||
: symbolPosition === 'prefix' && 'pl-9',
|
||
className,
|
||
])}
|
||
/>
|
||
{symbolPosition === 'suffix' && (
|
||
<span className="-translate-y-1/2 absolute top-1/2 right-3 text-muted-foreground text-sm">
|
||
{currencySymbol}
|
||
</span>
|
||
)}
|
||
{allowNegative && (
|
||
<button
|
||
type="button"
|
||
onPointerDown={(e) => e.preventDefault()}
|
||
onClick={toggleSign}
|
||
disabled={disabled}
|
||
aria-label={isNegative ? __('Make amount positive') : __('Make amount negative')}
|
||
aria-pressed={isNegative}
|
||
className={cn([
|
||
'-translate-y-1/2 absolute top-1/2 left-[0.35rem] flex h-7 w-7 items-center justify-center rounded-sm border font-semibold text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
|
||
isNegative
|
||
? 'border-destructive/40 bg-destructive/10 text-destructive'
|
||
: 'border-input bg-muted text-foreground hover:bg-accent',
|
||
])}
|
||
>
|
||
{isNegative ? '−' : '+'}
|
||
</button>
|
||
)}
|
||
</div>
|
||
);
|
||
},
|
||
);
|
||
|
||
AmountInput.displayName = 'AmountInput';
|
||
|