From ad4a29c8e4ed7478bf464fb0f93418aff2615cf0 Mon Sep 17 00:00:00 2001 From: gudnuf Date: Mon, 2 Mar 2026 19:58:13 -0800 Subject: [PATCH 1/4] Extract shared MoneyInputDisplay component Pull the duplicated MoneyInputDisplay + ConvertedMoneySwitcher out of send-input and receive-input into features/shared/money-input-display. The new component composes the raw amount display with the currency switcher and takes the same props both consumers were already threading through. --- app/components/money-display.tsx | 68 +-------- app/features/receive/receive-input.tsx | 60 ++------ app/features/send/send-input.tsx | 64 ++------ app/features/shared/money-input-display.tsx | 160 ++++++++++++++++++++ 4 files changed, 182 insertions(+), 170 deletions(-) create mode 100644 app/features/shared/money-input-display.tsx diff --git a/app/components/money-display.tsx b/app/components/money-display.tsx index 30b69139..f7adc3c2 100644 --- a/app/components/money-display.tsx +++ b/app/components/money-display.tsx @@ -1,6 +1,6 @@ import { type VariantProps, cva } from 'class-variance-authority'; import type { Currency, CurrencyUnit } from '~/lib/money'; -import { Money } from '~/lib/money'; +import type { Money } from '~/lib/money'; import { cn } from '~/lib/utils'; const textVariants = cva('', { @@ -54,72 +54,6 @@ type Variants = VariantProps & VariantProps & VariantProps; -interface MoneyInputDisplayProps { - /** Raw input value from user (e.g., "1", "1.", "1.0") */ - inputValue: string; - currency: C; - unit: CurrencyUnit; - locale?: string; -} - -export function MoneyInputDisplay({ - inputValue, - currency, - unit, - locale, -}: MoneyInputDisplayProps) { - const money = new Money({ amount: inputValue, currency, unit }); - const { - currencySymbol, - currencySymbolPosition, - integer, - numberOfDecimals, - decimalSeparator, - } = money.toLocalizedStringParts({ - locale, - unit, - minimumFractionDigits: 'max', - }); - - // Get decimal part of the input value - const inputHasDecimalPoint = decimalSeparator - ? inputValue.includes(decimalSeparator) - : false; - const inputDecimals = inputHasDecimalPoint - ? inputValue.split(decimalSeparator)[1] - : ''; - - // If decimal part exists in the input value, pad with zeros to numberOfDecimals places - const needsPaddedZeros = - inputHasDecimalPoint && inputDecimals.length < numberOfDecimals; - const paddedZeros = needsPaddedZeros - ? '0'.repeat(numberOfDecimals - inputDecimals.length) - : ''; - - const symbol = ( - {currencySymbol} - ); - - return ( - - {currencySymbolPosition === 'prefix' && symbol} - - {integer} - {(inputDecimals || needsPaddedZeros) && ( - <> - {decimalSeparator} - {inputDecimals} - {paddedZeros && ( - {paddedZeros} - )} - - )} - - {currencySymbolPosition === 'suffix' && symbol} - - ); -} - type MoneyDisplayProps = { money: Money; locale?: string; diff --git a/app/features/receive/receive-input.tsx b/app/features/receive/receive-input.tsx index c9e24723..9d78f4e5 100644 --- a/app/features/receive/receive-input.tsx +++ b/app/features/receive/receive-input.tsx @@ -1,6 +1,5 @@ import { getEncodedToken } from '@cashu/cashu-ts'; -import { ArrowUpDown, Clipboard, Scan } from 'lucide-react'; -import { MoneyDisplay, MoneyInputDisplay } from '~/components/money-display'; +import { Clipboard, Scan } from 'lucide-react'; import { Numpad } from '~/components/numpad'; import { ClosePageButton, @@ -10,20 +9,19 @@ import { PageHeaderTitle, } from '~/components/page'; import { Button } from '~/components/ui/button'; -import { Skeleton } from '~/components/ui/skeleton'; import { AccountSelector, toAccountSelectorOption, } from '~/features/accounts/account-selector'; import { accountOfflineToast } from '~/features/accounts/utils'; import { getDefaultUnit } from '~/features/shared/currencies'; +import { MoneyInputDisplay } from '~/features/shared/money-input-display'; import useAnimation from '~/hooks/use-animation'; import { useMoneyInput } from '~/hooks/use-money-input'; import { useRedirectTo } from '~/hooks/use-redirect-to'; import { useBuildLinkWithSearchParams } from '~/hooks/use-search-params-link'; import { useToast } from '~/hooks/use-toast'; import { extractCashuToken } from '~/lib/cashu'; -import type { Money } from '~/lib/money'; import { readClipboard } from '~/lib/read-clipboard'; import { LinkWithViewTransition, @@ -32,36 +30,6 @@ import { import { useAccount, useAccounts } from '../accounts/account-hooks'; import { useReceiveStore } from './receive-provider'; -type ConvertedMoneySwitcherProps = { - onSwitchInputCurrency: () => void; - money?: Money; -}; - -const ConvertedMoneySwitcher = ({ - onSwitchInputCurrency, - money, -}: ConvertedMoneySwitcherProps) => { - if (!money) { - return ; - } - - return ( - - ); -}; - export default function ReceiveInput() { const navigate = useNavigateWithViewTransition(); const { toast } = useToast(); @@ -161,22 +129,14 @@ export default function ReceiveInput() { -
-
- -
- - {!exchangeRateError && ( - - )} -
+
void; - money?: Money; -}; - -const ConvertedMoneySwitcher = ({ - onSwitchInputCurrency, - money, -}: ConvertedMoneySwitcherProps) => { - if (!money) { - return ; - } - - return ( - - ); -}; - export function SendInput() { const { toast } = useToast(); const navigate = useNavigateWithViewTransition(); @@ -217,22 +183,14 @@ export function SendInput() {
-
-
- -
- - {!exchangeRateError && ( - - )} -
+
{destinationDisplay && ( diff --git a/app/features/shared/money-input-display.tsx b/app/features/shared/money-input-display.tsx new file mode 100644 index 00000000..57d012ce --- /dev/null +++ b/app/features/shared/money-input-display.tsx @@ -0,0 +1,160 @@ +import { ArrowUpDown } from 'lucide-react'; +import { MoneyDisplay } from '~/components/money-display'; +import { Skeleton } from '~/components/ui/skeleton'; +import type { Currency, CurrencyUnit } from '~/lib/money'; +import { Money } from '~/lib/money'; +import { getDefaultUnit } from './currencies'; + +type ConvertedMoneySwitcherProps = { + onSwitch: () => void; + money?: Money; +}; + +const ConvertedMoneySwitcher = ({ + onSwitch, + money, +}: ConvertedMoneySwitcherProps) => { + if (!money) { + return ; + } + + return ( + + ); +}; + +function RawMoneyDisplay({ + inputValue, + currency, + unit, + locale, +}: { + inputValue: string; + currency: C; + unit: CurrencyUnit; + locale?: string; +}) { + const money = new Money({ amount: inputValue, currency, unit }); + const { + currencySymbol, + currencySymbolPosition, + integer, + numberOfDecimals, + decimalSeparator, + } = money.toLocalizedStringParts({ + locale, + unit, + minimumFractionDigits: 'max', + }); + + const inputHasDecimalPoint = decimalSeparator + ? inputValue.includes(decimalSeparator) + : false; + const inputDecimals = inputHasDecimalPoint + ? inputValue.split(decimalSeparator)[1] + : ''; + + const needsPaddedZeros = + inputHasDecimalPoint && inputDecimals.length < numberOfDecimals; + const paddedZeros = needsPaddedZeros + ? '0'.repeat(numberOfDecimals - inputDecimals.length) + : ''; + + const symbol = {currencySymbol}; + + return ( + + {currencySymbolPosition === 'prefix' && symbol} + + {integer} + {(inputDecimals || needsPaddedZeros) && ( + <> + {decimalSeparator} + {inputDecimals} + {paddedZeros && ( + {paddedZeros} + )} + + )} + + {currencySymbolPosition === 'suffix' && symbol} + + ); +} + +type MoneyInputDisplayProps = { + /** Raw input string from useMoneyInput (e.g. "1", "1.", "1.00") */ + rawInputValue: string; + /** Parsed Money from useMoneyInput */ + inputValue: Money; + /** Converted Money from useMoneyInput (undefined while rates load) */ + convertedValue?: Money; + /** Truthy when exchange rates failed to load */ + exchangeRateError?: Error | null; + /** Toggle between input and converted currency */ + onSwitchCurrency: () => void; + /** CSS class for shake animation (from useAnimation) */ + shakeClassName?: string; +}; + +/** + * Amount input display with currency switcher. Used by all input pages + * (receive, send, transfer, buy). + * + * The parent owns the shake animation since it connects the numpad + * (trigger) to this component (visual): + * + * ```tsx + * const { animationClass, start: shake } = useAnimation({ name: 'shake' }); + * const moneyInput = useMoneyInput({ ... }); + * + * + * moneyInput.handleNumberInput(v, shake)} /> + * ``` + */ +export function MoneyInputDisplay({ + rawInputValue, + inputValue, + convertedValue, + exchangeRateError, + onSwitchCurrency, + shakeClassName, +}: MoneyInputDisplayProps) { + return ( +
+
+ +
+ + {!exchangeRateError && ( + + )} +
+ ); +} From 98a35bd1cb52f7338e6267f20081908f49894001 Mon Sep 17 00:00:00 2001 From: gudnuf Date: Mon, 2 Mar 2026 20:03:09 -0800 Subject: [PATCH 2/4] Rename shakeClassName to inputErrorClassName MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prop name now describes intent (fires on invalid input) rather than implementation (shake animation). Consumers still own the animation choice via useAnimation — this just clarifies the contract. --- app/features/receive/receive-input.tsx | 2 +- app/features/send/send-input.tsx | 2 +- app/features/shared/money-input-display.tsx | 10 +++++----- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/app/features/receive/receive-input.tsx b/app/features/receive/receive-input.tsx index 9d78f4e5..62c2db62 100644 --- a/app/features/receive/receive-input.tsx +++ b/app/features/receive/receive-input.tsx @@ -130,7 +130,7 @@ export default function ReceiveInput() {
void; - /** CSS class for shake animation (from useAnimation) */ - shakeClassName?: string; + /** CSS class applied to the amount display on invalid input */ + inputErrorClassName?: string; }; /** @@ -121,7 +121,7 @@ type MoneyInputDisplayProps = { * const moneyInput = useMoneyInput({ ... }); * * -
+
Date: Mon, 2 Mar 2026 20:22:07 -0800 Subject: [PATCH 3/4] Add useMoneyInputField hook, migrate send and receive Composes useMoneyInput + useAnimation into a single hook so consumers don't wire them together manually. handleNumberInput is now single-arg (shake fires internally on invalid input). Also exposes showDecimal and inputErrorClassName so consumers don't derive them. --- app/features/receive/receive-input.tsx | 51 +++++++--------- app/features/send/send-input.tsx | 50 ++++++---------- app/features/shared/use-money-input-field.ts | 61 ++++++++++++++++++++ 3 files changed, 100 insertions(+), 62 deletions(-) create mode 100644 app/features/shared/use-money-input-field.ts diff --git a/app/features/receive/receive-input.tsx b/app/features/receive/receive-input.tsx index 62c2db62..08ddc417 100644 --- a/app/features/receive/receive-input.tsx +++ b/app/features/receive/receive-input.tsx @@ -16,8 +16,7 @@ import { import { accountOfflineToast } from '~/features/accounts/utils'; import { getDefaultUnit } from '~/features/shared/currencies'; import { MoneyInputDisplay } from '~/features/shared/money-input-display'; -import useAnimation from '~/hooks/use-animation'; -import { useMoneyInput } from '~/hooks/use-money-input'; +import { useMoneyInputField } from '~/features/shared/use-money-input-field'; import { useRedirectTo } from '~/hooks/use-redirect-to'; import { useBuildLinkWithSearchParams } from '~/hooks/use-search-params-link'; import { useToast } from '~/hooks/use-toast'; @@ -35,9 +34,6 @@ export default function ReceiveInput() { const { toast } = useToast(); const { redirectTo } = useRedirectTo('/'); const buildLinkWithSearchParams = useBuildLinkWithSearchParams(); - const { animationClass: shakeAnimationClass, start: startShakeAnimation } = - useAnimation({ name: 'shake' }); - const receiveAccountId = useReceiveStore((s) => s.accountId); const receiveAccount = useAccount(receiveAccountId); const receiveAmount = useReceiveStore((s) => s.amount); @@ -46,15 +42,7 @@ export default function ReceiveInput() { const setReceiveAmount = useReceiveStore((s) => s.setAmount); const { data: accounts } = useAccounts(); - const { - rawInputValue, - maxInputDecimals, - inputValue, - convertedValue, - exchangeRateError, - handleNumberInput, - switchInputCurrency, - } = useMoneyInput({ + const field = useMoneyInputField({ initialRawInputValue: receiveAmount?.toString(receiveCurrencyUnit) || '0', initialInputCurrency: receiveAccount.currency, initialOtherCurrency: receiveAccount.currency === 'BTC' ? 'USD' : 'BTC', @@ -66,14 +54,14 @@ export default function ReceiveInput() { return; } - if (inputValue.currency === receiveAccount.currency) { - setReceiveAmount(inputValue); + if (field.inputValue.currency === receiveAccount.currency) { + setReceiveAmount(field.inputValue); } else { - if (!convertedValue) { + if (!field.convertedValue) { // Can't happen because when there is no converted value, the toggle will not be shown so input currency and receive currency must be the same return; } - setReceiveAmount(convertedValue); + setReceiveAmount(field.convertedValue); } const nextPath = @@ -130,12 +118,12 @@ export default function ReceiveInput() {
@@ -146,8 +134,8 @@ export default function ReceiveInput() { selectedAccount={toAccountSelectorOption(receiveAccount)} onSelect={(account) => { setReceiveAccount(account); - if (account.currency !== inputValue.currency) { - switchInputCurrency(); + if (account.currency !== field.inputValue.currency) { + field.switchInputCurrency(); } }} disabled={accounts.length === 1} @@ -170,7 +158,10 @@ export default function ReceiveInput() {
{/* spacer */} -
@@ -178,10 +169,8 @@ export default function ReceiveInput() {
0} - onButtonClick={(value) => { - handleNumberInput(value, startShakeAnimation); - }} + showDecimal={field.showDecimal} + onButtonClick={field.handleNumberInput} /> diff --git a/app/features/send/send-input.tsx b/app/features/send/send-input.tsx index 5d534f0e..5c890df8 100644 --- a/app/features/send/send-input.tsx +++ b/app/features/send/send-input.tsx @@ -31,8 +31,7 @@ import { } from '~/features/accounts/account-selector'; import { accountOfflineToast } from '~/features/accounts/utils'; import { MoneyInputDisplay } from '~/features/shared/money-input-display'; -import useAnimation from '~/hooks/use-animation'; -import { useMoneyInput } from '~/hooks/use-money-input'; +import { useMoneyInputField } from '~/features/shared/use-money-input-field'; import { useRedirectTo } from '~/hooks/use-redirect-to'; import { useBuildLinkWithSearchParams } from '~/hooks/use-search-params-link'; import { useToast } from '~/hooks/use-toast'; @@ -54,8 +53,6 @@ export function SendInput() { const navigate = useNavigateWithViewTransition(); const { redirectTo } = useRedirectTo('/'); const buildLinkWithSearchParams = useBuildLinkWithSearchParams(); - const { animationClass: shakeAnimationClass, start: startShakeAnimation } = - useAnimation({ name: 'shake' }); const { data: accounts } = useAccounts(); const [selectDestinationDrawerOpen, setSelectDestinationDrawerOpen] = useState(false); @@ -74,16 +71,7 @@ export function SendInput() { : undefined; const initialInputCurrency = sendAmount?.currency ?? sendAccount.currency; - const { - rawInputValue, - maxInputDecimals, - inputValue, - convertedValue, - exchangeRateError, - handleNumberInput, - switchInputCurrency, - setInputValue, - } = useMoneyInput({ + const field = useMoneyInputField({ initialRawInputValue: sendAmount?.toString(sendAmountCurrencyUnit) || '0', initialInputCurrency: initialInputCurrency, initialOtherCurrency: initialInputCurrency === 'BTC' ? 'USD' : 'BTC', @@ -146,15 +134,15 @@ export function SendInput() { data: { amount }, } = result; - let latestInputValue = inputValue; - let latestConvertedValue = convertedValue; + let latestInputValue = field.inputValue; + let latestConvertedValue = field.convertedValue; if (amount) { const defaultUnit = getDefaultUnit(amount.currency); ({ newInputValue: latestInputValue, newConvertedValue: latestConvertedValue, - } = setInputValue(amount.toString(defaultUnit), amount.currency)); + } = field.setInputValue(amount.toString(defaultUnit), amount.currency)); } await handleContinue(latestInputValue, latestConvertedValue); @@ -184,12 +172,12 @@ export function SendInput() {
@@ -210,8 +198,8 @@ export function SendInput() { selectedAccount={toAccountSelectorOption(sendAccount)} onSelect={(account) => { selectSourceAccount(account); - if (account.currency !== inputValue.currency) { - switchInputCurrency(); + if (account.currency !== field.inputValue.currency) { + field.switchInputCurrency(); } }} disabled={accounts.length === 1} @@ -242,8 +230,10 @@ export function SendInput() {
{/* spacer */}
+ + + + + } + >
@@ -141,38 +147,7 @@ export default function ReceiveInput() { disabled={accounts.length === 1} />
- -
-
-
- - - - - -
-
{/* spacer */} - -
-
- - - - + ); } diff --git a/app/features/send/send-input.tsx b/app/features/send/send-input.tsx index 5c890df8..0051981a 100644 --- a/app/features/send/send-input.tsx +++ b/app/features/send/send-input.tsx @@ -7,16 +7,12 @@ import { ZapIcon, } from 'lucide-react'; import { useState } from 'react'; -import { Numpad } from '~/components/numpad'; import { ClosePageButton, - PageContent, - PageFooter, PageHeader, PageHeaderTitle, } from '~/components/page'; import { SearchBar } from '~/components/search-bar'; -import { Button } from '~/components/ui/button'; import { Drawer, DrawerContent, @@ -30,8 +26,10 @@ import { toAccountSelectorOption, } from '~/features/accounts/account-selector'; import { accountOfflineToast } from '~/features/accounts/utils'; -import { MoneyInputDisplay } from '~/features/shared/money-input-display'; -import { useMoneyInputField } from '~/features/shared/use-money-input-field'; +import { + MoneyInputLayout, + useMoneyInputField, +} from '~/features/shared/money-input-layout'; import { useRedirectTo } from '~/hooks/use-redirect-to'; import { useBuildLinkWithSearchParams } from '~/hooks/use-search-params-link'; import { useToast } from '~/hooks/use-toast'; @@ -169,17 +167,13 @@ export function SendInput() { Send - -
- - + + handleContinue(field.inputValue, field.convertedValue) + } + continueLoading={status === 'quoting'} + belowDisplay={
{destinationDisplay && ( <> @@ -188,8 +182,27 @@ export function SendInput() { )}
-
- + } + actions={ + <> + + + + + + + } + >
@@ -205,49 +218,7 @@ export function SendInput() { disabled={accounts.length === 1} />
- -
-
-
- - - - - - - -
-
{/* spacer */} -
- -
-
-
- - - - + ); } diff --git a/app/features/shared/money-input-display.tsx b/app/features/shared/money-input-display.tsx deleted file mode 100644 index 6cd7b418..00000000 --- a/app/features/shared/money-input-display.tsx +++ /dev/null @@ -1,160 +0,0 @@ -import { ArrowUpDown } from 'lucide-react'; -import { MoneyDisplay } from '~/components/money-display'; -import { Skeleton } from '~/components/ui/skeleton'; -import type { Currency, CurrencyUnit } from '~/lib/money'; -import { Money } from '~/lib/money'; -import { getDefaultUnit } from './currencies'; - -type ConvertedMoneySwitcherProps = { - onSwitch: () => void; - money?: Money; -}; - -const ConvertedMoneySwitcher = ({ - onSwitch, - money, -}: ConvertedMoneySwitcherProps) => { - if (!money) { - return ; - } - - return ( - - ); -}; - -function RawMoneyDisplay({ - inputValue, - currency, - unit, - locale, -}: { - inputValue: string; - currency: C; - unit: CurrencyUnit; - locale?: string; -}) { - const money = new Money({ amount: inputValue, currency, unit }); - const { - currencySymbol, - currencySymbolPosition, - integer, - numberOfDecimals, - decimalSeparator, - } = money.toLocalizedStringParts({ - locale, - unit, - minimumFractionDigits: 'max', - }); - - const inputHasDecimalPoint = decimalSeparator - ? inputValue.includes(decimalSeparator) - : false; - const inputDecimals = inputHasDecimalPoint - ? inputValue.split(decimalSeparator)[1] - : ''; - - const needsPaddedZeros = - inputHasDecimalPoint && inputDecimals.length < numberOfDecimals; - const paddedZeros = needsPaddedZeros - ? '0'.repeat(numberOfDecimals - inputDecimals.length) - : ''; - - const symbol = {currencySymbol}; - - return ( - - {currencySymbolPosition === 'prefix' && symbol} - - {integer} - {(inputDecimals || needsPaddedZeros) && ( - <> - {decimalSeparator} - {inputDecimals} - {paddedZeros && ( - {paddedZeros} - )} - - )} - - {currencySymbolPosition === 'suffix' && symbol} - - ); -} - -type MoneyInputDisplayProps = { - /** Raw input string from useMoneyInput (e.g. "1", "1.", "1.00") */ - rawInputValue: string; - /** Parsed Money from useMoneyInput */ - inputValue: Money; - /** Converted Money from useMoneyInput (undefined while rates load) */ - convertedValue?: Money; - /** Truthy when exchange rates failed to load */ - exchangeRateError?: Error | null; - /** Toggle between input and converted currency */ - onSwitchCurrency: () => void; - /** CSS class applied to the amount display on invalid input */ - inputErrorClassName?: string; -}; - -/** - * Amount input display with currency switcher. Used by all input pages - * (receive, send, transfer, buy). - * - * The parent owns the shake animation since it connects the numpad - * (trigger) to this component (visual): - * - * ```tsx - * const { animationClass, start: shake } = useAnimation({ name: 'shake' }); - * const moneyInput = useMoneyInput({ ... }); - * - * - * moneyInput.handleNumberInput(v, shake)} /> - * ``` - */ -export function MoneyInputDisplay({ - rawInputValue, - inputValue, - convertedValue, - exchangeRateError, - onSwitchCurrency, - inputErrorClassName, -}: MoneyInputDisplayProps) { - return ( -
-
- -
- - {!exchangeRateError && ( - - )} -
- ); -} diff --git a/app/features/shared/money-input-layout.tsx b/app/features/shared/money-input-layout.tsx new file mode 100644 index 00000000..84fe6874 --- /dev/null +++ b/app/features/shared/money-input-layout.tsx @@ -0,0 +1,299 @@ +import { ArrowUpDown } from 'lucide-react'; +import { MoneyDisplay } from '~/components/money-display'; +import type { NumpadButton } from '~/components/numpad'; +import { Numpad } from '~/components/numpad'; +import { PageContent, PageFooter } from '~/components/page'; +import { Button } from '~/components/ui/button'; +import { Skeleton } from '~/components/ui/skeleton'; +import useAnimation from '~/hooks/use-animation'; +import { useMoneyInput } from '~/hooks/use-money-input'; +import type { Currency, CurrencyUnit } from '~/lib/money'; +import { Money } from '~/lib/money'; +import { getDefaultUnit } from './currencies'; + +// --------------------------------------------------------------------------- +// useMoneyInputField +// --------------------------------------------------------------------------- + +type UseMoneyInputFieldProps = { + initialRawInputValue: string; + initialInputCurrency: Currency; + initialOtherCurrency: Currency; +}; + +/** + * Composes useMoneyInput with a shake animation so consumers don't have + * to wire them together. Pass the returned `field` to `` + * which handles the display, numpad, and continue button automatically. + * + * ```tsx + * const field = useMoneyInputField({ ... }); + * + * + * + * + * ``` + */ +export function useMoneyInputField({ + initialRawInputValue, + initialInputCurrency, + initialOtherCurrency, +}: UseMoneyInputFieldProps) { + const { animationClass, start: shakeOnError } = useAnimation({ + name: 'shake', + }); + + const moneyInput = useMoneyInput({ + initialRawInputValue, + initialInputCurrency, + initialOtherCurrency, + }); + + const handleNumberInput = (input: NumpadButton) => { + moneyInput.handleNumberInput(input, shakeOnError); + }; + + return { + rawInputValue: moneyInput.rawInputValue, + inputValue: moneyInput.inputValue, + convertedValue: moneyInput.convertedValue, + exchangeRateError: moneyInput.exchangeRateError, + switchInputCurrency: moneyInput.switchInputCurrency, + setInputValue: moneyInput.setInputValue, + handleNumberInput, + showDecimal: moneyInput.maxInputDecimals > 0, + inputErrorClassName: animationClass, + }; +} + +// --------------------------------------------------------------------------- +// MoneyInputLayout +// --------------------------------------------------------------------------- + +type MoneyInputFieldReturn = ReturnType; + +type MoneyInputLayoutProps = { + /** The money input field state (from useMoneyInputField) */ + field: MoneyInputFieldReturn; + /** Content rendered between the display area and the action row (e.g. AccountSelector) */ + children?: React.ReactNode; + /** Left side of the action row (e.g. paste/scan buttons) */ + actions?: React.ReactNode; + /** Continue button handler */ + onContinue: () => void; + /** Override the continue button label (defaults to "Continue") */ + continueLabel?: string; + /** Additional disabled condition — isZero() is always checked */ + continueDisabled?: boolean; + /** Show loading spinner on the continue button */ + continueLoading?: boolean; + /** Content rendered alongside MoneyInputDisplay (e.g. destination display in send). + * When provided, display and this content are wrapped in a flex column. */ + belowDisplay?: React.ReactNode; +}; + +/** + * Shared layout for money input pages (send, receive, transfer, buy). + * + * Renders: MoneyInputDisplay → children slot → action row → Numpad. + * The parent provides the page header (title/close vary per page). + * + * ```tsx + * ... + * + * + * + * ``` + */ +export function MoneyInputLayout({ + field, + children, + actions, + onContinue, + continueLabel = 'Continue', + continueDisabled, + continueLoading, + belowDisplay, +}: MoneyInputLayoutProps) { + const display = ( + + ); + + return ( + <> + + {belowDisplay ? ( +
+ {display} + {belowDisplay} +
+ ) : ( + display + )} + + {children} + +
+
+
+ {actions} +
+
+
+ +
+
+
+ + + + + + ); +} + +// --------------------------------------------------------------------------- +// Internal display components +// --------------------------------------------------------------------------- + +type ConvertedMoneySwitcherProps = { + onSwitch: () => void; + money?: Money; +}; + +const ConvertedMoneySwitcher = ({ + onSwitch, + money, +}: ConvertedMoneySwitcherProps) => { + if (!money) { + return ; + } + + return ( + + ); +}; + +function RawMoneyDisplay({ + inputValue, + currency, + unit, + locale, +}: { + inputValue: string; + currency: C; + unit: CurrencyUnit; + locale?: string; +}) { + const money = new Money({ amount: inputValue, currency, unit }); + const { + currencySymbol, + currencySymbolPosition, + integer, + numberOfDecimals, + decimalSeparator, + } = money.toLocalizedStringParts({ + locale, + unit, + minimumFractionDigits: 'max', + }); + + const inputHasDecimalPoint = decimalSeparator + ? inputValue.includes(decimalSeparator) + : false; + const inputDecimals = inputHasDecimalPoint + ? inputValue.split(decimalSeparator)[1] + : ''; + + const needsPaddedZeros = + inputHasDecimalPoint && inputDecimals.length < numberOfDecimals; + const paddedZeros = needsPaddedZeros + ? '0'.repeat(numberOfDecimals - inputDecimals.length) + : ''; + + const symbol = {currencySymbol}; + + return ( + + {currencySymbolPosition === 'prefix' && symbol} + + {integer} + {(inputDecimals || needsPaddedZeros) && ( + <> + {decimalSeparator} + {inputDecimals} + {paddedZeros && ( + {paddedZeros} + )} + + )} + + {currencySymbolPosition === 'suffix' && symbol} + + ); +} + +type MoneyInputDisplayProps = { + rawInputValue: string; + inputValue: Money; + convertedValue?: Money; + exchangeRateError?: Error | null; + onSwitchCurrency: () => void; + inputErrorClassName?: string; +}; + +function MoneyInputDisplay({ + rawInputValue, + inputValue, + convertedValue, + exchangeRateError, + onSwitchCurrency, + inputErrorClassName, +}: MoneyInputDisplayProps) { + return ( +
+
+ +
+ + {!exchangeRateError && ( + + )} +
+ ); +} diff --git a/app/features/shared/use-money-input-field.ts b/app/features/shared/use-money-input-field.ts deleted file mode 100644 index 32ae8aa5..00000000 --- a/app/features/shared/use-money-input-field.ts +++ /dev/null @@ -1,61 +0,0 @@ -import type { NumpadButton } from '~/components/numpad'; -import useAnimation from '~/hooks/use-animation'; -import { useMoneyInput } from '~/hooks/use-money-input'; -import type { Currency } from '~/lib/money'; - -type Props = { - initialRawInputValue: string; - initialInputCurrency: Currency; - initialOtherCurrency: Currency; -}; - -/** - * Composes useMoneyInput with a shake animation so consumers don't have - * to wire them together. Every money input page (send, receive, transfer, - * buy) uses this same combination. - * - * ```tsx - * const field = useMoneyInputField({ ... }); - * - * - * - * ``` - */ -export function useMoneyInputField({ - initialRawInputValue, - initialInputCurrency, - initialOtherCurrency, -}: Props) { - const { animationClass, start: shakeOnError } = useAnimation({ - name: 'shake', - }); - - const moneyInput = useMoneyInput({ - initialRawInputValue, - initialInputCurrency, - initialOtherCurrency, - }); - - const handleNumberInput = (input: NumpadButton) => { - moneyInput.handleNumberInput(input, shakeOnError); - }; - - return { - rawInputValue: moneyInput.rawInputValue, - inputValue: moneyInput.inputValue, - convertedValue: moneyInput.convertedValue, - exchangeRateError: moneyInput.exchangeRateError, - switchInputCurrency: moneyInput.switchInputCurrency, - setInputValue: moneyInput.setInputValue, - handleNumberInput, - showDecimal: moneyInput.maxInputDecimals > 0, - inputErrorClassName: animationClass, - }; -}