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..0493919f 100644 --- a/app/features/receive/receive-input.tsx +++ b/app/features/receive/receive-input.tsx @@ -1,29 +1,24 @@ import { getEncodedToken } from '@cashu/cashu-ts'; -import { ArrowUpDown, Clipboard, Scan } from 'lucide-react'; -import { MoneyDisplay, MoneyInputDisplay } from '~/components/money-display'; -import { Numpad } from '~/components/numpad'; +import { Clipboard, Scan } from 'lucide-react'; import { ClosePageButton, - PageContent, - PageFooter, PageHeader, 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 useAnimation from '~/hooks/use-animation'; -import { useMoneyInput } from '~/hooks/use-money-input'; +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'; import { extractCashuToken } from '~/lib/cashu'; -import type { Money } from '~/lib/money'; import { readClipboard } from '~/lib/read-clipboard'; import { LinkWithViewTransition, @@ -32,44 +27,11 @@ 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(); 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); @@ -78,15 +40,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', @@ -98,14 +52,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 = @@ -160,24 +114,24 @@ export default function ReceiveInput() { Receive - -
-
- -
- - {!exchangeRateError && ( - - )} -
- + + + + + + + } + >
@@ -186,44 +140,14 @@ 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} />
- -
-
-
- - - - - -
-
{/* spacer */} - -
-
- - - 0} - onButtonClick={(value) => { - handleNumberInput(value, startShakeAnimation); - }} - /> - + ); } diff --git a/app/features/send/send-input.tsx b/app/features/send/send-input.tsx index 3fdb2cac..0051981a 100644 --- a/app/features/send/send-input.tsx +++ b/app/features/send/send-input.tsx @@ -1,5 +1,4 @@ import { - ArrowUpDown, AtSign, Clipboard, LoaderCircle, @@ -8,33 +7,29 @@ import { ZapIcon, } from 'lucide-react'; import { useState } from 'react'; -import { MoneyDisplay, MoneyInputDisplay } from '~/components/money-display'; -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, DrawerHeader, DrawerTitle, + DrawerTrigger, } from '~/components/ui/drawer'; -import { DrawerTrigger } from '~/components/ui/drawer'; -import { Drawer } from '~/components/ui/drawer'; -import { Skeleton } from '~/components/ui/skeleton'; import { useAccounts } from '~/features/accounts/account-hooks'; import { AccountSelector, toAccountSelectorOption, } from '~/features/accounts/account-selector'; import { accountOfflineToast } from '~/features/accounts/utils'; -import useAnimation from '~/hooks/use-animation'; -import { useMoneyInput } from '~/hooks/use-money-input'; +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'; @@ -51,45 +46,11 @@ import { getDefaultUnit } from '../shared/currencies'; import { DomainError, getErrorMessage } from '../shared/error'; import { useSendStore } from './send-provider'; -type ConvertedMoneySwitcherProps = { - onSwitchInputCurrency: () => void; - money?: Money; -}; - -const ConvertedMoneySwitcher = ({ - onSwitchInputCurrency, - money, -}: ConvertedMoneySwitcherProps) => { - if (!money) { - return ; - } - - return ( - - ); -}; - export function SendInput() { const { toast } = useToast(); 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); @@ -108,16 +69,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', @@ -180,15 +132,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); @@ -215,25 +167,13 @@ export function SendInput() { Send - -
-
-
- -
- - {!exchangeRateError && ( - - )} -
- + + handleContinue(field.inputValue, field.convertedValue) + } + continueLoading={status === 'quoting'} + belowDisplay={
{destinationDisplay && ( <> @@ -242,8 +182,27 @@ export function SendInput() { )}
-
- + } + actions={ + <> + + + + + + + } + >
@@ -252,56 +211,14 @@ 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} />
- -
-
-
- - - - - - - -
-
{/* spacer */} -
- -
-
-
- - - 0} - onButtonClick={(value) => { - handleNumberInput(value, startShakeAnimation); - }} - /> - + ); } 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 && ( + + )} +
+ ); +}