Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/components/ui/button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
Expand Down
264 changes: 264 additions & 0 deletions app/features/buy/buy-checkout.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex items-center justify-between">
<p className="text-muted-foreground">{label}</p>
<div>{value}</div>
</div>
);
};

function ErrorCard({ message }: { message: string }) {
return (
<Card className="w-full">
<CardContent className="flex flex-col items-center justify-center gap-2 p-6">
<AlertCircle className="h-8 w-8 text-foreground" />
<p className="text-center text-muted-foreground text-sm">{message}</p>
</CardContent>
</Card>
);
}

function ConfirmationDetails({
accountName,
fee,
}: { accountName: string; fee?: Money }) {
return (
<Card className="w-full">
<CardContent className="flex flex-col gap-6 pt-6">
<ConfirmationRow label="From" value="Cash App" />
<ConfirmationRow label="To" value={accountName} />
{fee && (
<ConfirmationRow
label="Fee"
value={
<MoneyDisplay
size="sm"
money={fee}
unit={getDefaultUnit(fee.currency)}
/>
}
/>
)}
</CardContent>
</Card>
);
}

function MobileCheckoutContent({
errorMessage,
accountName,
fee,
}: {
errorMessage: string | undefined;
accountName: string;
fee?: Money;
}) {
return (
<div className="absolute top-0 right-0 bottom-0 left-0 mx-auto flex max-w-sm items-center justify-center">
<div className="m-4 w-full">
{errorMessage ? (
<ErrorCard message={errorMessage} />
) : (
<ConfirmationDetails accountName={accountName} fee={fee} />
)}
</div>
</div>
);
}

function DesktopCheckoutContent({
errorMessage,
paymentRequest,
accountName,
fee,
}: {
errorMessage: string | undefined;
paymentRequest: string;
accountName: string;
fee?: Money;
}) {
if (errorMessage) {
return (
<div className="max-w-sm">
<ErrorCard message={errorMessage} />
</div>
);
}

const deepLinkUrl = buildCashAppDeepLink(paymentRequest);

return (
<>
<QRCode
value={deepLinkUrl}
description="Scan with Cash App"
className="gap-4"
/>
<div className="max-w-sm">
<ConfirmationDetails accountName={accountName} fee={fee} />
</div>
</>
);
}

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 (
<>
<PageHeader className="z-10">
<PageBackButton
to={buildLinkWithSearchParams('/buy')}
transition="slideRight"
applyTo="oldView"
/>
<PageHeaderTitle>Buy</PageHeaderTitle>
</PageHeader>
<PageContent className="flex flex-col items-center gap-4">
<MoneyWithConvertedAmount money={amount} />
{isMobile ? (
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I find this hard to read with this very long chaining of:

isMobile ? (bunch of elements) : errorMessage ? (elements) : (elements)

I think it sould be reorganized so you can see at first glance what is rendered on mobile and what on desktop and what on error.

I'd probably just create a component int his file for mobile and for desktop and then render those here

<MobileCheckoutContent
errorMessage={errorMessage}
accountName={accountName}
fee={fee}
/>
) : (
<DesktopCheckoutContent
errorMessage={errorMessage}
paymentRequest={paymentRequest}
accountName={accountName}
fee={fee}
/>
)}
</PageContent>
{isMobile && !errorMessage && (
<PageFooter className="pb-14">
<Button asChild>
<a href={deepLinkUrl}>Pay</a>
</Button>
</PageFooter>
)}
</>
);
}

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 (
<CashAppCheckout
paymentRequest={quote.paymentRequest}
amount={amount}
accountName={accountName}
errorMessage={getErrorMessageFromQuoteStatus(quotePaymentStatus)}
fee={quote.mintingFee}
/>
);
}

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 (
<CashAppCheckout
paymentRequest={quote.paymentRequest}
amount={amount}
accountName={accountName}
errorMessage={getErrorMessageFromQuoteStatus(quotePaymentStatus)}
/>
);
}
71 changes: 71 additions & 0 deletions app/features/buy/buy-faq-drawer.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Drawer>
<DrawerTrigger asChild>
<button type="button">
<Info className="h-5 w-5 text-muted-foreground" />
</button>
</DrawerTrigger>
<DrawerContent className="h-[90svh] font-primary">
<div className="mx-auto flex h-full w-full max-w-sm flex-col overflow-hidden">
<DrawerHeader className="mb-4 shrink-0">
<DrawerTitle>Frequently Asked Questions</DrawerTitle>
</DrawerHeader>
<div className="min-h-0 flex-1 overflow-y-auto px-4 pb-8">
<div className="space-y-6">
{faqItems.map((item) => (
<div key={item.question}>
<h3 className="font-semibold text-sm">{item.question}</h3>
<p className="mt-1 text-muted-foreground text-sm">
{item.answer}
</p>
</div>
))}
</div>
</div>
</div>
</DrawerContent>
</Drawer>
);
}
Loading