@wopr-network/platform-ui-core 1.25.0 → 1.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/__tests__/amount-selector.test.tsx +36 -0
- package/src/__tests__/confirmation-tracker.test.tsx +57 -0
- package/src/__tests__/crypto-checkout.test.tsx +79 -0
- package/src/__tests__/deposit-view.test.tsx +43 -0
- package/src/__tests__/payment-method-picker.test.tsx +88 -0
- package/src/app/(dashboard)/billing/credits/page.tsx +2 -2
- package/src/components/billing/amount-selector.tsx +66 -0
- package/src/components/billing/buy-crypto-credits-panel.tsx +1 -302
- package/src/components/billing/confirmation-tracker.tsx +104 -0
- package/src/components/billing/crypto-checkout.tsx +183 -0
- package/src/components/billing/deposit-view.tsx +82 -0
- package/src/components/billing/payment-method-picker.tsx +133 -0
package/package.json
CHANGED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import userEvent from "@testing-library/user-event";
|
|
3
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { AmountSelector } from "@/components/billing/amount-selector";
|
|
5
|
+
|
|
6
|
+
describe("AmountSelector", () => {
|
|
7
|
+
it("renders preset amounts", () => {
|
|
8
|
+
render(<AmountSelector onSelect={vi.fn()} />);
|
|
9
|
+
expect(screen.getByText("$10")).toBeInTheDocument();
|
|
10
|
+
expect(screen.getByText("$25")).toBeInTheDocument();
|
|
11
|
+
expect(screen.getByText("$50")).toBeInTheDocument();
|
|
12
|
+
expect(screen.getByText("$100")).toBeInTheDocument();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("calls onSelect with chosen amount", async () => {
|
|
16
|
+
const onSelect = vi.fn();
|
|
17
|
+
render(<AmountSelector onSelect={onSelect} />);
|
|
18
|
+
await userEvent.click(screen.getByText("$25"));
|
|
19
|
+
await userEvent.click(screen.getByRole("button", { name: /continue/i }));
|
|
20
|
+
expect(onSelect).toHaveBeenCalledWith(25);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("supports custom amount input", async () => {
|
|
24
|
+
const onSelect = vi.fn();
|
|
25
|
+
render(<AmountSelector onSelect={onSelect} />);
|
|
26
|
+
const input = screen.getByPlaceholderText(/custom/i);
|
|
27
|
+
await userEvent.type(input, "75");
|
|
28
|
+
await userEvent.click(screen.getByRole("button", { name: /continue/i }));
|
|
29
|
+
expect(onSelect).toHaveBeenCalledWith(75);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("disables continue when no amount selected", () => {
|
|
33
|
+
render(<AmountSelector onSelect={vi.fn()} />);
|
|
34
|
+
expect(screen.getByRole("button", { name: /continue/i })).toBeDisabled();
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { ConfirmationTracker } from "@/components/billing/confirmation-tracker";
|
|
4
|
+
|
|
5
|
+
describe("ConfirmationTracker", () => {
|
|
6
|
+
it("shows confirmation progress", () => {
|
|
7
|
+
render(
|
|
8
|
+
<ConfirmationTracker
|
|
9
|
+
confirmations={8}
|
|
10
|
+
confirmationsRequired={20}
|
|
11
|
+
displayAmount="25.00 USDT"
|
|
12
|
+
credited={false}
|
|
13
|
+
/>,
|
|
14
|
+
);
|
|
15
|
+
expect(screen.getByText(/8/)).toBeInTheDocument();
|
|
16
|
+
expect(screen.getByText(/20/)).toBeInTheDocument();
|
|
17
|
+
expect(screen.getByText(/25\.00 USDT/)).toBeInTheDocument();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("shows credited state", () => {
|
|
21
|
+
render(
|
|
22
|
+
<ConfirmationTracker
|
|
23
|
+
confirmations={20}
|
|
24
|
+
confirmationsRequired={20}
|
|
25
|
+
displayAmount="25.00 USDT"
|
|
26
|
+
credited={true}
|
|
27
|
+
/>,
|
|
28
|
+
);
|
|
29
|
+
expect(screen.getByText(/credits applied/i)).toBeInTheDocument();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("renders progress bar", () => {
|
|
33
|
+
render(
|
|
34
|
+
<ConfirmationTracker
|
|
35
|
+
confirmations={10}
|
|
36
|
+
confirmationsRequired={20}
|
|
37
|
+
displayAmount="25.00 USDT"
|
|
38
|
+
credited={false}
|
|
39
|
+
/>,
|
|
40
|
+
);
|
|
41
|
+
const bar = screen.getByRole("progressbar");
|
|
42
|
+
expect(bar).toBeInTheDocument();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("shows tx hash when provided", () => {
|
|
46
|
+
render(
|
|
47
|
+
<ConfirmationTracker
|
|
48
|
+
confirmations={5}
|
|
49
|
+
confirmationsRequired={20}
|
|
50
|
+
displayAmount="25.00 USDT"
|
|
51
|
+
credited={false}
|
|
52
|
+
txHash="0xabc123"
|
|
53
|
+
/>,
|
|
54
|
+
);
|
|
55
|
+
expect(screen.getByText(/0xabc123/)).toBeInTheDocument();
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { render, screen, waitFor } from "@testing-library/react";
|
|
2
|
+
import userEvent from "@testing-library/user-event";
|
|
3
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
|
|
5
|
+
vi.mock("framer-motion", () => ({
|
|
6
|
+
motion: {
|
|
7
|
+
div: ({ children, ...props }: { children?: React.ReactNode; [key: string]: unknown }) => (
|
|
8
|
+
<div {...props}>{children}</div>
|
|
9
|
+
),
|
|
10
|
+
},
|
|
11
|
+
AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
vi.mock("qrcode.react", () => ({
|
|
15
|
+
QRCodeSVG: ({ value }: { value: string }) => <div data-testid="qr">{value}</div>,
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
vi.mock("@/lib/api", () => ({
|
|
19
|
+
getSupportedPaymentMethods: vi.fn().mockResolvedValue([
|
|
20
|
+
{
|
|
21
|
+
id: "BTC:mainnet",
|
|
22
|
+
type: "native",
|
|
23
|
+
token: "BTC",
|
|
24
|
+
chain: "bitcoin",
|
|
25
|
+
displayName: "Bitcoin",
|
|
26
|
+
decimals: 8,
|
|
27
|
+
displayOrder: 0,
|
|
28
|
+
iconUrl: "",
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
id: "USDT:tron",
|
|
32
|
+
type: "erc20",
|
|
33
|
+
token: "USDT",
|
|
34
|
+
chain: "tron",
|
|
35
|
+
displayName: "USDT on Tron",
|
|
36
|
+
decimals: 6,
|
|
37
|
+
displayOrder: 1,
|
|
38
|
+
iconUrl: "",
|
|
39
|
+
},
|
|
40
|
+
]),
|
|
41
|
+
createCheckout: vi.fn().mockResolvedValue({
|
|
42
|
+
depositAddress: "THwbQb1sPiRUpUYunVQxQKc6i4LCmpP1mj",
|
|
43
|
+
displayAmount: "32.24 TRX",
|
|
44
|
+
amountUsd: 10,
|
|
45
|
+
token: "TRX",
|
|
46
|
+
chain: "tron",
|
|
47
|
+
referenceId: "trx:test",
|
|
48
|
+
}),
|
|
49
|
+
getChargeStatus: vi.fn().mockResolvedValue({
|
|
50
|
+
status: "waiting",
|
|
51
|
+
amountExpectedCents: 1000,
|
|
52
|
+
amountReceivedCents: 0,
|
|
53
|
+
confirmations: 0,
|
|
54
|
+
confirmationsRequired: 20,
|
|
55
|
+
credited: false,
|
|
56
|
+
}),
|
|
57
|
+
apiFetch: vi.fn(),
|
|
58
|
+
}));
|
|
59
|
+
|
|
60
|
+
import { CryptoCheckout } from "@/components/billing/crypto-checkout";
|
|
61
|
+
|
|
62
|
+
describe("CryptoCheckout", () => {
|
|
63
|
+
it("renders amount selector on mount", async () => {
|
|
64
|
+
render(<CryptoCheckout />);
|
|
65
|
+
await waitFor(() => {
|
|
66
|
+
expect(screen.getByText("$25")).toBeInTheDocument();
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("advances to payment picker after selecting amount", async () => {
|
|
71
|
+
render(<CryptoCheckout />);
|
|
72
|
+
await waitFor(() => screen.getByText("$25"));
|
|
73
|
+
await userEvent.click(screen.getByText("$25"));
|
|
74
|
+
await userEvent.click(screen.getByRole("button", { name: /continue/i }));
|
|
75
|
+
await waitFor(() => {
|
|
76
|
+
expect(screen.getByPlaceholderText(/search/i)).toBeInTheDocument();
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import userEvent from "@testing-library/user-event";
|
|
3
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { DepositView } from "@/components/billing/deposit-view";
|
|
5
|
+
|
|
6
|
+
vi.mock("qrcode.react", () => ({
|
|
7
|
+
QRCodeSVG: ({ value }: { value: string }) => <div data-testid="qr" data-value={value} />,
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
const CHECKOUT = {
|
|
11
|
+
depositAddress: "THwbQb1sPiRUpUYunVQxQKc6i4LCmpP1mj",
|
|
12
|
+
displayAmount: "32.24 TRX",
|
|
13
|
+
amountUsd: 10,
|
|
14
|
+
token: "TRX",
|
|
15
|
+
chain: "tron",
|
|
16
|
+
referenceId: "trx:test123",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
describe("DepositView", () => {
|
|
20
|
+
it("shows deposit address and amount", () => {
|
|
21
|
+
render(<DepositView checkout={CHECKOUT} status="waiting" onBack={vi.fn()} />);
|
|
22
|
+
expect(screen.getByText(/32\.24 TRX/)).toBeInTheDocument();
|
|
23
|
+
expect(screen.getByText(/THwbQb1s/)).toBeInTheDocument();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("shows waiting status", () => {
|
|
27
|
+
render(<DepositView checkout={CHECKOUT} status="waiting" onBack={vi.fn()} />);
|
|
28
|
+
expect(screen.getByText(/waiting for payment/i)).toBeInTheDocument();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("renders QR code", () => {
|
|
32
|
+
render(<DepositView checkout={CHECKOUT} status="waiting" onBack={vi.fn()} />);
|
|
33
|
+
expect(screen.getByTestId("qr")).toBeInTheDocument();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("copies address to clipboard", async () => {
|
|
37
|
+
const writeText = vi.fn().mockResolvedValue(undefined);
|
|
38
|
+
Object.assign(navigator, { clipboard: { writeText } });
|
|
39
|
+
render(<DepositView checkout={CHECKOUT} status="waiting" onBack={vi.fn()} />);
|
|
40
|
+
await userEvent.click(screen.getByRole("button", { name: /copy/i }));
|
|
41
|
+
expect(writeText).toHaveBeenCalledWith(CHECKOUT.depositAddress);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import userEvent from "@testing-library/user-event";
|
|
3
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { PaymentMethodPicker } from "@/components/billing/payment-method-picker";
|
|
5
|
+
import type { SupportedPaymentMethod } from "@/lib/api";
|
|
6
|
+
|
|
7
|
+
const METHODS: SupportedPaymentMethod[] = [
|
|
8
|
+
{
|
|
9
|
+
id: "BTC:mainnet",
|
|
10
|
+
type: "native",
|
|
11
|
+
token: "BTC",
|
|
12
|
+
chain: "bitcoin",
|
|
13
|
+
displayName: "Bitcoin",
|
|
14
|
+
decimals: 8,
|
|
15
|
+
displayOrder: 0,
|
|
16
|
+
iconUrl: "",
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
id: "USDT:tron",
|
|
20
|
+
type: "erc20",
|
|
21
|
+
token: "USDT",
|
|
22
|
+
chain: "tron",
|
|
23
|
+
displayName: "USDT on Tron",
|
|
24
|
+
decimals: 6,
|
|
25
|
+
displayOrder: 1,
|
|
26
|
+
iconUrl: "",
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
id: "ETH:base",
|
|
30
|
+
type: "native",
|
|
31
|
+
token: "ETH",
|
|
32
|
+
chain: "base",
|
|
33
|
+
displayName: "ETH on Base",
|
|
34
|
+
decimals: 18,
|
|
35
|
+
displayOrder: 2,
|
|
36
|
+
iconUrl: "",
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
id: "USDC:polygon",
|
|
40
|
+
type: "erc20",
|
|
41
|
+
token: "USDC",
|
|
42
|
+
chain: "polygon",
|
|
43
|
+
displayName: "USDC on Polygon",
|
|
44
|
+
decimals: 6,
|
|
45
|
+
displayOrder: 3,
|
|
46
|
+
iconUrl: "",
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
id: "DOGE:dogecoin",
|
|
50
|
+
type: "native",
|
|
51
|
+
token: "DOGE",
|
|
52
|
+
chain: "dogecoin",
|
|
53
|
+
displayName: "Dogecoin",
|
|
54
|
+
decimals: 8,
|
|
55
|
+
displayOrder: 4,
|
|
56
|
+
iconUrl: "",
|
|
57
|
+
},
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
describe("PaymentMethodPicker", () => {
|
|
61
|
+
it("renders all methods", () => {
|
|
62
|
+
render(<PaymentMethodPicker methods={METHODS} onSelect={vi.fn()} />);
|
|
63
|
+
expect(screen.getByText("Bitcoin")).toBeInTheDocument();
|
|
64
|
+
expect(screen.getByText("USDT on Tron")).toBeInTheDocument();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("filters by search text", async () => {
|
|
68
|
+
render(<PaymentMethodPicker methods={METHODS} onSelect={vi.fn()} />);
|
|
69
|
+
await userEvent.type(screen.getByPlaceholderText(/search/i), "tron");
|
|
70
|
+
expect(screen.getByText("USDT on Tron")).toBeInTheDocument();
|
|
71
|
+
expect(screen.queryByText("Bitcoin")).not.toBeInTheDocument();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("filters by Stablecoins pill", async () => {
|
|
75
|
+
render(<PaymentMethodPicker methods={METHODS} onSelect={vi.fn()} />);
|
|
76
|
+
await userEvent.click(screen.getByText("Stablecoins"));
|
|
77
|
+
expect(screen.getByText("USDT on Tron")).toBeInTheDocument();
|
|
78
|
+
expect(screen.getByText("USDC on Polygon")).toBeInTheDocument();
|
|
79
|
+
expect(screen.queryByText("Bitcoin")).not.toBeInTheDocument();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("calls onSelect when a method is clicked", async () => {
|
|
83
|
+
const onSelect = vi.fn();
|
|
84
|
+
render(<PaymentMethodPicker methods={METHODS} onSelect={onSelect} />);
|
|
85
|
+
await userEvent.click(screen.getByText("Bitcoin"));
|
|
86
|
+
expect(onSelect).toHaveBeenCalledWith(METHODS[0]);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -5,9 +5,9 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
|
|
5
5
|
import { Suspense, useEffect, useState } from "react";
|
|
6
6
|
import { AutoTopupCard } from "@/components/billing/auto-topup-card";
|
|
7
7
|
import { BuyCreditsPanel } from "@/components/billing/buy-credits-panel";
|
|
8
|
-
import { BuyCryptoCreditPanel } from "@/components/billing/buy-crypto-credits-panel";
|
|
9
8
|
import { CouponInput } from "@/components/billing/coupon-input";
|
|
10
9
|
import { CreditBalance } from "@/components/billing/credit-balance";
|
|
10
|
+
import { CryptoCheckout } from "@/components/billing/crypto-checkout";
|
|
11
11
|
import { DividendBanner } from "@/components/billing/dividend-banner";
|
|
12
12
|
import { DividendEligibility } from "@/components/billing/dividend-eligibility";
|
|
13
13
|
import { DividendPoolStats } from "@/components/billing/dividend-pool-stats";
|
|
@@ -200,7 +200,7 @@ function CreditsContent() {
|
|
|
200
200
|
|
|
201
201
|
<BuyCreditsPanel />
|
|
202
202
|
<CouponInput />
|
|
203
|
-
<
|
|
203
|
+
<CryptoCheckout />
|
|
204
204
|
<AutoTopupCard />
|
|
205
205
|
<TransactionHistory />
|
|
206
206
|
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { Button } from "@/components/ui/button";
|
|
5
|
+
import { Input } from "@/components/ui/input";
|
|
6
|
+
import { cn } from "@/lib/utils";
|
|
7
|
+
|
|
8
|
+
const PRESETS = [10, 25, 50, 100];
|
|
9
|
+
|
|
10
|
+
interface AmountSelectorProps {
|
|
11
|
+
onSelect: (amount: number) => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function AmountSelector({ onSelect }: AmountSelectorProps) {
|
|
15
|
+
const [selected, setSelected] = useState<number | null>(null);
|
|
16
|
+
const [custom, setCustom] = useState("");
|
|
17
|
+
|
|
18
|
+
const activeAmount = custom ? Number(custom) : selected;
|
|
19
|
+
const isValid = activeAmount !== null && activeAmount >= 10 && Number.isFinite(activeAmount);
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<div className="space-y-4">
|
|
23
|
+
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
|
24
|
+
{PRESETS.map((amt) => (
|
|
25
|
+
<button
|
|
26
|
+
key={amt}
|
|
27
|
+
type="button"
|
|
28
|
+
onClick={() => {
|
|
29
|
+
setSelected(amt);
|
|
30
|
+
setCustom("");
|
|
31
|
+
}}
|
|
32
|
+
className={cn(
|
|
33
|
+
"rounded-md border p-3 text-lg font-bold transition-colors hover:bg-accent",
|
|
34
|
+
selected === amt && !custom
|
|
35
|
+
? "border-primary bg-primary/5 ring-1 ring-primary"
|
|
36
|
+
: "border-border",
|
|
37
|
+
)}
|
|
38
|
+
>
|
|
39
|
+
${amt}
|
|
40
|
+
</button>
|
|
41
|
+
))}
|
|
42
|
+
</div>
|
|
43
|
+
<Input
|
|
44
|
+
type="number"
|
|
45
|
+
min={10}
|
|
46
|
+
placeholder="Custom amount..."
|
|
47
|
+
value={custom}
|
|
48
|
+
onChange={(e) => {
|
|
49
|
+
setCustom(e.target.value);
|
|
50
|
+
setSelected(null);
|
|
51
|
+
}}
|
|
52
|
+
/>
|
|
53
|
+
<Button
|
|
54
|
+
onClick={() => {
|
|
55
|
+
if (isValid && activeAmount !== null) {
|
|
56
|
+
onSelect(activeAmount);
|
|
57
|
+
}
|
|
58
|
+
}}
|
|
59
|
+
disabled={!isValid}
|
|
60
|
+
className="w-full"
|
|
61
|
+
>
|
|
62
|
+
Continue to payment
|
|
63
|
+
</Button>
|
|
64
|
+
</div>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
@@ -1,304 +1,3 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
import { Check, CircleDollarSign, Copy } from "lucide-react";
|
|
5
|
-
import { useCallback, useEffect, useState } from "react";
|
|
6
|
-
import { Badge } from "@/components/ui/badge";
|
|
7
|
-
import { Button } from "@/components/ui/button";
|
|
8
|
-
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
9
|
-
import {
|
|
10
|
-
type CheckoutResult,
|
|
11
|
-
createCheckout,
|
|
12
|
-
getChargeStatus,
|
|
13
|
-
getSupportedPaymentMethods,
|
|
14
|
-
type SupportedPaymentMethod,
|
|
15
|
-
} from "@/lib/api";
|
|
16
|
-
import { cn } from "@/lib/utils";
|
|
17
|
-
|
|
18
|
-
type PaymentProgress = {
|
|
19
|
-
status: "waiting" | "partial" | "confirming" | "credited" | "expired" | "failed";
|
|
20
|
-
amountExpectedCents: number;
|
|
21
|
-
amountReceivedCents: number;
|
|
22
|
-
confirmations: number;
|
|
23
|
-
confirmationsRequired: number;
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
const AMOUNTS = [
|
|
27
|
-
{ value: 10, label: "$10" },
|
|
28
|
-
{ value: 25, label: "$25" },
|
|
29
|
-
{ value: 50, label: "$50" },
|
|
30
|
-
{ value: 100, label: "$100" },
|
|
31
|
-
];
|
|
32
|
-
|
|
33
|
-
function CopyButton({ text }: { text: string }) {
|
|
34
|
-
const [copied, setCopied] = useState(false);
|
|
35
|
-
const handleCopy = useCallback(() => {
|
|
36
|
-
navigator.clipboard.writeText(text);
|
|
37
|
-
setCopied(true);
|
|
38
|
-
setTimeout(() => setCopied(false), 2000);
|
|
39
|
-
}, [text]);
|
|
40
|
-
|
|
41
|
-
return (
|
|
42
|
-
<Button variant="ghost" size="sm" onClick={handleCopy} className="h-7 px-2">
|
|
43
|
-
{copied ? <Check className="h-3.5 w-3.5 text-primary" /> : <Copy className="h-3.5 w-3.5" />}
|
|
44
|
-
</Button>
|
|
45
|
-
);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export function BuyCryptoCreditPanel() {
|
|
49
|
-
const [methods, setMethods] = useState<SupportedPaymentMethod[]>([]);
|
|
50
|
-
const [selectedMethod, setSelectedMethod] = useState<SupportedPaymentMethod | null>(null);
|
|
51
|
-
const [selectedAmount, setSelectedAmount] = useState<number | null>(null);
|
|
52
|
-
const [loading, setLoading] = useState(false);
|
|
53
|
-
const [error, setError] = useState<string | null>(null);
|
|
54
|
-
const [checkout, setCheckout] = useState<CheckoutResult | null>(null);
|
|
55
|
-
const [paymentProgress, setPaymentProgress] = useState<PaymentProgress | null>(null);
|
|
56
|
-
|
|
57
|
-
// Poll charge status after checkout
|
|
58
|
-
useEffect(() => {
|
|
59
|
-
if (!checkout?.referenceId) {
|
|
60
|
-
setPaymentProgress(null);
|
|
61
|
-
return;
|
|
62
|
-
}
|
|
63
|
-
setPaymentProgress({
|
|
64
|
-
status: "waiting",
|
|
65
|
-
amountExpectedCents: 0,
|
|
66
|
-
amountReceivedCents: 0,
|
|
67
|
-
confirmations: 0,
|
|
68
|
-
confirmationsRequired: 0,
|
|
69
|
-
});
|
|
70
|
-
const interval = setInterval(async () => {
|
|
71
|
-
try {
|
|
72
|
-
const res = await getChargeStatus(checkout.referenceId);
|
|
73
|
-
let status: PaymentProgress["status"] = "waiting";
|
|
74
|
-
if (res.credited) {
|
|
75
|
-
status = "credited";
|
|
76
|
-
} else if (res.status === "expired" || res.status === "failed") {
|
|
77
|
-
status = res.status;
|
|
78
|
-
} else if (
|
|
79
|
-
res.amountReceivedCents >= res.amountExpectedCents &&
|
|
80
|
-
res.amountReceivedCents > 0
|
|
81
|
-
) {
|
|
82
|
-
status = "confirming";
|
|
83
|
-
} else if (res.amountReceivedCents > 0) {
|
|
84
|
-
status = "partial";
|
|
85
|
-
}
|
|
86
|
-
setPaymentProgress({
|
|
87
|
-
status,
|
|
88
|
-
amountExpectedCents: res.amountExpectedCents,
|
|
89
|
-
amountReceivedCents: res.amountReceivedCents,
|
|
90
|
-
confirmations: res.confirmations,
|
|
91
|
-
confirmationsRequired: res.confirmationsRequired,
|
|
92
|
-
});
|
|
93
|
-
if (status === "credited" || status === "expired" || status === "failed") {
|
|
94
|
-
clearInterval(interval);
|
|
95
|
-
}
|
|
96
|
-
} catch {
|
|
97
|
-
// Ignore poll errors
|
|
98
|
-
}
|
|
99
|
-
}, 5000);
|
|
100
|
-
return () => clearInterval(interval);
|
|
101
|
-
}, [checkout?.referenceId]);
|
|
102
|
-
|
|
103
|
-
// Fetch available payment methods from backend on mount
|
|
104
|
-
useEffect(() => {
|
|
105
|
-
getSupportedPaymentMethods()
|
|
106
|
-
.then((m) => {
|
|
107
|
-
if (m.length > 0) {
|
|
108
|
-
setMethods(m);
|
|
109
|
-
setSelectedMethod(m[0]);
|
|
110
|
-
}
|
|
111
|
-
})
|
|
112
|
-
.catch(() => {
|
|
113
|
-
// Backend unavailable — panel stays empty
|
|
114
|
-
});
|
|
115
|
-
}, []);
|
|
116
|
-
|
|
117
|
-
async function handleCheckout() {
|
|
118
|
-
if (selectedAmount === null || !selectedMethod) return;
|
|
119
|
-
setLoading(true);
|
|
120
|
-
setError(null);
|
|
121
|
-
try {
|
|
122
|
-
const result = await createCheckout(selectedMethod.id, selectedAmount);
|
|
123
|
-
setCheckout(result);
|
|
124
|
-
} catch {
|
|
125
|
-
setError("Checkout failed. Please try again.");
|
|
126
|
-
} finally {
|
|
127
|
-
setLoading(false);
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
function handleReset() {
|
|
132
|
-
setCheckout(null);
|
|
133
|
-
setSelectedAmount(null);
|
|
134
|
-
setError(null);
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
if (methods.length === 0) return null;
|
|
138
|
-
|
|
139
|
-
return (
|
|
140
|
-
<motion.div
|
|
141
|
-
initial={{ opacity: 0, y: 8 }}
|
|
142
|
-
animate={{ opacity: 1, y: 0 }}
|
|
143
|
-
transition={{ duration: 0.4, ease: "easeOut", delay: 0.1 }}
|
|
144
|
-
>
|
|
145
|
-
<Card>
|
|
146
|
-
<CardHeader>
|
|
147
|
-
<CardTitle className="flex items-center gap-2">
|
|
148
|
-
<CircleDollarSign className="h-4 w-4 text-primary" />
|
|
149
|
-
Pay with Crypto
|
|
150
|
-
</CardTitle>
|
|
151
|
-
<div className="flex flex-wrap gap-2 pt-1">
|
|
152
|
-
{methods.map((m) => (
|
|
153
|
-
<button
|
|
154
|
-
key={m.id}
|
|
155
|
-
type="button"
|
|
156
|
-
onClick={() => {
|
|
157
|
-
setSelectedMethod(m);
|
|
158
|
-
handleReset();
|
|
159
|
-
}}
|
|
160
|
-
className={cn(
|
|
161
|
-
"flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium transition-colors",
|
|
162
|
-
selectedMethod?.id === m.id
|
|
163
|
-
? "bg-primary/10 text-primary"
|
|
164
|
-
: "text-muted-foreground hover:text-foreground",
|
|
165
|
-
)}
|
|
166
|
-
>
|
|
167
|
-
{/* biome-ignore lint/performance/noImgElement: external dynamic URLs from backend, not static assets */}
|
|
168
|
-
<img
|
|
169
|
-
src={m.iconUrl}
|
|
170
|
-
alt={m.token}
|
|
171
|
-
className="h-4 w-4"
|
|
172
|
-
loading="lazy"
|
|
173
|
-
onError={(e) => {
|
|
174
|
-
e.currentTarget.style.display = "none";
|
|
175
|
-
}}
|
|
176
|
-
/>
|
|
177
|
-
{m.token}
|
|
178
|
-
</button>
|
|
179
|
-
))}
|
|
180
|
-
</div>
|
|
181
|
-
</CardHeader>
|
|
182
|
-
<CardContent className="space-y-4">
|
|
183
|
-
{checkout ? (
|
|
184
|
-
<motion.div
|
|
185
|
-
initial={{ opacity: 0, y: 8 }}
|
|
186
|
-
animate={{ opacity: 1, y: 0 }}
|
|
187
|
-
className="space-y-4"
|
|
188
|
-
>
|
|
189
|
-
<div className="rounded-md border p-4 space-y-3">
|
|
190
|
-
<div className="flex items-center justify-between">
|
|
191
|
-
<p className="text-sm text-muted-foreground">
|
|
192
|
-
Send{" "}
|
|
193
|
-
<span className="font-mono font-bold text-foreground">
|
|
194
|
-
{checkout.displayAmount}
|
|
195
|
-
</span>{" "}
|
|
196
|
-
to:
|
|
197
|
-
</p>
|
|
198
|
-
<Badge variant="outline" className="text-xs">
|
|
199
|
-
{checkout.token} on {checkout.chain}
|
|
200
|
-
</Badge>
|
|
201
|
-
</div>
|
|
202
|
-
<div className="flex items-center gap-2 rounded-md bg-muted/50 px-3 py-2">
|
|
203
|
-
<code className="flex-1 text-xs font-mono break-all text-foreground">
|
|
204
|
-
{checkout.depositAddress}
|
|
205
|
-
</code>
|
|
206
|
-
<CopyButton text={checkout.depositAddress} />
|
|
207
|
-
</div>
|
|
208
|
-
{checkout.priceCents && (
|
|
209
|
-
<p className="text-xs text-muted-foreground">
|
|
210
|
-
Price at checkout: ${(checkout.priceCents / 100).toFixed(2)} per{" "}
|
|
211
|
-
{checkout.token}
|
|
212
|
-
</p>
|
|
213
|
-
)}
|
|
214
|
-
<p className="text-xs text-muted-foreground">
|
|
215
|
-
Only send {checkout.token} on the {checkout.chain} network.
|
|
216
|
-
</p>
|
|
217
|
-
{paymentProgress?.status === "waiting" && (
|
|
218
|
-
<p className="text-xs text-yellow-500 animate-pulse">Waiting for payment...</p>
|
|
219
|
-
)}
|
|
220
|
-
{paymentProgress?.status === "partial" && (
|
|
221
|
-
<p className="text-xs text-blue-500">
|
|
222
|
-
Received ${(paymentProgress.amountReceivedCents / 100).toFixed(2)} of $
|
|
223
|
-
{(paymentProgress.amountExpectedCents / 100).toFixed(2)} — send $
|
|
224
|
-
{(
|
|
225
|
-
(paymentProgress.amountExpectedCents - paymentProgress.amountReceivedCents) /
|
|
226
|
-
100
|
|
227
|
-
).toFixed(2)}{" "}
|
|
228
|
-
more
|
|
229
|
-
</p>
|
|
230
|
-
)}
|
|
231
|
-
{paymentProgress?.status === "confirming" && (
|
|
232
|
-
<p className="text-xs text-blue-500">
|
|
233
|
-
Payment received. Confirming ({paymentProgress.confirmations} of{" "}
|
|
234
|
-
{paymentProgress.confirmationsRequired})...
|
|
235
|
-
</p>
|
|
236
|
-
)}
|
|
237
|
-
{paymentProgress?.status === "credited" && (
|
|
238
|
-
<p className="text-xs text-green-500 font-medium">
|
|
239
|
-
Payment confirmed! Credits added.
|
|
240
|
-
</p>
|
|
241
|
-
)}
|
|
242
|
-
{paymentProgress?.status === "expired" && (
|
|
243
|
-
<p className="text-xs text-red-500">Payment expired.</p>
|
|
244
|
-
)}
|
|
245
|
-
{paymentProgress?.status === "failed" && (
|
|
246
|
-
<p className="text-xs text-red-500">Payment failed.</p>
|
|
247
|
-
)}
|
|
248
|
-
</div>
|
|
249
|
-
{paymentProgress?.status === "credited" ? (
|
|
250
|
-
<Button variant="outline" size="sm" onClick={handleReset}>
|
|
251
|
-
Done
|
|
252
|
-
</Button>
|
|
253
|
-
) : paymentProgress?.status === "expired" || paymentProgress?.status === "failed" ? (
|
|
254
|
-
<div className="flex gap-2">
|
|
255
|
-
<Button variant="outline" size="sm" onClick={handleReset}>
|
|
256
|
-
Try Again
|
|
257
|
-
</Button>
|
|
258
|
-
</div>
|
|
259
|
-
) : (
|
|
260
|
-
<Button variant="ghost" size="sm" onClick={handleReset}>
|
|
261
|
-
Cancel
|
|
262
|
-
</Button>
|
|
263
|
-
)}
|
|
264
|
-
</motion.div>
|
|
265
|
-
) : (
|
|
266
|
-
<>
|
|
267
|
-
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
|
268
|
-
{AMOUNTS.map((amt) => (
|
|
269
|
-
<motion.button
|
|
270
|
-
key={amt.value}
|
|
271
|
-
type="button"
|
|
272
|
-
onClick={() => setSelectedAmount(amt.value)}
|
|
273
|
-
whileHover={{ scale: 1.03 }}
|
|
274
|
-
whileTap={{ scale: 0.98 }}
|
|
275
|
-
transition={{ type: "spring", stiffness: 400, damping: 25 }}
|
|
276
|
-
className={cn(
|
|
277
|
-
"flex flex-col items-center gap-1 rounded-md border p-3 text-sm font-medium transition-colors hover:bg-accent",
|
|
278
|
-
selectedAmount === amt.value
|
|
279
|
-
? "border-primary bg-primary/5 ring-1 ring-primary shadow-[0_0_15px_rgba(0,255,65,0.3)]"
|
|
280
|
-
: "border-border",
|
|
281
|
-
)}
|
|
282
|
-
>
|
|
283
|
-
<span className="text-lg font-bold">{amt.label}</span>
|
|
284
|
-
</motion.button>
|
|
285
|
-
))}
|
|
286
|
-
</div>
|
|
287
|
-
|
|
288
|
-
{error && <p className="text-sm text-destructive">{error}</p>}
|
|
289
|
-
|
|
290
|
-
<Button
|
|
291
|
-
onClick={handleCheckout}
|
|
292
|
-
disabled={selectedAmount === null || !selectedMethod || loading}
|
|
293
|
-
variant="outline"
|
|
294
|
-
className="w-full sm:w-auto"
|
|
295
|
-
>
|
|
296
|
-
{loading ? "Creating checkout..." : `Pay with ${selectedMethod?.token ?? "Crypto"}`}
|
|
297
|
-
</Button>
|
|
298
|
-
</>
|
|
299
|
-
)}
|
|
300
|
-
</CardContent>
|
|
301
|
-
</Card>
|
|
302
|
-
</motion.div>
|
|
303
|
-
);
|
|
304
|
-
}
|
|
3
|
+
export { CryptoCheckout as BuyCryptoCreditPanel } from "./crypto-checkout";
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Check } from "lucide-react";
|
|
4
|
+
|
|
5
|
+
interface ConfirmationTrackerProps {
|
|
6
|
+
confirmations: number;
|
|
7
|
+
confirmationsRequired: number;
|
|
8
|
+
displayAmount: string;
|
|
9
|
+
credited: boolean;
|
|
10
|
+
txHash?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function ConfirmationTracker({
|
|
14
|
+
confirmations,
|
|
15
|
+
confirmationsRequired,
|
|
16
|
+
displayAmount,
|
|
17
|
+
credited,
|
|
18
|
+
txHash,
|
|
19
|
+
}: ConfirmationTrackerProps) {
|
|
20
|
+
const pct =
|
|
21
|
+
confirmationsRequired > 0
|
|
22
|
+
? Math.min(100, Math.round((confirmations / confirmationsRequired) * 100))
|
|
23
|
+
: 0;
|
|
24
|
+
const detected = confirmations > 0 || credited;
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<div className="space-y-4 text-center">
|
|
28
|
+
<p className="text-sm text-muted-foreground">
|
|
29
|
+
{credited ? "Payment complete!" : "Payment received!"}
|
|
30
|
+
</p>
|
|
31
|
+
<p className="text-xl font-semibold">{displayAmount}</p>
|
|
32
|
+
|
|
33
|
+
<div className="rounded-lg border border-border p-3 space-y-2">
|
|
34
|
+
<div className="flex justify-between text-xs">
|
|
35
|
+
<span className="text-muted-foreground">Confirmations</span>
|
|
36
|
+
<span>
|
|
37
|
+
{confirmations} / {confirmationsRequired}
|
|
38
|
+
</span>
|
|
39
|
+
</div>
|
|
40
|
+
<div
|
|
41
|
+
className="h-1.5 rounded-full bg-muted overflow-hidden"
|
|
42
|
+
role="progressbar"
|
|
43
|
+
aria-valuenow={pct}
|
|
44
|
+
aria-valuemin={0}
|
|
45
|
+
aria-valuemax={100}
|
|
46
|
+
>
|
|
47
|
+
<div
|
|
48
|
+
className="h-full rounded-full bg-primary transition-all duration-500"
|
|
49
|
+
style={{ width: `${pct}%` }}
|
|
50
|
+
/>
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
53
|
+
|
|
54
|
+
<div className="space-y-2 text-left">
|
|
55
|
+
<div className="flex items-center gap-2">
|
|
56
|
+
<div
|
|
57
|
+
className={`flex h-4 w-4 items-center justify-center rounded-full text-[10px] ${detected ? "bg-green-500 text-white" : "bg-muted"}`}
|
|
58
|
+
>
|
|
59
|
+
{detected && <Check className="h-2.5 w-2.5" />}
|
|
60
|
+
</div>
|
|
61
|
+
<span
|
|
62
|
+
className={`text-xs ${detected ? "text-muted-foreground" : "text-muted-foreground/50"}`}
|
|
63
|
+
>
|
|
64
|
+
Payment detected
|
|
65
|
+
</span>
|
|
66
|
+
</div>
|
|
67
|
+
<div className="flex items-center gap-2">
|
|
68
|
+
<div
|
|
69
|
+
className={`flex h-4 w-4 items-center justify-center rounded-full text-[10px] ${credited ? "bg-green-500 text-white" : detected ? "bg-primary text-white animate-pulse" : "bg-muted"}`}
|
|
70
|
+
>
|
|
71
|
+
{credited ? (
|
|
72
|
+
<Check className="h-2.5 w-2.5" />
|
|
73
|
+
) : detected ? (
|
|
74
|
+
<span>·</span>
|
|
75
|
+
) : null}
|
|
76
|
+
</div>
|
|
77
|
+
<span
|
|
78
|
+
className={`text-xs ${detected ? "text-foreground" : "text-muted-foreground/50"}`}
|
|
79
|
+
>
|
|
80
|
+
{credited ? "Confirmed" : "Confirming on chain"}
|
|
81
|
+
</span>
|
|
82
|
+
</div>
|
|
83
|
+
<div className="flex items-center gap-2">
|
|
84
|
+
<div
|
|
85
|
+
className={`flex h-4 w-4 items-center justify-center rounded-full text-[10px] ${credited ? "bg-green-500 text-white" : "bg-muted"}`}
|
|
86
|
+
>
|
|
87
|
+
{credited && <Check className="h-2.5 w-2.5" />}
|
|
88
|
+
</div>
|
|
89
|
+
<span
|
|
90
|
+
className={`text-xs ${credited ? "text-foreground" : "text-muted-foreground/50"}`}
|
|
91
|
+
>
|
|
92
|
+
Credits applied
|
|
93
|
+
</span>
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
{txHash && (
|
|
98
|
+
<p className="text-xs text-muted-foreground font-mono truncate">
|
|
99
|
+
tx: {txHash}
|
|
100
|
+
</p>
|
|
101
|
+
)}
|
|
102
|
+
</div>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { AnimatePresence, motion } from "framer-motion";
|
|
4
|
+
import { CircleDollarSign } from "lucide-react";
|
|
5
|
+
import { useCallback, useEffect, useState } from "react";
|
|
6
|
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
7
|
+
import {
|
|
8
|
+
type CheckoutResult,
|
|
9
|
+
createCheckout,
|
|
10
|
+
getChargeStatus,
|
|
11
|
+
getSupportedPaymentMethods,
|
|
12
|
+
type SupportedPaymentMethod,
|
|
13
|
+
} from "@/lib/api";
|
|
14
|
+
import { AmountSelector } from "./amount-selector";
|
|
15
|
+
import { ConfirmationTracker } from "./confirmation-tracker";
|
|
16
|
+
import { DepositView } from "./deposit-view";
|
|
17
|
+
import { PaymentMethodPicker } from "./payment-method-picker";
|
|
18
|
+
|
|
19
|
+
type Step = "amount" | "method" | "deposit" | "confirming";
|
|
20
|
+
type PaymentStatus = "waiting" | "partial" | "confirming" | "credited" | "expired" | "failed";
|
|
21
|
+
|
|
22
|
+
export function CryptoCheckout() {
|
|
23
|
+
const [step, setStep] = useState<Step>("amount");
|
|
24
|
+
const [methods, setMethods] = useState<SupportedPaymentMethod[]>([]);
|
|
25
|
+
const [amountUsd, setAmountUsd] = useState(0);
|
|
26
|
+
const [checkout, setCheckout] = useState<CheckoutResult | null>(null);
|
|
27
|
+
const [status, setStatus] = useState<PaymentStatus>("waiting");
|
|
28
|
+
const [confirmations, setConfirmations] = useState(0);
|
|
29
|
+
const [confirmationsRequired, setConfirmationsRequired] = useState(0);
|
|
30
|
+
const [loading, setLoading] = useState(false);
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
getSupportedPaymentMethods()
|
|
34
|
+
.then(setMethods)
|
|
35
|
+
.catch(() => {});
|
|
36
|
+
}, []);
|
|
37
|
+
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
if (!checkout?.referenceId) return;
|
|
40
|
+
const interval = setInterval(async () => {
|
|
41
|
+
try {
|
|
42
|
+
const res = await getChargeStatus(checkout.referenceId);
|
|
43
|
+
setConfirmations(res.confirmations);
|
|
44
|
+
setConfirmationsRequired(res.confirmationsRequired);
|
|
45
|
+
if (res.credited) {
|
|
46
|
+
setStatus("credited");
|
|
47
|
+
setStep("confirming");
|
|
48
|
+
clearInterval(interval);
|
|
49
|
+
} else if (res.status === "expired" || res.status === "failed") {
|
|
50
|
+
setStatus(res.status as PaymentStatus);
|
|
51
|
+
clearInterval(interval);
|
|
52
|
+
} else if (
|
|
53
|
+
res.amountReceivedCents > 0 &&
|
|
54
|
+
res.amountReceivedCents >= res.amountExpectedCents
|
|
55
|
+
) {
|
|
56
|
+
setStatus("confirming");
|
|
57
|
+
setStep("confirming");
|
|
58
|
+
} else if (res.amountReceivedCents > 0) {
|
|
59
|
+
setStatus("partial");
|
|
60
|
+
}
|
|
61
|
+
} catch {
|
|
62
|
+
/* ignore poll errors */
|
|
63
|
+
}
|
|
64
|
+
}, 5000);
|
|
65
|
+
return () => clearInterval(interval);
|
|
66
|
+
}, [checkout?.referenceId]);
|
|
67
|
+
|
|
68
|
+
const handleAmount = useCallback((amt: number) => {
|
|
69
|
+
setAmountUsd(amt);
|
|
70
|
+
setStep("method");
|
|
71
|
+
}, []);
|
|
72
|
+
|
|
73
|
+
const handleMethod = useCallback(
|
|
74
|
+
async (method: SupportedPaymentMethod) => {
|
|
75
|
+
setLoading(true);
|
|
76
|
+
try {
|
|
77
|
+
const result = await createCheckout(method.id, amountUsd);
|
|
78
|
+
setCheckout(result);
|
|
79
|
+
setStatus("waiting");
|
|
80
|
+
setStep("deposit");
|
|
81
|
+
} catch {
|
|
82
|
+
// Stay on method step
|
|
83
|
+
} finally {
|
|
84
|
+
setLoading(false);
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
[amountUsd],
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
const handleReset = useCallback(() => {
|
|
91
|
+
setStep("amount");
|
|
92
|
+
setCheckout(null);
|
|
93
|
+
setStatus("waiting");
|
|
94
|
+
setAmountUsd(0);
|
|
95
|
+
setConfirmations(0);
|
|
96
|
+
}, []);
|
|
97
|
+
|
|
98
|
+
if (methods.length === 0) return null;
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<motion.div
|
|
102
|
+
initial={{ opacity: 0, y: 8 }}
|
|
103
|
+
animate={{ opacity: 1, y: 0 }}
|
|
104
|
+
transition={{ duration: 0.3 }}
|
|
105
|
+
>
|
|
106
|
+
<Card>
|
|
107
|
+
<CardHeader>
|
|
108
|
+
<CardTitle className="flex items-center gap-2">
|
|
109
|
+
<CircleDollarSign className="h-4 w-4 text-primary" />
|
|
110
|
+
Pay with Crypto
|
|
111
|
+
</CardTitle>
|
|
112
|
+
</CardHeader>
|
|
113
|
+
<CardContent>
|
|
114
|
+
<AnimatePresence mode="wait">
|
|
115
|
+
{step === "amount" && (
|
|
116
|
+
<motion.div
|
|
117
|
+
key="amount"
|
|
118
|
+
initial={{ opacity: 0, x: -20 }}
|
|
119
|
+
animate={{ opacity: 1, x: 0 }}
|
|
120
|
+
exit={{ opacity: 0, x: 20 }}
|
|
121
|
+
>
|
|
122
|
+
<AmountSelector onSelect={handleAmount} />
|
|
123
|
+
</motion.div>
|
|
124
|
+
)}
|
|
125
|
+
{step === "method" && (
|
|
126
|
+
<motion.div
|
|
127
|
+
key="method"
|
|
128
|
+
initial={{ opacity: 0, x: -20 }}
|
|
129
|
+
animate={{ opacity: 1, x: 0 }}
|
|
130
|
+
exit={{ opacity: 0, x: 20 }}
|
|
131
|
+
>
|
|
132
|
+
<PaymentMethodPicker
|
|
133
|
+
methods={methods}
|
|
134
|
+
onSelect={handleMethod}
|
|
135
|
+
onBack={() => setStep("amount")}
|
|
136
|
+
/>
|
|
137
|
+
{loading && (
|
|
138
|
+
<p className="mt-2 text-xs text-muted-foreground animate-pulse">
|
|
139
|
+
Creating checkout...
|
|
140
|
+
</p>
|
|
141
|
+
)}
|
|
142
|
+
</motion.div>
|
|
143
|
+
)}
|
|
144
|
+
{step === "deposit" && checkout && (
|
|
145
|
+
<motion.div
|
|
146
|
+
key="deposit"
|
|
147
|
+
initial={{ opacity: 0, x: -20 }}
|
|
148
|
+
animate={{ opacity: 1, x: 0 }}
|
|
149
|
+
exit={{ opacity: 0, x: 20 }}
|
|
150
|
+
>
|
|
151
|
+
<DepositView checkout={checkout} status={status} onBack={() => setStep("method")} />
|
|
152
|
+
</motion.div>
|
|
153
|
+
)}
|
|
154
|
+
{step === "confirming" && checkout && (
|
|
155
|
+
<motion.div
|
|
156
|
+
key="confirming"
|
|
157
|
+
initial={{ opacity: 0, x: -20 }}
|
|
158
|
+
animate={{ opacity: 1, x: 0 }}
|
|
159
|
+
exit={{ opacity: 0, x: 20 }}
|
|
160
|
+
>
|
|
161
|
+
<ConfirmationTracker
|
|
162
|
+
confirmations={confirmations}
|
|
163
|
+
confirmationsRequired={confirmationsRequired}
|
|
164
|
+
displayAmount={checkout.displayAmount}
|
|
165
|
+
credited={status === "credited"}
|
|
166
|
+
/>
|
|
167
|
+
{status === "credited" && (
|
|
168
|
+
<button
|
|
169
|
+
type="button"
|
|
170
|
+
onClick={handleReset}
|
|
171
|
+
className="mt-4 text-sm text-primary hover:underline"
|
|
172
|
+
>
|
|
173
|
+
Done — buy more credits
|
|
174
|
+
</button>
|
|
175
|
+
)}
|
|
176
|
+
</motion.div>
|
|
177
|
+
)}
|
|
178
|
+
</AnimatePresence>
|
|
179
|
+
</CardContent>
|
|
180
|
+
</Card>
|
|
181
|
+
</motion.div>
|
|
182
|
+
);
|
|
183
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Check, Copy } from "lucide-react";
|
|
4
|
+
import { QRCodeSVG } from "qrcode.react";
|
|
5
|
+
import { useCallback, useEffect, useState } from "react";
|
|
6
|
+
import { Button } from "@/components/ui/button";
|
|
7
|
+
import type { CheckoutResult } from "@/lib/api";
|
|
8
|
+
|
|
9
|
+
interface DepositViewProps {
|
|
10
|
+
checkout: CheckoutResult;
|
|
11
|
+
status: "waiting" | "partial" | "confirming" | "credited" | "expired" | "failed";
|
|
12
|
+
onBack: () => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function DepositView({ checkout, status, onBack }: DepositViewProps) {
|
|
16
|
+
const [copied, setCopied] = useState(false);
|
|
17
|
+
const [timeLeft, setTimeLeft] = useState(30 * 60);
|
|
18
|
+
|
|
19
|
+
const handleCopy = useCallback(() => {
|
|
20
|
+
navigator.clipboard.writeText(checkout.depositAddress);
|
|
21
|
+
setCopied(true);
|
|
22
|
+
setTimeout(() => setCopied(false), 2000);
|
|
23
|
+
}, [checkout.depositAddress]);
|
|
24
|
+
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
if (status !== "waiting") return;
|
|
27
|
+
const timer = setInterval(() => setTimeLeft((t) => Math.max(0, t - 1)), 1000);
|
|
28
|
+
return () => clearInterval(timer);
|
|
29
|
+
}, [status]);
|
|
30
|
+
|
|
31
|
+
const mins = Math.floor(timeLeft / 60);
|
|
32
|
+
const secs = timeLeft % 60;
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<div className="space-y-4 text-center">
|
|
36
|
+
<button
|
|
37
|
+
type="button"
|
|
38
|
+
onClick={onBack}
|
|
39
|
+
className="text-sm text-muted-foreground hover:text-foreground self-start"
|
|
40
|
+
>
|
|
41
|
+
← Back
|
|
42
|
+
</button>
|
|
43
|
+
<p className="text-sm text-muted-foreground">Send exactly</p>
|
|
44
|
+
<p className="text-2xl font-semibold">{checkout.displayAmount}</p>
|
|
45
|
+
<p className="text-xs text-muted-foreground">
|
|
46
|
+
on {checkout.chain} · ${checkout.amountUsd.toFixed(2)} USD
|
|
47
|
+
</p>
|
|
48
|
+
<div className="mx-auto w-fit rounded-lg border border-border bg-background p-3" aria-hidden="true">
|
|
49
|
+
<QRCodeSVG value={checkout.depositAddress} size={140} bgColor="hsl(var(--background))" fgColor="hsl(var(--foreground))" />
|
|
50
|
+
</div>
|
|
51
|
+
<div className="flex items-center gap-2 rounded-lg border border-border bg-muted/50 px-3 py-2">
|
|
52
|
+
<code className="flex-1 truncate text-xs font-mono">{checkout.depositAddress}</code>
|
|
53
|
+
<Button variant="ghost" size="sm" onClick={handleCopy} aria-label="Copy address">
|
|
54
|
+
{copied ? (
|
|
55
|
+
<Check className="h-3.5 w-3.5 text-primary" />
|
|
56
|
+
) : (
|
|
57
|
+
<Copy className="h-3.5 w-3.5" />
|
|
58
|
+
)}
|
|
59
|
+
</Button>
|
|
60
|
+
</div>
|
|
61
|
+
<div className="flex items-center justify-center gap-2 rounded-lg border border-border p-2">
|
|
62
|
+
{status === "waiting" && (
|
|
63
|
+
<>
|
|
64
|
+
<span className="h-2 w-2 animate-pulse rounded-full bg-yellow-500" />
|
|
65
|
+
<span className="text-xs text-yellow-500">Waiting for payment...</span>
|
|
66
|
+
<span className="text-xs text-muted-foreground">
|
|
67
|
+
· {mins}:{secs.toString().padStart(2, "0")}
|
|
68
|
+
</span>
|
|
69
|
+
</>
|
|
70
|
+
)}
|
|
71
|
+
{status === "partial" && (
|
|
72
|
+
<>
|
|
73
|
+
<span className="h-2 w-2 rounded-full bg-blue-500" />
|
|
74
|
+
<span className="text-xs text-blue-500">Partial payment received</span>
|
|
75
|
+
</>
|
|
76
|
+
)}
|
|
77
|
+
{status === "expired" && <span className="text-xs text-destructive">Payment expired</span>}
|
|
78
|
+
{status === "failed" && <span className="text-xs text-destructive">Payment failed</span>}
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useMemo, useState } from "react";
|
|
4
|
+
import { Input } from "@/components/ui/input";
|
|
5
|
+
import type { SupportedPaymentMethod } from "@/lib/api";
|
|
6
|
+
import { cn } from "@/lib/utils";
|
|
7
|
+
|
|
8
|
+
type Filter = "popular" | "stablecoins" | "l2" | "native";
|
|
9
|
+
|
|
10
|
+
const FILTERS: { key: Filter; label: string }[] = [
|
|
11
|
+
{ key: "popular", label: "Popular" },
|
|
12
|
+
{ key: "stablecoins", label: "Stablecoins" },
|
|
13
|
+
{ key: "l2", label: "L2s" },
|
|
14
|
+
{ key: "native", label: "Native" },
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
const STABLECOIN_TOKENS = new Set(["USDC", "USDT", "DAI"]);
|
|
18
|
+
const L2_CHAINS = new Set(["base", "arbitrum", "optimism", "polygon"]);
|
|
19
|
+
const POPULAR_COUNT = 6;
|
|
20
|
+
|
|
21
|
+
interface PaymentMethodPickerProps {
|
|
22
|
+
methods: SupportedPaymentMethod[];
|
|
23
|
+
onSelect: (method: SupportedPaymentMethod) => void;
|
|
24
|
+
onBack?: () => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function PaymentMethodPicker({
|
|
28
|
+
methods,
|
|
29
|
+
onSelect,
|
|
30
|
+
onBack,
|
|
31
|
+
}: PaymentMethodPickerProps) {
|
|
32
|
+
const [search, setSearch] = useState("");
|
|
33
|
+
const [filter, setFilter] = useState<Filter>("popular");
|
|
34
|
+
|
|
35
|
+
const filtered = useMemo(() => {
|
|
36
|
+
if (search) {
|
|
37
|
+
const q = search.toLowerCase();
|
|
38
|
+
return methods.filter(
|
|
39
|
+
(m) =>
|
|
40
|
+
m.token.toLowerCase().includes(q) ||
|
|
41
|
+
m.chain.toLowerCase().includes(q) ||
|
|
42
|
+
m.displayName.toLowerCase().includes(q),
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
switch (filter) {
|
|
47
|
+
case "popular":
|
|
48
|
+
return methods.slice(0, POPULAR_COUNT);
|
|
49
|
+
case "stablecoins":
|
|
50
|
+
return methods.filter((m) => STABLECOIN_TOKENS.has(m.token));
|
|
51
|
+
case "l2":
|
|
52
|
+
return methods.filter((m) => L2_CHAINS.has(m.chain));
|
|
53
|
+
case "native":
|
|
54
|
+
return methods.filter((m) => m.type === "native" && !L2_CHAINS.has(m.chain));
|
|
55
|
+
default:
|
|
56
|
+
return methods;
|
|
57
|
+
}
|
|
58
|
+
}, [methods, search, filter]);
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<div className="space-y-3">
|
|
62
|
+
{onBack && (
|
|
63
|
+
<button
|
|
64
|
+
type="button"
|
|
65
|
+
onClick={onBack}
|
|
66
|
+
className="text-sm text-muted-foreground hover:text-foreground"
|
|
67
|
+
>
|
|
68
|
+
← Back
|
|
69
|
+
</button>
|
|
70
|
+
)}
|
|
71
|
+
<Input
|
|
72
|
+
placeholder="Search token or network..."
|
|
73
|
+
value={search}
|
|
74
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
75
|
+
/>
|
|
76
|
+
<div className="flex flex-wrap gap-2">
|
|
77
|
+
{FILTERS.map((f) => (
|
|
78
|
+
<button
|
|
79
|
+
key={f.key}
|
|
80
|
+
type="button"
|
|
81
|
+
onClick={() => {
|
|
82
|
+
setFilter(f.key);
|
|
83
|
+
setSearch("");
|
|
84
|
+
}}
|
|
85
|
+
className={cn(
|
|
86
|
+
"rounded-full px-3 py-1 text-xs font-medium transition-colors",
|
|
87
|
+
filter === f.key && !search
|
|
88
|
+
? "bg-primary text-primary-foreground"
|
|
89
|
+
: "bg-muted text-muted-foreground hover:text-foreground",
|
|
90
|
+
)}
|
|
91
|
+
>
|
|
92
|
+
{f.label}
|
|
93
|
+
</button>
|
|
94
|
+
))}
|
|
95
|
+
</div>
|
|
96
|
+
<div className="max-h-[320px] space-y-2 overflow-y-auto">
|
|
97
|
+
{filtered.map((m) => (
|
|
98
|
+
<button
|
|
99
|
+
key={m.id}
|
|
100
|
+
type="button"
|
|
101
|
+
onClick={() => onSelect(m)}
|
|
102
|
+
className="flex w-full items-center justify-between rounded-lg border border-border p-3 text-left transition-colors hover:bg-accent"
|
|
103
|
+
>
|
|
104
|
+
<div className="flex items-center gap-3">
|
|
105
|
+
{m.iconUrl && (
|
|
106
|
+
// biome-ignore lint/performance/noImgElement: external dynamic URLs
|
|
107
|
+
<img
|
|
108
|
+
src={m.iconUrl}
|
|
109
|
+
alt={m.token}
|
|
110
|
+
className="h-7 w-7 rounded-full"
|
|
111
|
+
loading="lazy"
|
|
112
|
+
onError={(e) => {
|
|
113
|
+
e.currentTarget.style.display = "none";
|
|
114
|
+
}}
|
|
115
|
+
/>
|
|
116
|
+
)}
|
|
117
|
+
<div>
|
|
118
|
+
<div className="text-sm font-medium">{m.displayName}</div>
|
|
119
|
+
<div className="text-xs text-muted-foreground">
|
|
120
|
+
{m.token} · {m.chain}
|
|
121
|
+
{m.type === "erc20" ? " · ERC-20" : " · Native"}
|
|
122
|
+
</div>
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
</button>
|
|
126
|
+
))}
|
|
127
|
+
{filtered.length === 0 && (
|
|
128
|
+
<p className="py-4 text-center text-sm text-muted-foreground">No payment methods found</p>
|
|
129
|
+
)}
|
|
130
|
+
</div>
|
|
131
|
+
</div>
|
|
132
|
+
);
|
|
133
|
+
}
|