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/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/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/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 }; + } + }, })); }; 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 && ( + + )} +
+ ); +} 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 ( + + + + ); +}