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
389 changes: 317 additions & 72 deletions package-lock.json

Large diffs are not rendered by default.

36 changes: 36 additions & 0 deletions src/__tests__/amount-selector.test.tsx
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();
});
});
57 changes: 57 additions & 0 deletions src/__tests__/confirmation-tracker.test.tsx
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();
});
});
59 changes: 59 additions & 0 deletions src/__tests__/crypto-checkout.test.tsx
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();
});
});
});
43 changes: 43 additions & 0 deletions src/__tests__/deposit-view.test.tsx
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 +19 to +28
Copy link

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 &larr; Back button triggers the onBack callback. This will cover all user-visible states and navigation behavior for the deposit step.

Suggested implementation:

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();
  });

  it("shows partial status", () => {
    render(<DepositView checkout={CHECKOUT} status="partial" onBack={vi.fn()} />);
    expect(screen.getByText(/payment partially received/i)).toBeInTheDocument();
  });

  it("shows expired status", () => {
    render(<DepositView checkout={CHECKOUT} status="expired" onBack={vi.fn()} />);
    expect(screen.getByText(/payment request expired/i)).toBeInTheDocument();
  });

  it("shows failed status", () => {
    render(<DepositView checkout={CHECKOUT} status="failed" onBack={vi.fn()} />);
    expect(screen.getByText(/payment failed/i)).toBeInTheDocument();
  });

  it("calls onBack when back button is clicked", () => {
    const handleBack = vi.fn();
    render(<DepositView checkout={CHECKOUT} status="waiting" onBack={handleBack} />);

    const backButton = screen.getByRole("button", { name: /\s*back/i });
    fireEvent.click(backButton);

    expect(handleBack).toHaveBeenCalledTimes(1);
  });

The new tests assume:

  1. The UI strings are:
    • "payment partially received" for status="partial",
    • "payment request expired" for status="expired",
    • "payment failed" for status="failed".
      If DepositView uses different copy, adjust the regexes in getByText to match the actual text.
  2. The back button is rendered as a <button> with accessible name containing "← Back". If the arrow or casing differ, tweak the name matcher (e.g. /back/i), or use getByText/getByRole with the correct label.
  3. If you expose status-specific indicator styles via test IDs or class names (e.g. data-testid="status-indicator"), you can extend each status test to assert the correct indicator styling (e.g. expect(indicator).toHaveClass("status-partial")).

Comment on lines +26 to +28
Copy link

Choose a reason for hiding this comment

The 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 DepositView keeps a timeLeft countdown while status === "waiting", please add a fake-timer test that:

  • Advances timers and asserts the MM:SS display updates correctly.
  • Asserts that with status !== "waiting", the timer does not keep decrementing.
    This will help catch regressions in countdown logic and interval cleanup.

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:

  1. Ensure DepositView renders the countdown with data-testid="countdown-timer" (e.g. <span data-testid="countdown-timer">{mm}:{ss}</span>).
  2. Confirm that the countdown is driven by setInterval / setTimeout so that vi.useFakeTimers() can control it.
  3. Verify that DepositView stops decrementing timeLeft when status !== "waiting" (e.g. by clearing the interval in an effect cleanup or when status changes). If the status used for "done" is not "completed", adjust the test’s status prop accordingly.

});

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);
});
});
88 changes: 88 additions & 0 deletions src/__tests__/payment-method-picker.test.tsx
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} 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} 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} 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} onSelect={onSelect} />);
await userEvent.click(screen.getByText("Bitcoin"));
expect(onSelect).toHaveBeenCalledWith(METHODS[0]);
Comment on lines +82 to +86
Copy link

Choose a reason for hiding this comment

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

suggestion (testing): Consider testing the optional onBack behavior when provided

A focused test that renders PaymentMethodPicker with onBack={vi.fn()}, clicks the ← Back button, and asserts the callback is called would help ensure the back navigation remains wired correctly in the multi-step flow.

});
});
26 changes: 26 additions & 0 deletions src/app/admin/products/error.tsx
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>
);
}
Loading
Loading