diff --git a/app/components/ui/button.tsx b/app/components/ui/button.tsx
index 9675a1d6..cdfcec9b 100644
--- a/app/components/ui/button.tsx
+++ b/app/components/ui/button.tsx
@@ -16,7 +16,7 @@ const buttonVariants = cva(
outline:
'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
secondary:
- 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
+ 'border border-border bg-card text-card-foreground hover:bg-card/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
diff --git a/app/features/buy/buy-checkout.tsx b/app/features/buy/buy-checkout.tsx
new file mode 100644
index 00000000..6de32393
--- /dev/null
+++ b/app/features/buy/buy-checkout.tsx
@@ -0,0 +1,264 @@
+import { AlertCircle } from 'lucide-react';
+import { MoneyDisplay } from '~/components/money-display';
+import {
+ PageBackButton,
+ PageContent,
+ PageFooter,
+ 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 { useSparkReceiveQuote } from '../receive/spark-receive-quote-hooks';
+import { getDefaultUnit } from '../shared/currencies';
+import { MoneyWithConvertedAmount } from '../shared/money-with-converted-amount';
+import type { BuyQuote } from './buy-store';
+import { buildCashAppDeepLink } from './cash-app';
+
+const getErrorMessageFromQuoteStatus = (status: string) => {
+ if (status === 'EXPIRED') {
+ return 'This invoice has expired. Please create a new one.';
+ }
+ if (status === 'FAILED') {
+ return 'Something went wrong. Please try again.';
+ }
+ return undefined;
+};
+
+const useNavigateToTransaction = () => {
+ const navigate = useNavigateWithViewTransition();
+ const buildLinkWithSearchParams = useBuildLinkWithSearchParams();
+
+ return (transactionId: string) => {
+ navigate(
+ buildLinkWithSearchParams(`/transactions/${transactionId}`, {
+ showOkButton: 'true',
+ }),
+ { transition: 'fade', applyTo: 'newView' },
+ );
+ };
+};
+
+const ConfirmationRow = ({
+ label,
+ value,
+}: { label: string; value: React.ReactNode }) => {
+ return (
+
+ );
+};
+
+function ErrorCard({ message }: { message: string }) {
+ return (
+
+
+
+ {message}
+
+
+ );
+}
+
+function ConfirmationDetails({
+ accountName,
+ fee,
+}: { accountName: string; fee?: Money }) {
+ return (
+
+
+
+
+ {fee && (
+
+ }
+ />
+ )}
+
+
+ );
+}
+
+function MobileCheckoutContent({
+ errorMessage,
+ accountName,
+ fee,
+}: {
+ errorMessage: string | undefined;
+ accountName: string;
+ fee?: Money;
+}) {
+ return (
+
+
+ {errorMessage ? (
+
+ ) : (
+
+ )}
+
+
+ );
+}
+
+function DesktopCheckoutContent({
+ errorMessage,
+ paymentRequest,
+ accountName,
+ fee,
+}: {
+ errorMessage: string | undefined;
+ paymentRequest: string;
+ accountName: string;
+ fee?: Money;
+}) {
+ if (errorMessage) {
+ return (
+
+
+
+ );
+ }
+
+ const deepLinkUrl = buildCashAppDeepLink(paymentRequest);
+
+ return (
+ <>
+
+
+
+
+ >
+ );
+}
+
+function CashAppCheckout({
+ paymentRequest,
+ amount,
+ accountName,
+ errorMessage,
+ fee,
+}: {
+ paymentRequest: string;
+ amount: Money;
+ accountName: string;
+ errorMessage: string | undefined;
+ fee?: Money;
+}) {
+ const buildLinkWithSearchParams = useBuildLinkWithSearchParams();
+ const { isMobile } = useUserAgent();
+ const deepLinkUrl = buildCashAppDeepLink(paymentRequest);
+
+ return (
+ <>
+
+
+ Buy
+
+
+
+ {isMobile ? (
+
+ ) : (
+
+ )}
+
+ {isMobile && !errorMessage && (
+
+
+
+ )}
+ >
+ );
+}
+
+export function BuyCheckoutCashu({
+ quote,
+ amount,
+ accountName,
+}: {
+ quote: BuyQuote;
+ amount: Money;
+ accountName: string;
+}) {
+ const navigateToTransaction = useNavigateToTransaction();
+
+ const { status: quotePaymentStatus } = useCashuReceiveQuote({
+ quoteId: quote.id,
+ onPaid: (cashuQuote) => {
+ navigateToTransaction(cashuQuote.transactionId);
+ },
+ });
+
+ return (
+
+ );
+}
+
+export function BuyCheckoutSpark({
+ quote,
+ amount,
+ accountName,
+}: {
+ quote: BuyQuote;
+ amount: Money;
+ accountName: string;
+}) {
+ const navigateToTransaction = useNavigateToTransaction();
+
+ const { status: quotePaymentStatus } = useSparkReceiveQuote({
+ quoteId: quote.id,
+ onPaid: (sparkQuote) => {
+ navigateToTransaction(sparkQuote.transactionId);
+ },
+ });
+
+ return (
+
+ );
+}
diff --git a/app/features/buy/buy-faq-drawer.tsx b/app/features/buy/buy-faq-drawer.tsx
new file mode 100644
index 00000000..a896a355
--- /dev/null
+++ b/app/features/buy/buy-faq-drawer.tsx
@@ -0,0 +1,71 @@
+import { Info } from 'lucide-react';
+import {
+ Drawer,
+ DrawerContent,
+ DrawerHeader,
+ DrawerTitle,
+ DrawerTrigger,
+} from '~/components/ui/drawer';
+
+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.',
+ },
+];
+
+export function BuyFaqDrawer() {
+ return (
+
+
+
+
+
+
+
+ Frequently Asked Questions
+
+
+
+ {faqItems.map((item) => (
+
+
{item.question}
+
+ {item.answer}
+
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/app/features/buy/buy-input.tsx b/app/features/buy/buy-input.tsx
new file mode 100644
index 00000000..fe7fb36c
--- /dev/null
+++ b/app/features/buy/buy-input.tsx
@@ -0,0 +1,179 @@
+import { MoneyInputDisplay } from '~/components/money-display';
+import { Numpad } from '~/components/numpad';
+import {
+ ClosePageButton,
+ PageContent,
+ PageFooter,
+ PageHeader,
+ PageHeaderItem,
+ PageHeaderTitle,
+} from '~/components/page';
+import { Button } from '~/components/ui/button';
+import {
+ AccountSelector,
+ toAccountSelectorOption,
+} from '~/features/accounts/account-selector';
+import { accountOfflineToast } from '~/features/accounts/utils';
+import { ConvertedMoneySwitcher } from '~/features/shared/converted-money-switcher';
+import { getDefaultUnit } from '~/features/shared/currencies';
+import { DomainError } from '~/features/shared/error';
+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 type { Money } from '~/lib/money';
+import { useNavigateWithViewTransition } from '~/lib/transitions';
+import { useAccount, useAccounts } from '../accounts/account-hooks';
+import { BuyFaqDrawer } from './buy-faq-drawer';
+import { useBuyStore } from './buy-provider';
+import { CashAppLogo } from './cash-app';
+
+export default function BuyInput() {
+ const navigate = useNavigateWithViewTransition();
+ const buildLinkWithSearchParams = useBuildLinkWithSearchParams();
+ const { toast } = useToast();
+ const { redirectTo } = useRedirectTo('/');
+ const { animationClass: shakeAnimationClass, start: startShakeAnimation } =
+ useAnimation({ name: 'shake' });
+
+ const buyAccountId = useBuyStore((s) => s.accountId);
+ const buyAccount = useAccount(buyAccountId);
+ const buyAmount = useBuyStore((s) => s.amount);
+ const buyCurrencyUnit = getDefaultUnit(buyAccount.currency);
+ const setBuyAccount = useBuyStore((s) => s.setAccount);
+ const getBuyQuote = useBuyStore((s) => s.getBuyQuote);
+ const status = useBuyStore((s) => s.status);
+ const { data: accounts } = useAccounts();
+
+ const {
+ rawInputValue,
+ maxInputDecimals,
+ inputValue,
+ convertedValue,
+ exchangeRateError,
+ handleNumberInput,
+ switchInputCurrency,
+ } = useMoneyInput({
+ 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 (inputValue.currency === buyAccount.currency) {
+ amount = inputValue;
+ } else {
+ if (!convertedValue) {
+ return;
+ }
+ amount = convertedValue;
+ }
+
+ const result = await getBuyQuote(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;
+ }
+
+ navigate(buildLinkWithSearchParams('/buy/checkout'), {
+ transition: 'slideLeft',
+ applyTo: 'newView',
+ });
+ };
+
+ return (
+ <>
+
+
+ Buy
+
+
+
+
+
+
+
+
+
+
+
+ {!exchangeRateError && (
+
+ )}
+
+
+
+
+ toAccountSelectorOption(account),
+ )}
+ selectedAccount={toAccountSelectorOption(buyAccount)}
+ onSelect={(account) => {
+ setBuyAccount(account);
+ if (account.currency !== inputValue.currency) {
+ switchInputCurrency();
+ }
+ }}
+ disabled={accounts.length === 1}
+ />
+
+
+
+
+
+
+ Pay with
+
+
+
+
+
+
+
+
+
+
+
+ 0}
+ onButtonClick={(value) => {
+ handleNumberInput(value, startShakeAnimation);
+ }}
+ />
+
+ >
+ );
+}
diff --git a/app/features/buy/buy-provider.tsx b/app/features/buy/buy-provider.tsx
new file mode 100644
index 00000000..b19520db
--- /dev/null
+++ b/app/features/buy/buy-provider.tsx
@@ -0,0 +1,44 @@
+import {
+ type PropsWithChildren,
+ createContext,
+ useContext,
+ useState,
+} from 'react';
+import { useStore } from 'zustand';
+import type { Account } from '../accounts/account';
+import { useGetAccount } from '../accounts/account-hooks';
+import { useCreateCashuReceiveQuote } from '../receive/cashu-receive-quote-hooks';
+import { useCreateSparkReceiveQuote } from '../receive/spark-receive-quote-hooks';
+import type { BuyState, BuyStore } from './buy-store';
+import { createBuyStore } from './buy-store';
+
+const BuyContext = createContext(null);
+
+type Props = PropsWithChildren<{
+ initialAccount: Account;
+}>;
+
+export const BuyProvider = ({ children, initialAccount }: Props) => {
+ const getAccount = useGetAccount();
+ const { mutateAsync: createCashuReceiveQuote } = useCreateCashuReceiveQuote();
+ const { mutateAsync: createSparkReceiveQuote } = useCreateSparkReceiveQuote();
+
+ const [store] = useState(() =>
+ createBuyStore({
+ initialAccount,
+ getAccount,
+ createCashuReceiveQuote,
+ createSparkReceiveQuote,
+ }),
+ );
+
+ return {children};
+};
+
+export const useBuyStore = (selector: (state: BuyState) => T): T => {
+ const store = useContext(BuyContext);
+ if (!store) {
+ throw new Error('Missing BuyProvider in the tree');
+ }
+ return useStore(store, selector);
+};
diff --git a/app/features/buy/buy-store.ts b/app/features/buy/buy-store.ts
new file mode 100644
index 00000000..4a9ed120
--- /dev/null
+++ b/app/features/buy/buy-store.ts
@@ -0,0 +1,105 @@
+import { create } from 'zustand';
+import type { Currency, Money } from '~/lib/money';
+import type { Account, CashuAccount, SparkAccount } from '../accounts/account';
+import type { CashuReceiveQuote } from '../receive/cashu-receive-quote';
+import type { SparkReceiveQuote } from '../receive/spark-receive-quote';
+
+export type BuyQuote = {
+ id: string;
+ paymentRequest: string;
+ transactionId: string;
+ mintingFee?: Money;
+};
+
+type GetBuyQuoteResult =
+ | { success: true; quote: BuyQuote }
+ | { success: false; error: unknown };
+
+export type BuyState = {
+ status: 'idle' | 'quoting' | 'success';
+ /** The ID of the account to buy into */
+ accountId: string;
+ /** The amount to buy */
+ amount: Money | null;
+ /** The buy quote (Lightning invoice) */
+ quote: BuyQuote | null;
+ /** Set the account to buy into */
+ setAccount: (account: Account) => void;
+ /** Set the amount to buy */
+ setAmount: (amount: Money) => void;
+ /** Create a receive quote for the given amount */
+ getBuyQuote: (amount: Money) => Promise;
+};
+
+type CreateBuyStoreProps = {
+ initialAccount: Account;
+ getAccount: (id: string) => Account;
+ createCashuReceiveQuote: (params: {
+ account: CashuAccount;
+ amount: Money;
+ description?: string;
+ }) => Promise;
+ createSparkReceiveQuote: (params: {
+ account: SparkAccount;
+ amount: Money;
+ description?: string;
+ }) => Promise;
+};
+
+export const createBuyStore = ({
+ initialAccount,
+ getAccount,
+ createCashuReceiveQuote,
+ createSparkReceiveQuote,
+}: CreateBuyStoreProps) => {
+ return create((set, get) => ({
+ status: 'idle',
+ accountId: initialAccount.id,
+ amount: null,
+ quote: null,
+ setAccount: (account) =>
+ set({ accountId: account.id, amount: null, quote: null }),
+ setAmount: (amount) => set({ amount }),
+ getBuyQuote: async (amount) => {
+ const account = getAccount(get().accountId);
+ set({ status: 'quoting', amount });
+
+ try {
+ let quote: BuyQuote;
+
+ if (account.type === 'cashu') {
+ const cashuQuote = await createCashuReceiveQuote({
+ account,
+ amount,
+ description: 'Pay to Agicash',
+ });
+ quote = {
+ id: cashuQuote.id,
+ paymentRequest: cashuQuote.paymentRequest,
+ transactionId: cashuQuote.transactionId,
+ mintingFee: cashuQuote.mintingFee,
+ };
+ } else {
+ const sparkQuote = await createSparkReceiveQuote({
+ account,
+ amount,
+ description: 'Pay to Agicash',
+ });
+ quote = {
+ id: sparkQuote.id,
+ paymentRequest: sparkQuote.paymentRequest,
+ transactionId: sparkQuote.transactionId,
+ };
+ }
+
+ set({ status: 'success', quote });
+ return { success: true, quote };
+ } catch (error) {
+ set({ status: 'idle' });
+ return { success: false, error };
+ }
+ },
+ }));
+};
+
+export type BuyStore = ReturnType;
diff --git a/app/features/buy/cash-app.tsx b/app/features/buy/cash-app.tsx
new file mode 100644
index 00000000..74686835
--- /dev/null
+++ b/app/features/buy/cash-app.tsx
@@ -0,0 +1,17 @@
+export function buildCashAppDeepLink(paymentRequest: string) {
+ return `https://cash.app/launch/lightning/${paymentRequest}`;
+}
+
+export const CASH_APP_LOGO_URL =
+ 'https://static.afterpaycdn.com/en-US/integration/logo/lockup/cashapp-color-white-32.svg';
+
+export function CashAppLogo({ className }: { className?: string }) {
+ return (
+
+ );
+}
diff --git a/app/features/buy/index.ts b/app/features/buy/index.ts
new file mode 100644
index 00000000..2dd668fd
--- /dev/null
+++ b/app/features/buy/index.ts
@@ -0,0 +1,3 @@
+export { BuyCheckoutCashu, BuyCheckoutSpark } from './buy-checkout';
+export { default as BuyInput } from './buy-input';
+export { BuyProvider } from './buy-provider';
diff --git a/app/features/receive/receive-input.tsx b/app/features/receive/receive-input.tsx
index c9e24723..9bbf13bd 100644
--- a/app/features/receive/receive-input.tsx
+++ b/app/features/receive/receive-input.tsx
@@ -1,6 +1,6 @@
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 { MoneyInputDisplay } from '~/components/money-display';
import { Numpad } from '~/components/numpad';
import {
ClosePageButton,
@@ -10,12 +10,12 @@ 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 { ConvertedMoneySwitcher } from '~/features/shared/converted-money-switcher';
import { getDefaultUnit } from '~/features/shared/currencies';
import useAnimation from '~/hooks/use-animation';
import { useMoneyInput } from '~/hooks/use-money-input';
@@ -23,7 +23,6 @@ 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 +31,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();
@@ -172,7 +141,7 @@ export default function ReceiveInput() {
{!exchangeRateError && (
)}
diff --git a/app/features/receive/spark-receive-quote-hooks.ts b/app/features/receive/spark-receive-quote-hooks.ts
index f4d56a9a..3fc9243d 100644
--- a/app/features/receive/spark-receive-quote-hooks.ts
+++ b/app/features/receive/spark-receive-quote-hooks.ts
@@ -268,6 +268,10 @@ type CreateProps = {
* If not provided, the invoice will be created for the user that owns the Spark wallet.
*/
receiverIdentityPubkey?: string;
+ /**
+ * Description to include in the Lightning invoice memo.
+ */
+ description?: string;
};
/**
@@ -287,11 +291,13 @@ export function useCreateSparkReceiveQuote() {
account,
amount,
receiverIdentityPubkey,
+ description,
}: CreateProps) => {
const lightningQuote = await getLightningQuote({
wallet: account.wallet,
amount,
receiverIdentityPubkey,
+ description,
});
return sparkReceiveQuoteService.createReceiveQuote({
diff --git a/app/features/send/send-input.tsx b/app/features/send/send-input.tsx
index 3fdb2cac..cea77d45 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,7 +7,7 @@ import {
ZapIcon,
} from 'lucide-react';
import { useState } from 'react';
-import { MoneyDisplay, MoneyInputDisplay } from '~/components/money-display';
+import { MoneyInputDisplay } from '~/components/money-display';
import { Numpad } from '~/components/numpad';
import {
ClosePageButton,
@@ -20,19 +19,19 @@ import {
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 { ConvertedMoneySwitcher } from '~/features/shared/converted-money-switcher';
import useAnimation from '~/hooks/use-animation';
import { useMoneyInput } from '~/hooks/use-money-input';
import { useRedirectTo } from '~/hooks/use-redirect-to';
@@ -51,38 +50,6 @@ 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();
@@ -228,7 +195,7 @@ export function SendInput() {
{!exchangeRateError && (
)}
@@ -286,7 +253,7 @@ export function SendInput() {
diff --git a/app/features/send/send-store.ts b/app/features/send/send-store.ts
index ef9e2dba..d38af242 100644
--- a/app/features/send/send-store.ts
+++ b/app/features/send/send-store.ts
@@ -111,7 +111,7 @@ type DecodedDestination = {
};
type State = {
- status: 'idle' | 'quoting';
+ status: 'idle' | 'quoting' | 'success';
/**
* Amount to send.
*/
@@ -481,7 +481,7 @@ export const createSendStore = ({
}
}
- set({ status: 'idle' });
+ set({ status: 'success' });
return { success: true, next: 'confirmQuote' };
},
};
diff --git a/app/features/shared/converted-money-switcher.tsx b/app/features/shared/converted-money-switcher.tsx
new file mode 100644
index 00000000..543725c8
--- /dev/null
+++ b/app/features/shared/converted-money-switcher.tsx
@@ -0,0 +1,35 @@
+import { ArrowUpDown } from 'lucide-react';
+import { MoneyDisplay } from '~/components/money-display';
+import { Skeleton } from '~/components/ui/skeleton';
+import type { Money } from '~/lib/money';
+import { getDefaultUnit } from './currencies';
+
+type ConvertedMoneySwitcherProps = {
+ onSwitch: () => void;
+ money?: Money;
+};
+
+export const ConvertedMoneySwitcher = ({
+ onSwitch,
+ money,
+}: ConvertedMoneySwitcherProps) => {
+ if (!money) {
+ return ;
+ }
+
+ return (
+
+ );
+};
diff --git a/app/routes/_protected._index.tsx b/app/routes/_protected._index.tsx
index 41af69e2..4f1a5a0c 100644
--- a/app/routes/_protected._index.tsx
+++ b/app/routes/_protected._index.tsx
@@ -84,14 +84,27 @@ 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..9dde804e
--- /dev/null
+++ b/app/routes/_protected.buy.checkout.tsx
@@ -0,0 +1,34 @@
+import { Page } from '~/components/page';
+import { Redirect } from '~/components/redirect';
+import { useAccount } from '~/features/accounts/account-hooks';
+import { BuyCheckoutCashu, BuyCheckoutSpark } from '~/features/buy';
+import { useBuyStore } from '~/features/buy/buy-provider';
+
+export default function BuyCheckoutPage() {
+ const buyAmount = useBuyStore((s) => s.amount);
+ const buyAccountId = useBuyStore((s) => s.accountId);
+ const quote = useBuyStore((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..ba18e17f
--- /dev/null
+++ b/app/routes/_protected.buy.tsx
@@ -0,0 +1,21 @@
+import type { LinksFunction } from 'react-router';
+import { Outlet, useSearchParams } from 'react-router';
+import { useAccountOrDefault } from '~/features/accounts/account-hooks';
+import { BuyProvider } from '~/features/buy';
+import { CASH_APP_LOGO_URL } from '~/features/buy/cash-app';
+
+export const links: LinksFunction = () => [
+ { rel: 'prefetch', href: CASH_APP_LOGO_URL, as: 'image' },
+];
+
+export default function BuyLayout() {
+ const [searchParams] = useSearchParams();
+ const accountId = searchParams.get('accountId');
+ const initialAccount = useAccountOrDefault(accountId);
+
+ return (
+
+
+
+ );
+}