"use client"; import React, { useState } from "react"; import { Input, InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from "../../shadcnui"; /** * Standalone (non-`Form*`) currency input that operates in integer cents. * * **Why this exists.** A controlled input whose displayed string is recomputed * from a canonical numeric value on every keystroke causes the browser caret * to jump to the end whenever the rendered string differs from what the user * just typed (e.g. typing "1" with selection "10.00" → onChange parses cents * → reformats to "1.00" → caret resets). To avoid that this component keeps * a **local draft string while focused** and only parses + commits the cents * value to the parent **on blur**. While typing, the input shows literally * what the user typed. * * Currency symbols and decimal-separator rules: the component accepts both * `,` and `.` as decimal separators (mirroring the codebase's `parseCurrencyInput` * utility) and renders the canonical cents → `"X.YZ"` via `(cents / 100).toFixed(2)`. * A localised display (e.g. `1.234,56`) is intentionally out of scope here — * this input is for editing; presentation lives elsewhere (`formatCurrency`). * * For form-bound inputs use ``. This component * is for standalone (non-react-hook-form) usage such as table-row editors * where each row is local component state. */ export type CurrencyInputProps = { valueCents: number; onChange: (cents: number) => void; currencySymbol?: string; readOnly?: boolean; disabled?: boolean; placeholder?: string; className?: string; testId?: string; }; function centsToInput(cents: number): string { return (cents / 100).toFixed(2); } function parseInput(raw: string): number { if (!raw || raw.trim() === "") return 0; const trimmed = raw.trim(); const lastComma = trimmed.lastIndexOf(","); const lastDot = trimmed.lastIndexOf("."); let normalized: string; if (lastComma > lastDot) { normalized = trimmed.replace(/\./g, "").replace(",", "."); } else if (lastDot > lastComma) { normalized = trimmed.replace(/,/g, ""); } else { normalized = trimmed.replace(",", "."); } const parsed = parseFloat(normalized); if (Number.isNaN(parsed)) return 0; return Math.round(parsed * 100); } function sanitizeKeystroke(raw: string): string { let value = raw.replace(/[^0-9.,-]/g, ""); const firstSep = value.search(/[.,]/); if (firstSep !== -1) { const sep = value[firstSep]; const before = value.slice(0, firstSep); const after = value.slice(firstSep + 1).replace(/[.,]/g, ""); value = before + sep + after; } return value; } export function CurrencyInput({ valueCents, onChange, currencySymbol = "€", readOnly, disabled, placeholder, className, testId, }: CurrencyInputProps) { const [draft, setDraft] = useState(null); const display = draft !== null ? draft : centsToInput(valueCents); const handleChange = (e: React.ChangeEvent) => { setDraft(sanitizeKeystroke(e.target.value)); }; const handleBlur = () => { if (draft === null) return; onChange(parseInput(draft)); setDraft(null); }; const sharedProps = { type: "text" as const, inputMode: "decimal" as const, value: display, readOnly, disabled, placeholder, onChange: handleChange, onBlur: handleBlur, "data-testid": testId, }; if (!currencySymbol) { return ; } return ( {currencySymbol} ); }