From ad4a29c8e4ed7478bf464fb0f93418aff2615cf0 Mon Sep 17 00:00:00 2001 From: gudnuf Date: Mon, 2 Mar 2026 19:58:13 -0800 Subject: [PATCH 1/6] 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/6] 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/6] 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, - }; -} From fc38ad17a7766edef672929f0e151485f32f9776 Mon Sep 17 00:00:00 2001 From: gudnuf Date: Mon, 2 Mar 2026 22:33:35 -0800 Subject: [PATCH 5/6] Add receive quote creation to receive store Add ReceiveQuote type and getReceiveQuote action to the receive store, allowing the buy flow (and future flows) to create receive quotes directly through the store. The provider injects cashu/spark quote creation as dependencies. --- app/features/receive/receive-provider.tsx | 10 +++ app/features/receive/receive-store.ts | 86 +++++++++++++++++++++-- 2 files changed, 89 insertions(+), 7 deletions(-) diff --git a/app/features/receive/receive-provider.tsx b/app/features/receive/receive-provider.tsx index 33b2fdc1..f5280169 100644 --- a/app/features/receive/receive-provider.tsx +++ b/app/features/receive/receive-provider.tsx @@ -6,8 +6,11 @@ import { } from 'react'; import { useStore } from 'zustand'; import type { Account } from '../accounts/account'; +import { useGetAccount } from '../accounts/account-hooks'; +import { useCreateCashuReceiveQuote } from './cashu-receive-quote-hooks'; import type { ReceiveState, ReceiveStore } from './receive-store'; import { createReceiveStore } from './receive-store'; +import { useCreateSparkReceiveQuote } from './spark-receive-quote-hooks'; const ReceiveContext = createContext(null); @@ -17,10 +20,17 @@ type Props = PropsWithChildren<{ }>; export const ReceiveProvider = ({ children, initialAccount }: Props) => { + const getAccount = useGetAccount(); + const { mutateAsync: createCashuReceiveQuote } = useCreateCashuReceiveQuote(); + const { mutateAsync: createSparkReceiveQuote } = useCreateSparkReceiveQuote(); + const [store] = useState(() => createReceiveStore({ initialAccount, initialAmount: null, + getAccount, + createCashuReceiveQuote, + createSparkReceiveQuote, }), ); diff --git a/app/features/receive/receive-store.ts b/app/features/receive/receive-store.ts index 971eece4..d6598a53 100644 --- a/app/features/receive/receive-store.ts +++ b/app/features/receive/receive-store.ts @@ -1,30 +1,102 @@ import { create } from 'zustand'; import type { Currency, Money } from '~/lib/money'; -import type { Account } from '../accounts/account'; +import type { Account, CashuAccount, SparkAccount } from '../accounts/account'; +import type { CashuReceiveQuote } from './cashu-receive-quote'; +import type { SparkReceiveQuote } from './spark-receive-quote'; + +export type ReceiveQuote = { + id: string; + paymentRequest: string; + transactionId: string; + mintingFee?: Money; +}; + +type GetReceiveQuoteResult = + | { success: true; quote: ReceiveQuote } + | { success: false; error: unknown }; export type ReceiveState = { + status: 'idle' | 'quoting'; /** The ID of the account to receive funds in */ accountId: string; /** The amount to receive in the account's currency */ amount: Money | null; + /** The receive quote created for the buy flow */ + quote: ReceiveQuote | null; /** Set the account to receive funds in */ setAccount: (account: Account) => void; /** Set the amount to receive in the account's currency */ setAmount: (amount: Money) => void; + /** Create a receive quote for the given amount */ + getReceiveQuote: (amount: Money) => Promise; +}; + +type CreateReceiveStoreProps = { + initialAccount: Account; + initialAmount: Money | null; + getAccount: (id: string) => Account; + createCashuReceiveQuote: (params: { + account: CashuAccount; + amount: Money; + }) => Promise; + createSparkReceiveQuote: (params: { + account: SparkAccount; + amount: Money; + }) => Promise; }; export const createReceiveStore = ({ initialAccount, initialAmount, -}: { - initialAccount: Account; - initialAmount: Money | null; -}) => { - return create((set) => ({ + getAccount, + createCashuReceiveQuote, + createSparkReceiveQuote, +}: CreateReceiveStoreProps) => { + return create((set, get) => ({ + status: 'idle', accountId: initialAccount.id, amount: initialAmount, - setAccount: (account) => set({ accountId: account.id, amount: null }), + quote: null, + setAccount: (account) => + set({ accountId: account.id, amount: null, quote: null }), setAmount: (amount) => set({ amount }), + getReceiveQuote: async (amount) => { + const account = getAccount(get().accountId); + set({ status: 'quoting', amount }); + + try { + let quote: ReceiveQuote; + + if (account.type === 'cashu') { + const cashuQuote = await createCashuReceiveQuote({ + account, + amount, + }); + quote = { + id: cashuQuote.id, + paymentRequest: cashuQuote.paymentRequest, + transactionId: cashuQuote.transactionId, + mintingFee: cashuQuote.mintingFee, + }; + } else { + const sparkQuote = await createSparkReceiveQuote({ + account, + amount, + }); + quote = { + id: sparkQuote.id, + paymentRequest: sparkQuote.paymentRequest, + transactionId: sparkQuote.transactionId, + }; + } + + set({ status: 'idle', quote }); + return { success: true, quote }; + } catch (error) { + set({ status: 'idle' }); + return { success: false, error }; + } + }, })); }; From c723525136d3f53684b3cbfda5f9f127fa580839 Mon Sep 17 00:00:00 2001 From: gudnuf Date: Mon, 2 Mar 2026 22:34:20 -0800 Subject: [PATCH 6/6] Add Buy with Cash App top-up flow Buy flow lets users purchase bitcoin via Cash App Lightning payments. Uses the receive store to create invoices, then deep-links to Cash App for payment. Supports both Cashu and Spark accounts. --- app/features/buy/buy-checkout.tsx | 188 ++++++++++++++++++++++ app/features/buy/buy-input.tsx | 207 +++++++++++++++++++++++++ app/features/buy/cash-app.tsx | 20 +++ app/features/buy/index.ts | 2 + app/routes/_protected._index.tsx | 25 ++- app/routes/_protected.buy._index.tsx | 10 ++ app/routes/_protected.buy.checkout.tsx | 26 ++++ app/routes/_protected.buy.tsx | 25 +++ 8 files changed, 495 insertions(+), 8 deletions(-) create mode 100644 app/features/buy/buy-checkout.tsx create mode 100644 app/features/buy/buy-input.tsx create mode 100644 app/features/buy/cash-app.tsx create mode 100644 app/features/buy/index.ts create mode 100644 app/routes/_protected.buy._index.tsx create mode 100644 app/routes/_protected.buy.checkout.tsx create mode 100644 app/routes/_protected.buy.tsx diff --git a/app/features/buy/buy-checkout.tsx b/app/features/buy/buy-checkout.tsx new file mode 100644 index 00000000..bfb080e7 --- /dev/null +++ b/app/features/buy/buy-checkout.tsx @@ -0,0 +1,188 @@ +import { Loader2 } from 'lucide-react'; +import { MoneyDisplay } from '~/components/money-display'; +import { + PageBackButton, + PageContent, + PageHeader, + PageHeaderTitle, +} from '~/components/page'; +import { QRCode } from '~/components/qr-code'; +import { Button } from '~/components/ui/button'; +import { Card, CardContent } from '~/components/ui/card'; +import { useBuildLinkWithSearchParams } from '~/hooks/use-search-params-link'; +import useUserAgent from '~/hooks/use-user-agent'; +import type { Money } from '~/lib/money'; +import { useNavigateWithViewTransition } from '~/lib/transitions'; +import { useCashuReceiveQuote } from '../receive/cashu-receive-quote-hooks'; +import type { ReceiveQuote } from '../receive/receive-store'; +import { useSparkReceiveQuote } from '../receive/spark-receive-quote-hooks'; +import { getDefaultUnit } from '../shared/currencies'; +import { MoneyWithConvertedAmount } from '../shared/money-with-converted-amount'; +import { CashAppLogo, buildCashAppDeepLink } from './cash-app'; + +function CashAppCheckout({ + paymentRequest, + amount, + errorMessage, + fee, +}: { + paymentRequest: string; + amount: Money; + errorMessage: string | undefined; + fee?: Money; +}) { + const { isMobile } = useUserAgent(); + const buildLinkWithSearchParams = useBuildLinkWithSearchParams(); + + const deepLinkUrl = buildCashAppDeepLink(paymentRequest); + + const displayAmount = fee ? amount.add(fee) : amount; + + return ( + <> + + + Buy + + + + + {isMobile ? ( +
+ {errorMessage && ( +

+ {errorMessage} +

+ )} + + {!errorMessage && ( + <> +
+ + Waiting for payment... +
+ + + + )} +
+ ) : ( + window.open(deepLinkUrl, '_blank')} + className="gap-4" + size={256} + /> + )} + + {/* TODO: this is duplicated from receive-cashu.tsx — consider extracting to a shared component */} + {fee && ( + + +
+

Receive

+ +
+
+

Fee

+ +
+
+
+ )} +
+ + ); +} + +export function BuyCheckoutCashu({ + quote, + amount, +}: { + quote: ReceiveQuote; + amount: Money; +}) { + const navigate = useNavigateWithViewTransition(); + const buildLinkWithSearchParams = useBuildLinkWithSearchParams(); + + const { status: quotePaymentStatus } = useCashuReceiveQuote({ + quoteId: quote.id, + onPaid: (cashuQuote) => { + navigate( + buildLinkWithSearchParams(`/transactions/${cashuQuote.transactionId}`, { + showOkButton: 'true', + }), + { transition: 'slideLeft', applyTo: 'newView' }, + ); + }, + }); + + const errorMessage = + quotePaymentStatus === 'EXPIRED' + ? 'This invoice has expired. Please create a new one.' + : undefined; + + return ( + + ); +} + +export function BuyCheckoutSpark({ + quote, + amount, +}: { + quote: ReceiveQuote; + amount: Money; +}) { + const navigate = useNavigateWithViewTransition(); + const buildLinkWithSearchParams = useBuildLinkWithSearchParams(); + + const { status: quotePaymentStatus } = useSparkReceiveQuote({ + quoteId: quote.id, + onPaid: (sparkQuote) => { + navigate( + buildLinkWithSearchParams(`/transactions/${sparkQuote.transactionId}`, { + showOkButton: 'true', + }), + { transition: 'slideLeft', applyTo: 'newView' }, + ); + }, + }); + + const errorMessage = + quotePaymentStatus === 'EXPIRED' + ? 'This invoice has expired. Please create a new one.' + : undefined; + + return ( + + ); +} diff --git a/app/features/buy/buy-input.tsx b/app/features/buy/buy-input.tsx new file mode 100644 index 00000000..f6ce1fa4 --- /dev/null +++ b/app/features/buy/buy-input.tsx @@ -0,0 +1,207 @@ +import { Info } from 'lucide-react'; +import { + ClosePageButton, + PageHeader, + PageHeaderItem, + type PageHeaderPosition, + PageHeaderTitle, +} from '~/components/page'; +import { + Drawer, + DrawerContent, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from '~/components/ui/drawer'; +import { + AccountSelector, + toAccountSelectorOption, +} from '~/features/accounts/account-selector'; +import { accountOfflineToast } from '~/features/accounts/utils'; +import { getDefaultUnit } from '~/features/shared/currencies'; +import { DomainError } from '~/features/shared/error'; +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 useUserAgent from '~/hooks/use-user-agent'; +import type { Money } from '~/lib/money'; +import { useNavigateWithViewTransition } from '~/lib/transitions'; +import { useAccount, useAccounts } from '../accounts/account-hooks'; +import { useReceiveStore } from '../receive/receive-provider'; +import { CashAppLogo, buildCashAppDeepLink } from './cash-app'; + +export default function BuyInput() { + const navigate = useNavigateWithViewTransition(); + const buildLinkWithSearchParams = useBuildLinkWithSearchParams(); + const { toast } = useToast(); + const { redirectTo } = useRedirectTo('/'); + const { isMobile } = useUserAgent(); + + const buyAccountId = useReceiveStore((s) => s.accountId); + const buyAccount = useAccount(buyAccountId); + const buyAmount = useReceiveStore((s) => s.amount); + const buyCurrencyUnit = getDefaultUnit(buyAccount.currency); + const setBuyAccount = useReceiveStore((s) => s.setAccount); + const getReceiveQuote = useReceiveStore((s) => s.getReceiveQuote); + const status = useReceiveStore((s) => s.status); + const { data: accounts } = useAccounts(); + + const field = useMoneyInputField({ + initialRawInputValue: buyAmount?.toString(buyCurrencyUnit) || '0', + initialInputCurrency: buyAccount.currency, + initialOtherCurrency: buyAccount.currency === 'BTC' ? 'USD' : 'BTC', + }); + + const handleContinue = async () => { + if (!buyAccount.isOnline) { + toast(accountOfflineToast); + return; + } + + let amount: Money; + if (field.inputValue.currency === buyAccount.currency) { + amount = field.inputValue; + } else { + if (!field.convertedValue) { + // Can't happen because when there is no converted value, the toggle will not be shown so input currency and buy currency must be the same + return; + } + amount = field.convertedValue; + } + + const result = await getReceiveQuote(amount); + + if (!result.success) { + if (result.error instanceof DomainError) { + toast({ description: result.error.message }); + } else { + console.error('Failed to create invoice', { cause: result.error }); + toast({ + description: 'Failed to create invoice. Please try again.', + variant: 'destructive', + }); + } + return; + } + + if (isMobile) { + window.open(buildCashAppDeepLink(result.quote.paymentRequest), '_blank'); + } + + navigate(buildLinkWithSearchParams('/buy/checkout'), { + transition: 'slideLeft', + applyTo: 'newView', + }); + }; + + return ( + <> + + + Buy + + + + + Pay with + + + } + > +
+ + toAccountSelectorOption(account), + )} + selectedAccount={toAccountSelectorOption(buyAccount)} + onSelect={(account) => { + setBuyAccount(account); + if (account.currency !== field.inputValue.currency) { + field.switchInputCurrency(); + } + }} + disabled={accounts.length === 1} + /> +
+
+ + ); +} + +const faqItems = [ + { + question: 'Why Cash App?', + answer: + 'Cash App is the first supported payment method because it natively supports Bitcoin Lightning payments.', + }, + { + question: "What if I don't have Cash App?", + answer: + "Don't worry, we're launching more payment options soon. In the meantime, you can receive bitcoin from any Bitcoin Lightning wallet by tapping the Receive button.", + }, + { + question: "Why isn't my Cash App loading?", + answer: "Make sure you've downloaded the latest version.", + }, + { + question: 'What are the fees?', + answer: + 'None. Agicash charges zero fees. Cash App charges zero fees. Your transaction executes at the mid-market rate, making this the cheapest way to buy bitcoin.', + }, + { + question: 'Is there a purchase limit?', + answer: + 'Cash App has a $999/week limit on Lightning payments. This is a Cash App limit, not an Agicash limit.', + }, + { + question: 'How fast is it?', + answer: + 'Instant. Your purchase and settlement happen in seconds over the Bitcoin Lightning Network.', + }, +]; + +function BuyFaqDrawer() { + return ( + + + + + + + + Frequently Asked Questions + +
+
+ {faqItems.map((item) => ( +
+

{item.question}

+

+ {item.answer} +

+
+ ))} +
+
+
+
+
+ ); +} +BuyFaqDrawer.isHeaderItem = true; +BuyFaqDrawer.defaultPosition = 'right' as PageHeaderPosition; diff --git a/app/features/buy/cash-app.tsx b/app/features/buy/cash-app.tsx new file mode 100644 index 00000000..c008185b --- /dev/null +++ b/app/features/buy/cash-app.tsx @@ -0,0 +1,20 @@ +import { useTheme } from '~/features/theme/use-theme'; + +export function buildCashAppDeepLink(paymentRequest: string) { + return `https://cash.app/launch/lightning/${paymentRequest}`; +} + +export const CASH_APP_LOGO_WHITE = + 'https://static.afterpaycdn.com/en-US/integration/logo/lockup/cashapp-color-white-32.svg'; +export const CASH_APP_LOGO_BLACK = + 'https://static.afterpaycdn.com/en-US/integration/logo/lockup/cashapp-color-black-32.svg'; + +export function CashAppLogo({ className }: { className?: string }) { + const { effectiveColorMode } = useTheme(); + const logoUrl = + effectiveColorMode === 'dark' ? CASH_APP_LOGO_WHITE : CASH_APP_LOGO_BLACK; + + return ( + Cash App + ); +} diff --git a/app/features/buy/index.ts b/app/features/buy/index.ts new file mode 100644 index 00000000..b0355d33 --- /dev/null +++ b/app/features/buy/index.ts @@ -0,0 +1,2 @@ +export { BuyCheckoutCashu, BuyCheckoutSpark } from './buy-checkout'; +export { default as BuyInput } from './buy-input'; diff --git a/app/routes/_protected._index.tsx b/app/routes/_protected._index.tsx index 41af69e2..914f58ad 100644 --- a/app/routes/_protected._index.tsx +++ b/app/routes/_protected._index.tsx @@ -84,14 +84,23 @@ export default function Index() {
)} -
- - - +
+
+ + + + + + +
+ + + ); +} diff --git a/app/routes/_protected.buy.checkout.tsx b/app/routes/_protected.buy.checkout.tsx new file mode 100644 index 00000000..fef98df0 --- /dev/null +++ b/app/routes/_protected.buy.checkout.tsx @@ -0,0 +1,26 @@ +import { Page } from '~/components/page'; +import { Redirect } from '~/components/redirect'; +import { useAccount } from '~/features/accounts/account-hooks'; +import { BuyCheckoutCashu, BuyCheckoutSpark } from '~/features/buy'; +import { useReceiveStore } from '~/features/receive/receive-provider'; + +export default function BuyCheckoutPage() { + const buyAmount = useReceiveStore((s) => s.amount); + const buyAccountId = useReceiveStore((s) => s.accountId); + const quote = useReceiveStore((s) => s.quote); + const account = useAccount(buyAccountId); + + if (!buyAmount || !quote) { + return ; + } + + return ( + + {account.type === 'cashu' ? ( + + ) : ( + + )} + + ); +} diff --git a/app/routes/_protected.buy.tsx b/app/routes/_protected.buy.tsx new file mode 100644 index 00000000..f0bec6f7 --- /dev/null +++ b/app/routes/_protected.buy.tsx @@ -0,0 +1,25 @@ +import type { LinksFunction } from 'react-router'; +import { Outlet, useSearchParams } from 'react-router'; +import { useAccountOrDefault } from '~/features/accounts/account-hooks'; +import { + CASH_APP_LOGO_BLACK, + CASH_APP_LOGO_WHITE, +} from '~/features/buy/cash-app'; +import { ReceiveProvider } from '~/features/receive'; + +export const links: LinksFunction = () => [ + { rel: 'prefetch', href: CASH_APP_LOGO_WHITE, as: 'image' }, + { rel: 'prefetch', href: CASH_APP_LOGO_BLACK, as: 'image' }, +]; + +export default function BuyLayout() { + const [searchParams] = useSearchParams(); + const accountId = searchParams.get('accountId'); + const initialAccount = useAccountOrDefault(accountId); + + return ( + + + + ); +}