@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wopr-network/platform-ui-core",
3
- "version": "1.25.0",
3
+ "version": "1.26.0",
4
4
  "description": "Brand-agnostic AI agent platform UI — deploy as any brand via env vars",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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
- <BuyCryptoCreditPanel />
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
- import { motion } from "framer-motion";
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>&middot;</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
+ &larr; 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} &middot; ${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
+ &middot; {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
+ &larr; 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} &middot; {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
+ }