-
Notifications
You must be signed in to change notification settings - Fork 0
feat: crypto checkout redesign — 4-step flow with search, QR, confirmation #62
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 6 commits
8477ad6
c0a7a27
d92f35e
cdedd60
1d8aaf3
27285fa
45567cf
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| import { render, screen } from "@testing-library/react"; | ||
| import userEvent from "@testing-library/user-event"; | ||
| import { describe, expect, it, vi } from "vitest"; | ||
| import { AmountSelector } from "@/components/billing/amount-selector"; | ||
|
|
||
| describe("AmountSelector", () => { | ||
| it("renders preset amounts", () => { | ||
| render(<AmountSelector onSelect={vi.fn()} />); | ||
| expect(screen.getByText("$10")).toBeInTheDocument(); | ||
| expect(screen.getByText("$25")).toBeInTheDocument(); | ||
| expect(screen.getByText("$50")).toBeInTheDocument(); | ||
| expect(screen.getByText("$100")).toBeInTheDocument(); | ||
| }); | ||
|
|
||
| it("calls onSelect with chosen amount", async () => { | ||
| const onSelect = vi.fn(); | ||
| render(<AmountSelector onSelect={onSelect} />); | ||
| await userEvent.click(screen.getByText("$25")); | ||
| await userEvent.click(screen.getByRole("button", { name: /continue/i })); | ||
| expect(onSelect).toHaveBeenCalledWith(25); | ||
| }); | ||
|
|
||
| it("supports custom amount input", async () => { | ||
| const onSelect = vi.fn(); | ||
| render(<AmountSelector onSelect={onSelect} />); | ||
| const input = screen.getByPlaceholderText(/custom/i); | ||
| await userEvent.type(input, "75"); | ||
| await userEvent.click(screen.getByRole("button", { name: /continue/i })); | ||
| expect(onSelect).toHaveBeenCalledWith(75); | ||
| }); | ||
|
|
||
| it("disables continue when no amount selected", () => { | ||
| render(<AmountSelector onSelect={vi.fn()} />); | ||
| expect(screen.getByRole("button", { name: /continue/i })).toBeDisabled(); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,57 @@ | ||
| import { render, screen } from "@testing-library/react"; | ||
| import { describe, expect, it } from "vitest"; | ||
| import { ConfirmationTracker } from "@/components/billing/confirmation-tracker"; | ||
|
|
||
| describe("ConfirmationTracker", () => { | ||
| it("shows confirmation progress", () => { | ||
| render( | ||
| <ConfirmationTracker | ||
| confirmations={8} | ||
| confirmationsRequired={20} | ||
| displayAmount="25.00 USDT" | ||
| credited={false} | ||
| />, | ||
| ); | ||
| expect(screen.getByText(/8/)).toBeInTheDocument(); | ||
| expect(screen.getByText(/20/)).toBeInTheDocument(); | ||
| expect(screen.getByText(/25\.00 USDT/)).toBeInTheDocument(); | ||
| }); | ||
|
|
||
| it("shows credited state", () => { | ||
| render( | ||
| <ConfirmationTracker | ||
| confirmations={20} | ||
| confirmationsRequired={20} | ||
| displayAmount="25.00 USDT" | ||
| credited={true} | ||
| />, | ||
| ); | ||
| expect(screen.getByText(/credits applied/i)).toBeInTheDocument(); | ||
| }); | ||
|
|
||
| it("renders progress bar", () => { | ||
| render( | ||
| <ConfirmationTracker | ||
| confirmations={10} | ||
| confirmationsRequired={20} | ||
| displayAmount="25.00 USDT" | ||
| credited={false} | ||
| />, | ||
| ); | ||
| const bar = screen.getByRole("progressbar"); | ||
| expect(bar).toBeInTheDocument(); | ||
| }); | ||
|
|
||
| it("shows tx hash when provided", () => { | ||
| render( | ||
| <ConfirmationTracker | ||
| confirmations={5} | ||
| confirmationsRequired={20} | ||
| displayAmount="25.00 USDT" | ||
| credited={false} | ||
| txHash="0xabc123" | ||
| />, | ||
| ); | ||
| expect(screen.getByText(/0xabc123/)).toBeInTheDocument(); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| import { render, screen, waitFor } from "@testing-library/react"; | ||
| import userEvent from "@testing-library/user-event"; | ||
| import { describe, expect, it, vi } from "vitest"; | ||
|
|
||
| vi.mock("framer-motion", () => ({ | ||
| motion: { | ||
| div: ({ children, ...props }: Record<string, unknown>) => <div {...props}>{children}</div>, | ||
| }, | ||
| AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}</>, | ||
| })); | ||
|
|
||
| vi.mock("qrcode.react", () => ({ | ||
| QRCodeSVG: ({ value }: { value: string }) => <div data-testid="qr">{value}</div>, | ||
| })); | ||
|
|
||
| vi.mock("@/lib/api", () => ({ | ||
| getSupportedPaymentMethods: vi.fn().mockResolvedValue([ | ||
| { id: "BTC:mainnet", type: "native", token: "BTC", chain: "bitcoin", displayName: "Bitcoin", decimals: 8, displayOrder: 0, iconUrl: "" }, | ||
| { id: "USDT:tron", type: "erc20", token: "USDT", chain: "tron", displayName: "USDT on Tron", decimals: 6, displayOrder: 1, iconUrl: "" }, | ||
| ]), | ||
| createCheckout: vi.fn().mockResolvedValue({ | ||
| depositAddress: "THwbQb1sPiRUpUYunVQxQKc6i4LCmpP1mj", | ||
| displayAmount: "32.24 TRX", | ||
| amountUsd: 10, | ||
| token: "TRX", | ||
| chain: "tron", | ||
| referenceId: "trx:test", | ||
| }), | ||
| getChargeStatus: vi.fn().mockResolvedValue({ | ||
| status: "waiting", | ||
| amountExpectedCents: 1000, | ||
| amountReceivedCents: 0, | ||
| confirmations: 0, | ||
| confirmationsRequired: 20, | ||
| credited: false, | ||
| }), | ||
| apiFetch: vi.fn(), | ||
| })); | ||
|
|
||
| import { CryptoCheckout } from "@/components/billing/crypto-checkout"; | ||
|
|
||
| describe("CryptoCheckout", () => { | ||
| it("renders amount selector on mount", async () => { | ||
| render(<CryptoCheckout />); | ||
| await waitFor(() => { | ||
| expect(screen.getByText("$25")).toBeInTheDocument(); | ||
| }); | ||
| }); | ||
|
|
||
| it("advances to payment picker after selecting amount", async () => { | ||
| render(<CryptoCheckout />); | ||
| await waitFor(() => screen.getByText("$25")); | ||
| await userEvent.click(screen.getByText("$25")); | ||
| await userEvent.click(screen.getByRole("button", { name: /continue/i })); | ||
| await waitFor(() => { | ||
| expect(screen.getByPlaceholderText(/search/i)).toBeInTheDocument(); | ||
| }); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| import { render, screen } from "@testing-library/react"; | ||
| import userEvent from "@testing-library/user-event"; | ||
| import { describe, expect, it, vi } from "vitest"; | ||
| import { DepositView } from "@/components/billing/deposit-view"; | ||
|
|
||
| vi.mock("qrcode.react", () => ({ | ||
| QRCodeSVG: ({ value }: { value: string }) => <div data-testid="qr" data-value={value} />, | ||
| })); | ||
|
|
||
| const CHECKOUT = { | ||
| depositAddress: "THwbQb1sPiRUpUYunVQxQKc6i4LCmpP1mj", | ||
| displayAmount: "32.24 TRX", | ||
| amountUsd: 10, | ||
| token: "TRX", | ||
| chain: "tron", | ||
| referenceId: "trx:test123", | ||
| }; | ||
|
|
||
| describe("DepositView", () => { | ||
| it("shows deposit address and amount", () => { | ||
| render(<DepositView checkout={CHECKOUT} status="waiting" onBack={vi.fn()} />); | ||
| expect(screen.getByText(/32\.24 TRX/)).toBeInTheDocument(); | ||
| expect(screen.getByText(/THwbQb1s/)).toBeInTheDocument(); | ||
| }); | ||
|
|
||
| it("shows waiting status", () => { | ||
| render(<DepositView checkout={CHECKOUT} status="waiting" onBack={vi.fn()} />); | ||
| expect(screen.getByText(/waiting for payment/i)).toBeInTheDocument(); | ||
|
Comment on lines
+26
to
+28
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. suggestion (testing): Consider testing the countdown timer behavior and its stopping conditions Because
Suggested implementation: it("shows deposit address and amount", () => {
render(<DepositView checkout={CHECKOUT} status="waiting" onBack={vi.fn()} />);
expect(screen.getByText(/32\.24 TRX/)).toBeInTheDocument();
expect(screen.getByText(/THwbQb1s/)).toBeInTheDocument();
});
it("updates countdown timer while waiting", () => {
vi.useFakeTimers();
render(<DepositView checkout={CHECKOUT} status="waiting" onBack={vi.fn()} />);
const getTimeLeft = () => screen.getByTestId("countdown-timer").textContent;
const initialTime = getTimeLeft();
vi.advanceTimersByTime(5_000);
expect(getTimeLeft()).not.toBe(initialTime);
vi.useRealTimers();
});
it("stops countdown timer when status is no longer waiting", () => {
vi.useFakeTimers();
const { rerender } = render(
<DepositView checkout={CHECKOUT} status="waiting" onBack={vi.fn()} />,
);
const getTimeLeft = () => screen.getByTestId("countdown-timer").textContent;
vi.advanceTimersByTime(5_000);
const timeAfterWaiting = getTimeLeft();
rerender(<DepositView checkout={CHECKOUT} status="completed" onBack={vi.fn()} />);
vi.advanceTimersByTime(5_000);
expect(getTimeLeft()).toBe(timeAfterWaiting);
vi.useRealTimers();
});
vi.mock("qrcode.react", () => ({To make these tests pass you may need to:
|
||
| }); | ||
|
|
||
| it("renders QR code", () => { | ||
| render(<DepositView checkout={CHECKOUT} status="waiting" onBack={vi.fn()} />); | ||
| expect(screen.getByTestId("qr")).toBeInTheDocument(); | ||
| }); | ||
|
|
||
| it("copies address to clipboard", async () => { | ||
| const writeText = vi.fn().mockResolvedValue(undefined); | ||
| Object.assign(navigator, { clipboard: { writeText } }); | ||
| render(<DepositView checkout={CHECKOUT} status="waiting" onBack={vi.fn()} />); | ||
| await userEvent.click(screen.getByRole("button", { name: /copy/i })); | ||
| expect(writeText).toHaveBeenCalledWith(CHECKOUT.depositAddress); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,88 @@ | ||
| import { render, screen } from "@testing-library/react"; | ||
| import userEvent from "@testing-library/user-event"; | ||
| import { describe, expect, it, vi } from "vitest"; | ||
| import { PaymentMethodPicker } from "@/components/billing/payment-method-picker"; | ||
| import type { SupportedPaymentMethod } from "@/lib/api"; | ||
|
|
||
| const METHODS: SupportedPaymentMethod[] = [ | ||
| { | ||
| id: "BTC:mainnet", | ||
| type: "native", | ||
| token: "BTC", | ||
| chain: "bitcoin", | ||
| displayName: "Bitcoin", | ||
| decimals: 8, | ||
| displayOrder: 0, | ||
| iconUrl: "", | ||
| }, | ||
| { | ||
| id: "USDT:tron", | ||
| type: "erc20", | ||
| token: "USDT", | ||
| chain: "tron", | ||
| displayName: "USDT on Tron", | ||
| decimals: 6, | ||
| displayOrder: 1, | ||
| iconUrl: "", | ||
| }, | ||
| { | ||
| id: "ETH:base", | ||
| type: "native", | ||
| token: "ETH", | ||
| chain: "base", | ||
| displayName: "ETH on Base", | ||
| decimals: 18, | ||
| displayOrder: 2, | ||
| iconUrl: "", | ||
| }, | ||
| { | ||
| id: "USDC:polygon", | ||
| type: "erc20", | ||
| token: "USDC", | ||
| chain: "polygon", | ||
| displayName: "USDC on Polygon", | ||
| decimals: 6, | ||
| displayOrder: 3, | ||
| iconUrl: "", | ||
| }, | ||
| { | ||
| id: "DOGE:dogecoin", | ||
| type: "native", | ||
| token: "DOGE", | ||
| chain: "dogecoin", | ||
| displayName: "Dogecoin", | ||
| decimals: 8, | ||
| displayOrder: 4, | ||
| iconUrl: "", | ||
| }, | ||
| ]; | ||
|
|
||
| describe("PaymentMethodPicker", () => { | ||
| it("renders all methods", () => { | ||
| render(<PaymentMethodPicker methods={METHODS} amountUsd={25} onSelect={vi.fn()} />); | ||
| expect(screen.getByText("Bitcoin")).toBeInTheDocument(); | ||
| expect(screen.getByText("USDT on Tron")).toBeInTheDocument(); | ||
| }); | ||
|
|
||
| it("filters by search text", async () => { | ||
| render(<PaymentMethodPicker methods={METHODS} amountUsd={25} onSelect={vi.fn()} />); | ||
| await userEvent.type(screen.getByPlaceholderText(/search/i), "tron"); | ||
| expect(screen.getByText("USDT on Tron")).toBeInTheDocument(); | ||
| expect(screen.queryByText("Bitcoin")).not.toBeInTheDocument(); | ||
| }); | ||
|
|
||
| it("filters by Stablecoins pill", async () => { | ||
| render(<PaymentMethodPicker methods={METHODS} amountUsd={25} onSelect={vi.fn()} />); | ||
| await userEvent.click(screen.getByText("Stablecoins")); | ||
| expect(screen.getByText("USDT on Tron")).toBeInTheDocument(); | ||
| expect(screen.getByText("USDC on Polygon")).toBeInTheDocument(); | ||
| expect(screen.queryByText("Bitcoin")).not.toBeInTheDocument(); | ||
| }); | ||
|
|
||
| it("calls onSelect when a method is clicked", async () => { | ||
| const onSelect = vi.fn(); | ||
| render(<PaymentMethodPicker methods={METHODS} amountUsd={25} onSelect={onSelect} />); | ||
| await userEvent.click(screen.getByText("Bitcoin")); | ||
| expect(onSelect).toHaveBeenCalledWith(METHODS[0]); | ||
|
Comment on lines
+82
to
+86
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. suggestion (testing): Consider testing the optional A focused test that renders |
||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| "use client"; | ||
|
|
||
| import { useEffect } from "react"; | ||
| import { Button } from "@/components/ui/button"; | ||
|
|
||
| export default function ProductsError({ | ||
| error, | ||
| reset, | ||
| }: { | ||
| error: Error & { digest?: string }; | ||
| reset: () => void; | ||
| }) { | ||
| useEffect(() => { | ||
| console.error("Admin products page error:", error); | ||
| }, [error]); | ||
|
|
||
| return ( | ||
| <div className="flex h-64 flex-col items-center justify-center gap-4 p-6"> | ||
| <p className="text-sm text-destructive">Failed to load product configuration.</p> | ||
| <p className="text-xs text-muted-foreground">{error.message}</p> | ||
| <Button variant="outline" size="sm" onClick={reset}> | ||
| Try Again | ||
| </Button> | ||
| </div> | ||
| ); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
suggestion (testing): Add coverage for non-waiting statuses and back navigation in DepositView
Current tests only cover the waiting/QR/copy flow. Please also add tests for
status="partial","expired", and"failed"to verify the expected text and indicator styles, and a test that the← Backbutton triggers theonBackcallback. This will cover all user-visible states and navigation behavior for the deposit step.Suggested implementation:
The new tests assume:
"payment partially received"forstatus="partial","payment request expired"forstatus="expired","payment failed"forstatus="failed".If
DepositViewuses different copy, adjust the regexes ingetByTextto match the actual text.<button>with accessible name containing"← Back". If the arrow or casing differ, tweak thenamematcher (e.g./back/i), or usegetByText/getByRolewith the correct label.data-testid="status-indicator"), you can extend each status test to assert the correct indicator styling (e.g.expect(indicator).toHaveClass("status-partial")).