@wopr-network/platform-ui-core 1.5.0 → 1.6.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.5.0",
3
+ "version": "1.6.0",
4
4
  "description": "Brand-agnostic AI agent platform UI — deploy as any brand via env vars",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,14 +1,19 @@
1
- import { render, screen } from "@testing-library/react";
1
+ import { render, screen, waitFor } from "@testing-library/react";
2
2
  import userEvent from "@testing-library/user-event";
3
3
  import { afterEach, describe, expect, it, vi } from "vitest";
4
4
 
5
- const { mockCreateCryptoCheckout, mockIsAllowedRedirectUrl } = vi.hoisted(() => ({
6
- mockCreateCryptoCheckout: vi.fn(),
7
- mockIsAllowedRedirectUrl: vi.fn(),
8
- }));
5
+ const { mockCreateCryptoCheckout, mockIsAllowedRedirectUrl, mockGetSupportedPaymentMethods } =
6
+ vi.hoisted(() => ({
7
+ mockCreateCryptoCheckout: vi.fn(),
8
+ mockIsAllowedRedirectUrl: vi.fn(),
9
+ mockGetSupportedPaymentMethods: vi.fn(),
10
+ }));
9
11
 
10
12
  vi.mock("@/lib/api", () => ({
11
13
  createCryptoCheckout: (...args: unknown[]) => mockCreateCryptoCheckout(...args),
14
+ createEthCheckout: vi.fn(),
15
+ createStablecoinCheckout: vi.fn(),
16
+ getSupportedPaymentMethods: (...args: unknown[]) => mockGetSupportedPaymentMethods(...args),
12
17
  }));
13
18
 
14
19
  vi.mock("@/lib/validate-redirect-url", () => ({
@@ -44,6 +49,9 @@ vi.mock("next/navigation", () => ({
44
49
  vi.mock("better-auth/react", () => ({
45
50
  createAuthClient: () => ({
46
51
  useSession: () => ({ data: null, isPending: false, error: null }),
52
+ signIn: { email: vi.fn(), social: vi.fn() },
53
+ signUp: { email: vi.fn() },
54
+ signOut: vi.fn(),
47
55
  }),
48
56
  }));
49
57
 
@@ -55,14 +63,31 @@ vi.mock("lucide-react", async (importOriginal) => {
55
63
  };
56
64
  });
57
65
 
66
+ const MOCK_USDC_METHOD = {
67
+ id: "usdc:base",
68
+ type: "stablecoin",
69
+ label: "USDC on Base",
70
+ token: "USDC",
71
+ chain: "base",
72
+ };
73
+ const MOCK_BTC_METHOD = {
74
+ id: "btc:btcpay",
75
+ type: "btc",
76
+ label: "BTC",
77
+ token: "BTC",
78
+ chain: "bitcoin",
79
+ };
80
+
58
81
  afterEach(() => {
59
82
  vi.restoreAllMocks();
60
83
  mockIsAllowedRedirectUrl.mockReset();
61
84
  mockCreateCryptoCheckout.mockReset();
85
+ mockGetSupportedPaymentMethods.mockReset();
62
86
  });
63
87
 
64
88
  describe("BuyCryptoCreditPanel", () => {
65
89
  it("renders crypto amount buttons ($10, $25, $50, $100)", async () => {
90
+ mockGetSupportedPaymentMethods.mockResolvedValue([MOCK_USDC_METHOD, MOCK_BTC_METHOD]);
66
91
  const { BuyCryptoCreditPanel } = await import("../components/billing/buy-crypto-credits-panel");
67
92
  render(<BuyCryptoCreditPanel />);
68
93
 
@@ -74,23 +99,35 @@ describe("BuyCryptoCreditPanel", () => {
74
99
  });
75
100
 
76
101
  it("Pay button is disabled when no amount selected", async () => {
102
+ mockGetSupportedPaymentMethods.mockResolvedValue([MOCK_USDC_METHOD, MOCK_BTC_METHOD]);
77
103
  const { BuyCryptoCreditPanel } = await import("../components/billing/buy-crypto-credits-panel");
78
104
  render(<BuyCryptoCreditPanel />);
79
105
 
80
- const payBtn = screen.getByRole("button", { name: "Pay with crypto" });
106
+ // Wait for methods to load
107
+ await waitFor(() => {
108
+ expect(screen.getByText("Stablecoin")).toBeInTheDocument();
109
+ });
110
+
111
+ const payBtn = screen.getByRole("button", { name: "Pay with USDC" });
81
112
  expect(payBtn).toBeDisabled();
82
113
  });
83
114
 
84
115
  it("Pay button is enabled after selecting an amount", async () => {
116
+ mockGetSupportedPaymentMethods.mockResolvedValue([MOCK_USDC_METHOD, MOCK_BTC_METHOD]);
85
117
  const user = userEvent.setup();
86
118
  const { BuyCryptoCreditPanel } = await import("../components/billing/buy-crypto-credits-panel");
87
119
  render(<BuyCryptoCreditPanel />);
88
120
 
121
+ await waitFor(() => {
122
+ expect(screen.getByText("Stablecoin")).toBeInTheDocument();
123
+ });
124
+
89
125
  await user.click(screen.getByText("$25"));
90
- expect(screen.getByRole("button", { name: "Pay with crypto" })).toBeEnabled();
126
+ expect(screen.getByRole("button", { name: "Pay with USDC" })).toBeEnabled();
91
127
  });
92
128
 
93
129
  it("calls createCryptoCheckout with selected amount and redirects", async () => {
130
+ mockGetSupportedPaymentMethods.mockResolvedValue([MOCK_USDC_METHOD, MOCK_BTC_METHOD]);
94
131
  const hrefSetter = vi.fn();
95
132
  Object.defineProperty(window, "location", {
96
133
  value: { ...window.location, href: "" },
@@ -112,8 +149,14 @@ describe("BuyCryptoCreditPanel", () => {
112
149
  const { BuyCryptoCreditPanel } = await import("../components/billing/buy-crypto-credits-panel");
113
150
  render(<BuyCryptoCreditPanel />);
114
151
 
152
+ await waitFor(() => {
153
+ expect(screen.getByText("BTC")).toBeInTheDocument();
154
+ });
155
+
156
+ // Switch to BTC tab
157
+ await user.click(screen.getByText("BTC"));
115
158
  await user.click(screen.getByText("$50"));
116
- await user.click(screen.getByRole("button", { name: "Pay with crypto" }));
159
+ await user.click(screen.getByRole("button", { name: "Pay with BTC" }));
117
160
 
118
161
  expect(mockCreateCryptoCheckout).toHaveBeenCalledWith(50);
119
162
  expect(mockIsAllowedRedirectUrl).toHaveBeenCalledWith("https://btcpay.example.com/i/abc123");
@@ -121,6 +164,7 @@ describe("BuyCryptoCreditPanel", () => {
121
164
  });
122
165
 
123
166
  it("shows error when redirect URL is not allowed", async () => {
167
+ mockGetSupportedPaymentMethods.mockResolvedValue([MOCK_USDC_METHOD, MOCK_BTC_METHOD]);
124
168
  mockCreateCryptoCheckout.mockResolvedValue({
125
169
  url: "https://evil.com/steal",
126
170
  referenceId: "ref-evil",
@@ -131,15 +175,21 @@ describe("BuyCryptoCreditPanel", () => {
131
175
  const { BuyCryptoCreditPanel } = await import("../components/billing/buy-crypto-credits-panel");
132
176
  render(<BuyCryptoCreditPanel />);
133
177
 
178
+ await waitFor(() => {
179
+ expect(screen.getByText("BTC")).toBeInTheDocument();
180
+ });
181
+
182
+ await user.click(screen.getByText("BTC"));
134
183
  await user.click(screen.getByText("$10"));
135
- await user.click(screen.getByRole("button", { name: "Pay with crypto" }));
184
+ await user.click(screen.getByRole("button", { name: "Pay with BTC" }));
136
185
 
137
186
  expect(
138
187
  await screen.findByText("Unexpected checkout URL. Please contact support."),
139
188
  ).toBeInTheDocument();
140
189
  });
141
190
 
142
- it("shows Opening checkout... while checkout is in progress", async () => {
191
+ it("shows Creating checkout... while checkout is in progress", async () => {
192
+ mockGetSupportedPaymentMethods.mockResolvedValue([MOCK_USDC_METHOD, MOCK_BTC_METHOD]);
143
193
  mockCreateCryptoCheckout.mockReturnValue(
144
194
  new Promise(() => {
145
195
  /* intentionally pending */
@@ -150,29 +200,33 @@ describe("BuyCryptoCreditPanel", () => {
150
200
  const { BuyCryptoCreditPanel } = await import("../components/billing/buy-crypto-credits-panel");
151
201
  render(<BuyCryptoCreditPanel />);
152
202
 
203
+ await waitFor(() => {
204
+ expect(screen.getByText("BTC")).toBeInTheDocument();
205
+ });
206
+
207
+ await user.click(screen.getByText("BTC"));
153
208
  await user.click(screen.getByText("$100"));
154
- await user.click(screen.getByRole("button", { name: "Pay with crypto" }));
209
+ await user.click(screen.getByRole("button", { name: "Pay with BTC" }));
155
210
 
156
- expect(await screen.findByText("Opening checkout...")).toBeInTheDocument();
211
+ expect(await screen.findByText("Creating checkout...")).toBeInTheDocument();
157
212
  });
158
213
 
159
214
  it("shows error when crypto checkout API call fails", async () => {
215
+ mockGetSupportedPaymentMethods.mockResolvedValue([MOCK_USDC_METHOD, MOCK_BTC_METHOD]);
160
216
  mockCreateCryptoCheckout.mockRejectedValue(new Error("API down"));
161
217
 
162
218
  const user = userEvent.setup();
163
219
  const { BuyCryptoCreditPanel } = await import("../components/billing/buy-crypto-credits-panel");
164
220
  render(<BuyCryptoCreditPanel />);
165
221
 
222
+ await waitFor(() => {
223
+ expect(screen.getByText("BTC")).toBeInTheDocument();
224
+ });
225
+
226
+ await user.click(screen.getByText("BTC"));
166
227
  await user.click(screen.getByText("$25"));
167
- await user.click(screen.getByRole("button", { name: "Pay with crypto" }));
228
+ await user.click(screen.getByRole("button", { name: "Pay with BTC" }));
168
229
 
169
230
  expect(await screen.findByText("Checkout failed. Please try again.")).toBeInTheDocument();
170
231
  });
171
-
172
- it("displays minimum amount note", async () => {
173
- const { BuyCryptoCreditPanel } = await import("../components/billing/buy-crypto-credits-panel");
174
- render(<BuyCryptoCreditPanel />);
175
-
176
- expect(screen.getByText(/Minimum \$10/)).toBeInTheDocument();
177
- });
178
232
  });
@@ -19,10 +19,17 @@ vi.mock("next/navigation", () => ({
19
19
  usePathname: () => "/settings/profile",
20
20
  }));
21
21
 
22
- // Mock better-auth/react
22
+ // Mock better-auth/react — session with user-001 (Alice) so role-gated org UI renders admin view
23
23
  vi.mock("better-auth/react", () => ({
24
24
  createAuthClient: () => ({
25
- useSession: () => ({ data: null, isPending: false, error: null }),
25
+ useSession: () => ({
26
+ data: {
27
+ user: { id: "user-001", name: "Alice Johnson", email: "alice@example.com" },
28
+ session: { id: "sess-1", userId: "user-001", expiresAt: new Date(Date.now() + 86400000) },
29
+ },
30
+ isPending: false,
31
+ error: null,
32
+ }),
26
33
  signIn: { email: vi.fn(), social: vi.fn() },
27
34
  signUp: { email: vi.fn() },
28
35
  signOut: vi.fn(),
@@ -0,0 +1,249 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useState } from "react";
4
+ import { Button } from "@/components/ui/button";
5
+ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
6
+ import {
7
+ adminListPaymentMethods,
8
+ adminTogglePaymentMethod,
9
+ adminUpsertPaymentMethod,
10
+ type PaymentMethodAdmin,
11
+ } from "@/lib/api";
12
+
13
+ function AddMethodForm({ onSaved }: { onSaved: () => void }) {
14
+ const [open, setOpen] = useState(false);
15
+ const [saving, setSaving] = useState(false);
16
+ const [form, setForm] = useState({
17
+ token: "",
18
+ chain: "",
19
+ type: "erc20",
20
+ contractAddress: "",
21
+ decimals: 18,
22
+ displayName: "",
23
+ displayOrder: 0,
24
+ confirmations: 1,
25
+ rpcUrl: "",
26
+ });
27
+
28
+ async function handleSubmit(e: React.FormEvent) {
29
+ e.preventDefault();
30
+ setSaving(true);
31
+ const id = `${form.token}:${form.chain}`;
32
+ await adminUpsertPaymentMethod({
33
+ id,
34
+ type: form.type,
35
+ token: form.token,
36
+ chain: form.chain,
37
+ contractAddress: form.contractAddress || null,
38
+ decimals: form.decimals,
39
+ displayName: form.displayName || `${form.token} on ${form.chain}`,
40
+ enabled: true,
41
+ displayOrder: form.displayOrder,
42
+ rpcUrl: form.rpcUrl || null,
43
+ confirmations: form.confirmations,
44
+ });
45
+ setSaving(false);
46
+ setOpen(false);
47
+ setForm({
48
+ token: "",
49
+ chain: "",
50
+ type: "erc20",
51
+ contractAddress: "",
52
+ decimals: 18,
53
+ displayName: "",
54
+ displayOrder: 0,
55
+ confirmations: 1,
56
+ rpcUrl: "",
57
+ });
58
+ onSaved();
59
+ }
60
+
61
+ if (!open) {
62
+ return (
63
+ <Button variant="outline" size="sm" onClick={() => setOpen(true)}>
64
+ Add Payment Method
65
+ </Button>
66
+ );
67
+ }
68
+
69
+ return (
70
+ <form onSubmit={handleSubmit} className="rounded-md border p-4 space-y-3">
71
+ <div className="grid grid-cols-2 gap-3">
72
+ <label className="space-y-1">
73
+ <span className="text-xs text-muted-foreground">Token</span>
74
+ <input
75
+ className="w-full rounded-md border bg-background px-3 py-1.5 text-sm"
76
+ placeholder="USDC"
77
+ value={form.token}
78
+ onChange={(e) => setForm({ ...form, token: e.target.value })}
79
+ required
80
+ />
81
+ </label>
82
+ <label className="space-y-1">
83
+ <span className="text-xs text-muted-foreground">Chain</span>
84
+ <input
85
+ className="w-full rounded-md border bg-background px-3 py-1.5 text-sm"
86
+ placeholder="base"
87
+ value={form.chain}
88
+ onChange={(e) => setForm({ ...form, chain: e.target.value })}
89
+ required
90
+ />
91
+ </label>
92
+ <label className="space-y-1">
93
+ <span className="text-xs text-muted-foreground">Type</span>
94
+ <select
95
+ className="w-full rounded-md border bg-background px-3 py-1.5 text-sm"
96
+ value={form.type}
97
+ onChange={(e) => setForm({ ...form, type: e.target.value })}
98
+ >
99
+ <option value="erc20">ERC-20</option>
100
+ <option value="native">Native</option>
101
+ </select>
102
+ </label>
103
+ <label className="space-y-1">
104
+ <span className="text-xs text-muted-foreground">Decimals</span>
105
+ <input
106
+ type="number"
107
+ className="w-full rounded-md border bg-background px-3 py-1.5 text-sm"
108
+ value={form.decimals}
109
+ onChange={(e) => setForm({ ...form, decimals: Number(e.target.value) })}
110
+ />
111
+ </label>
112
+ <label className="col-span-2 space-y-1">
113
+ <span className="text-xs text-muted-foreground">Contract Address (empty for native)</span>
114
+ <input
115
+ className="w-full rounded-md border bg-background px-3 py-1.5 text-sm font-mono"
116
+ placeholder="0x..."
117
+ value={form.contractAddress}
118
+ onChange={(e) => setForm({ ...form, contractAddress: e.target.value })}
119
+ />
120
+ </label>
121
+ <label className="space-y-1">
122
+ <span className="text-xs text-muted-foreground">Display Order</span>
123
+ <input
124
+ type="number"
125
+ className="w-full rounded-md border bg-background px-3 py-1.5 text-sm"
126
+ value={form.displayOrder}
127
+ onChange={(e) => setForm({ ...form, displayOrder: Number(e.target.value) })}
128
+ />
129
+ </label>
130
+ <label className="space-y-1">
131
+ <span className="text-xs text-muted-foreground">Confirmations</span>
132
+ <input
133
+ type="number"
134
+ className="w-full rounded-md border bg-background px-3 py-1.5 text-sm"
135
+ value={form.confirmations}
136
+ onChange={(e) => setForm({ ...form, confirmations: Number(e.target.value) })}
137
+ />
138
+ </label>
139
+ </div>
140
+ <div className="flex gap-2">
141
+ <Button type="submit" size="sm" disabled={saving}>
142
+ {saving ? "Saving..." : "Save"}
143
+ </Button>
144
+ <Button type="button" variant="ghost" size="sm" onClick={() => setOpen(false)}>
145
+ Cancel
146
+ </Button>
147
+ </div>
148
+ </form>
149
+ );
150
+ }
151
+
152
+ export default function AdminPaymentMethodsPage() {
153
+ const [methods, setMethods] = useState<PaymentMethodAdmin[]>([]);
154
+ const [loading, setLoading] = useState(true);
155
+
156
+ const load = useCallback(async () => {
157
+ setLoading(true);
158
+ try {
159
+ const m = await adminListPaymentMethods();
160
+ setMethods(m);
161
+ } catch {
162
+ // Failed to load
163
+ } finally {
164
+ setLoading(false);
165
+ }
166
+ }, []);
167
+
168
+ useEffect(() => {
169
+ load();
170
+ }, [load]);
171
+
172
+ async function handleToggle(id: string, enabled: boolean) {
173
+ await adminTogglePaymentMethod(id, enabled);
174
+ load();
175
+ }
176
+
177
+ return (
178
+ <div className="space-y-6">
179
+ <div className="flex items-center justify-between">
180
+ <h1 className="text-2xl font-bold">Payment Methods</h1>
181
+ <AddMethodForm onSaved={load} />
182
+ </div>
183
+
184
+ <Card>
185
+ <CardHeader>
186
+ <CardTitle>Configured Methods</CardTitle>
187
+ </CardHeader>
188
+ <CardContent>
189
+ {loading ? (
190
+ <p className="text-sm text-muted-foreground">Loading...</p>
191
+ ) : methods.length === 0 ? (
192
+ <p className="text-sm text-muted-foreground">No payment methods configured.</p>
193
+ ) : (
194
+ <div className="overflow-x-auto">
195
+ <table className="w-full text-sm">
196
+ <thead>
197
+ <tr className="border-b text-left text-muted-foreground">
198
+ <th className="pb-2 pr-4">Token</th>
199
+ <th className="pb-2 pr-4">Chain</th>
200
+ <th className="pb-2 pr-4">Type</th>
201
+ <th className="pb-2 pr-4">Contract</th>
202
+ <th className="pb-2 pr-4">Decimals</th>
203
+ <th className="pb-2 pr-4">Order</th>
204
+ <th className="pb-2 pr-4">Status</th>
205
+ <th className="pb-2">Actions</th>
206
+ </tr>
207
+ </thead>
208
+ <tbody>
209
+ {methods.map((m) => (
210
+ <tr key={m.id} className="border-b last:border-0">
211
+ <td className="py-2 pr-4 font-medium">{m.token}</td>
212
+ <td className="py-2 pr-4">{m.chain}</td>
213
+ <td className="py-2 pr-4">{m.type}</td>
214
+ <td className="py-2 pr-4 font-mono text-xs">
215
+ {m.contractAddress ? `${m.contractAddress.slice(0, 10)}...` : "—"}
216
+ </td>
217
+ <td className="py-2 pr-4">{m.decimals}</td>
218
+ <td className="py-2 pr-4">{m.displayOrder}</td>
219
+ <td className="py-2 pr-4">
220
+ <span
221
+ className={
222
+ m.enabled
223
+ ? "text-xs font-medium text-green-500"
224
+ : "text-xs font-medium text-red-500"
225
+ }
226
+ >
227
+ {m.enabled ? "Enabled" : "Disabled"}
228
+ </span>
229
+ </td>
230
+ <td className="py-2">
231
+ <Button
232
+ variant="ghost"
233
+ size="sm"
234
+ onClick={() => handleToggle(m.id, !m.enabled)}
235
+ >
236
+ {m.enabled ? "Disable" : "Enable"}
237
+ </Button>
238
+ </td>
239
+ </tr>
240
+ ))}
241
+ </tbody>
242
+ </table>
243
+ </div>
244
+ )}
245
+ </CardContent>
246
+ </Card>
247
+ </div>
248
+ );
249
+ }
@@ -1,49 +1,28 @@
1
1
  "use client";
2
2
 
3
3
  import { motion } from "framer-motion";
4
- import { Bitcoin, Check, CircleDollarSign, Copy } from "lucide-react";
5
- import { useCallback, useEffect, useRef, useState } from "react";
4
+ import { Check, CircleDollarSign, Copy } from "lucide-react";
5
+ import { useCallback, useEffect, useState } from "react";
6
6
  import { Badge } from "@/components/ui/badge";
7
7
  import { Button } from "@/components/ui/button";
8
8
  import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
9
9
  import {
10
- createCryptoCheckout,
11
- createEthCheckout,
12
- createStablecoinCheckout,
13
- type EthCheckoutResult,
14
- type StablecoinCheckoutResult,
10
+ type CheckoutResult,
11
+ createCheckout,
12
+ getSupportedPaymentMethods,
13
+ type SupportedPaymentMethod,
15
14
  } from "@/lib/api";
16
15
  import { cn } from "@/lib/utils";
17
- import { isAllowedRedirectUrl } from "@/lib/validate-redirect-url";
18
16
 
19
- const CRYPTO_AMOUNTS = [
17
+ const AMOUNTS = [
20
18
  { value: 10, label: "$10" },
21
19
  { value: 25, label: "$25" },
22
20
  { value: 50, label: "$50" },
23
21
  { value: 100, label: "$100" },
24
22
  ];
25
23
 
26
- const STABLECOIN_TOKENS = [
27
- { token: "USDC", label: "USDC", chain: "base", chainLabel: "Base" },
28
- { token: "USDT", label: "USDT", chain: "base", chainLabel: "Base" },
29
- { token: "DAI", label: "DAI", chain: "base", chainLabel: "Base" },
30
- ];
31
-
32
- type PaymentMethod = "btc" | "stablecoin" | "eth";
33
-
34
- /** Format wei (BigInt string) to ETH string without Number precision loss. */
35
- function formatWeiToEth(weiStr: string): string {
36
- const wei = BigInt(weiStr);
37
- const divisor = BigInt("1000000000000000000");
38
- const whole = wei / divisor;
39
- const frac = wei % divisor;
40
- const fracStr = frac.toString().padStart(18, "0").slice(0, 6);
41
- return `${whole}.${fracStr}`;
42
- }
43
-
44
24
  function CopyButton({ text }: { text: string }) {
45
25
  const [copied, setCopied] = useState(false);
46
-
47
26
  const handleCopy = useCallback(() => {
48
27
  navigator.clipboard.writeText(text);
49
28
  setCopied(true);
@@ -57,128 +36,35 @@ function CopyButton({ text }: { text: string }) {
57
36
  );
58
37
  }
59
38
 
60
- function StablecoinDeposit({
61
- checkout,
62
- onReset,
63
- }: {
64
- checkout: StablecoinCheckoutResult;
65
- onReset: () => void;
66
- }) {
67
- const [confirmed, _setConfirmed] = useState(false);
68
- const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
69
-
70
- useEffect(() => {
71
- // TODO: poll charge status via tRPC query when backend endpoint exists
72
- // For now, show the deposit address and let the user confirm manually
73
- return () => {
74
- if (pollRef.current) clearInterval(pollRef.current);
75
- };
76
- }, []);
77
-
78
- const displayAmount = `${checkout.amountUsd} ${checkout.token}`;
79
-
80
- if (confirmed) {
81
- return (
82
- <motion.div
83
- initial={{ opacity: 0, scale: 0.95 }}
84
- animate={{ opacity: 1, scale: 1 }}
85
- className="rounded-md border border-primary/25 bg-primary/5 p-4 text-center"
86
- >
87
- <Check className="mx-auto h-8 w-8 text-primary" />
88
- <p className="mt-2 text-sm font-medium">Payment detected. Credits will appear shortly.</p>
89
- </motion.div>
90
- );
91
- }
92
-
93
- return (
94
- <motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} className="space-y-4">
95
- <div className="rounded-md border p-4 space-y-3">
96
- <div className="flex items-center justify-between">
97
- <p className="text-sm text-muted-foreground">
98
- Send exactly{" "}
99
- <span className="font-mono font-bold text-foreground">{displayAmount}</span> to:
100
- </p>
101
- <Badge variant="outline" className="text-xs">
102
- {checkout.token} on {checkout.chain}
103
- </Badge>
104
- </div>
105
-
106
- <div className="flex items-center gap-2 rounded-md bg-muted/50 px-3 py-2">
107
- <code className="flex-1 text-xs font-mono break-all text-foreground">
108
- {checkout.depositAddress}
109
- </code>
110
- <CopyButton text={checkout.depositAddress} />
111
- </div>
112
-
113
- <p className="text-xs text-muted-foreground">
114
- Only send {checkout.token} on the {checkout.chain} network. Other tokens or chains will be
115
- lost.
116
- </p>
117
- </div>
118
-
119
- <div className="flex gap-2">
120
- <Button variant="ghost" size="sm" onClick={onReset}>
121
- Cancel
122
- </Button>
123
- </div>
124
- </motion.div>
125
- );
126
- }
127
-
128
39
  export function BuyCryptoCreditPanel() {
129
- const [method, setMethod] = useState<PaymentMethod>("stablecoin");
130
- const [selected, setSelected] = useState<number | null>(null);
131
- const [selectedToken, setSelectedToken] = useState(STABLECOIN_TOKENS[0]);
40
+ const [methods, setMethods] = useState<SupportedPaymentMethod[]>([]);
41
+ const [selectedMethod, setSelectedMethod] = useState<SupportedPaymentMethod | null>(null);
42
+ const [selectedAmount, setSelectedAmount] = useState<number | null>(null);
132
43
  const [loading, setLoading] = useState(false);
133
44
  const [error, setError] = useState<string | null>(null);
134
- const [stablecoinCheckout, setStablecoinCheckout] = useState<StablecoinCheckoutResult | null>(
135
- null,
136
- );
137
- const [ethCheckout, setEthCheckout] = useState<EthCheckoutResult | null>(null);
138
-
139
- async function handleEthCheckout() {
140
- if (selected === null) return;
141
- setLoading(true);
142
- setError(null);
143
- try {
144
- const result = await createEthCheckout(selected, "base");
145
- setEthCheckout(result);
146
- } catch {
147
- setError("Checkout failed. Please try again.");
148
- } finally {
149
- setLoading(false);
150
- }
151
- }
45
+ const [checkout, setCheckout] = useState<CheckoutResult | null>(null);
152
46
 
153
- async function handleBtcCheckout() {
154
- if (selected === null) return;
155
- setLoading(true);
156
- setError(null);
157
- try {
158
- const { url } = await createCryptoCheckout(selected);
159
- if (!isAllowedRedirectUrl(url)) {
160
- setError("Unexpected checkout URL. Please contact support.");
161
- setLoading(false);
162
- return;
163
- }
164
- window.location.href = url;
165
- } catch {
166
- setError("Checkout failed. Please try again.");
167
- setLoading(false);
168
- }
169
- }
47
+ // Fetch available payment methods from backend on mount
48
+ useEffect(() => {
49
+ getSupportedPaymentMethods()
50
+ .then((m) => {
51
+ if (m.length > 0) {
52
+ setMethods(m);
53
+ setSelectedMethod(m[0]);
54
+ }
55
+ })
56
+ .catch(() => {
57
+ // Backend unavailable — panel stays empty
58
+ });
59
+ }, []);
170
60
 
171
- async function handleStablecoinCheckout() {
172
- if (selected === null) return;
61
+ async function handleCheckout() {
62
+ if (selectedAmount === null || !selectedMethod) return;
173
63
  setLoading(true);
174
64
  setError(null);
175
65
  try {
176
- const result = await createStablecoinCheckout(
177
- selected,
178
- selectedToken.token,
179
- selectedToken.chain,
180
- );
181
- setStablecoinCheckout(result);
66
+ const result = await createCheckout(selectedMethod.id, selectedAmount);
67
+ setCheckout(result);
182
68
  } catch {
183
69
  setError("Checkout failed. Please try again.");
184
70
  } finally {
@@ -187,12 +73,13 @@ export function BuyCryptoCreditPanel() {
187
73
  }
188
74
 
189
75
  function handleReset() {
190
- setStablecoinCheckout(null);
191
- setEthCheckout(null);
192
- setSelected(null);
76
+ setCheckout(null);
77
+ setSelectedAmount(null);
193
78
  setError(null);
194
79
  }
195
80
 
81
+ if (methods.length === 0) return null;
82
+
196
83
  return (
197
84
  <motion.div
198
85
  initial={{ opacity: 0, y: 8 }}
@@ -202,65 +89,32 @@ export function BuyCryptoCreditPanel() {
202
89
  <Card>
203
90
  <CardHeader>
204
91
  <CardTitle className="flex items-center gap-2">
205
- {method === "btc" ? (
206
- <Bitcoin className="h-4 w-4 text-amber-500" />
207
- ) : method === "eth" ? (
208
- <CircleDollarSign className="h-4 w-4 text-indigo-500" />
209
- ) : (
210
- <CircleDollarSign className="h-4 w-4 text-blue-500" />
211
- )}
92
+ <CircleDollarSign className="h-4 w-4 text-primary" />
212
93
  Pay with Crypto
213
94
  </CardTitle>
214
- <div className="flex gap-2 pt-1">
215
- <button
216
- type="button"
217
- onClick={() => {
218
- setMethod("stablecoin");
219
- handleReset();
220
- }}
221
- className={cn(
222
- "rounded-full px-3 py-1 text-xs font-medium transition-colors",
223
- method === "stablecoin"
224
- ? "bg-primary/10 text-primary"
225
- : "text-muted-foreground hover:text-foreground",
226
- )}
227
- >
228
- Stablecoin
229
- </button>
230
- <button
231
- type="button"
232
- onClick={() => {
233
- setMethod("eth");
234
- handleReset();
235
- }}
236
- className={cn(
237
- "rounded-full px-3 py-1 text-xs font-medium transition-colors",
238
- method === "eth"
239
- ? "bg-indigo-500/10 text-indigo-500"
240
- : "text-muted-foreground hover:text-foreground",
241
- )}
242
- >
243
- ETH
244
- </button>
245
- <button
246
- type="button"
247
- onClick={() => {
248
- setMethod("btc");
249
- handleReset();
250
- }}
251
- className={cn(
252
- "rounded-full px-3 py-1 text-xs font-medium transition-colors",
253
- method === "btc"
254
- ? "bg-amber-500/10 text-amber-500"
255
- : "text-muted-foreground hover:text-foreground",
256
- )}
257
- >
258
- BTC
259
- </button>
95
+ <div className="flex flex-wrap gap-2 pt-1">
96
+ {methods.map((m) => (
97
+ <button
98
+ key={m.id}
99
+ type="button"
100
+ onClick={() => {
101
+ setSelectedMethod(m);
102
+ handleReset();
103
+ }}
104
+ className={cn(
105
+ "rounded-full px-3 py-1 text-xs font-medium transition-colors",
106
+ selectedMethod?.id === m.id
107
+ ? "bg-primary/10 text-primary"
108
+ : "text-muted-foreground hover:text-foreground",
109
+ )}
110
+ >
111
+ {m.token}
112
+ </button>
113
+ ))}
260
114
  </div>
261
115
  </CardHeader>
262
116
  <CardContent className="space-y-4">
263
- {ethCheckout ? (
117
+ {checkout ? (
264
118
  <motion.div
265
119
  initial={{ opacity: 0, y: 8 }}
266
120
  animate={{ opacity: 1, y: 0 }}
@@ -269,67 +123,50 @@ export function BuyCryptoCreditPanel() {
269
123
  <div className="rounded-md border p-4 space-y-3">
270
124
  <div className="flex items-center justify-between">
271
125
  <p className="text-sm text-muted-foreground">
272
- Send approximately{" "}
126
+ Send{" "}
273
127
  <span className="font-mono font-bold text-foreground">
274
- {formatWeiToEth(ethCheckout.expectedWei)} ETH
128
+ {checkout.displayAmount}
275
129
  </span>{" "}
276
- (${ethCheckout.amountUsd}) to:
130
+ to:
277
131
  </p>
278
132
  <Badge variant="outline" className="text-xs">
279
- ETH on {ethCheckout.chain}
133
+ {checkout.token} on {checkout.chain}
280
134
  </Badge>
281
135
  </div>
282
136
  <div className="flex items-center gap-2 rounded-md bg-muted/50 px-3 py-2">
283
137
  <code className="flex-1 text-xs font-mono break-all text-foreground">
284
- {ethCheckout.depositAddress}
138
+ {checkout.depositAddress}
285
139
  </code>
286
- <CopyButton text={ethCheckout.depositAddress} />
140
+ <CopyButton text={checkout.depositAddress} />
287
141
  </div>
142
+ {checkout.priceCents && (
143
+ <p className="text-xs text-muted-foreground">
144
+ Price at checkout: ${(checkout.priceCents / 100).toFixed(2)} per{" "}
145
+ {checkout.token}
146
+ </p>
147
+ )}
288
148
  <p className="text-xs text-muted-foreground">
289
- ETH price at checkout: ${(ethCheckout.priceCents / 100).toFixed(2)}. Only send ETH
290
- on the {ethCheckout.chain} network.
149
+ Only send {checkout.token} on the {checkout.chain} network.
291
150
  </p>
292
151
  </div>
293
152
  <Button variant="ghost" size="sm" onClick={handleReset}>
294
153
  Cancel
295
154
  </Button>
296
155
  </motion.div>
297
- ) : stablecoinCheckout ? (
298
- <StablecoinDeposit checkout={stablecoinCheckout} onReset={handleReset} />
299
156
  ) : (
300
157
  <>
301
- {method === "stablecoin" && (
302
- <div className="flex gap-2">
303
- {STABLECOIN_TOKENS.map((t) => (
304
- <button
305
- key={`${t.token}:${t.chain}`}
306
- type="button"
307
- onClick={() => setSelectedToken(t)}
308
- className={cn(
309
- "rounded-md border px-3 py-1.5 text-xs font-medium transition-colors",
310
- selectedToken.token === t.token && selectedToken.chain === t.chain
311
- ? "border-primary bg-primary/5 text-primary"
312
- : "border-border text-muted-foreground hover:text-foreground",
313
- )}
314
- >
315
- {t.label}
316
- </button>
317
- ))}
318
- </div>
319
- )}
320
-
321
158
  <div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
322
- {CRYPTO_AMOUNTS.map((amt) => (
159
+ {AMOUNTS.map((amt) => (
323
160
  <motion.button
324
161
  key={amt.value}
325
162
  type="button"
326
- onClick={() => setSelected(amt.value)}
163
+ onClick={() => setSelectedAmount(amt.value)}
327
164
  whileHover={{ scale: 1.03 }}
328
165
  whileTap={{ scale: 0.98 }}
329
166
  transition={{ type: "spring", stiffness: 400, damping: 25 }}
330
167
  className={cn(
331
168
  "flex flex-col items-center gap-1 rounded-md border p-3 text-sm font-medium transition-colors hover:bg-accent",
332
- selected === amt.value
169
+ selectedAmount === amt.value
333
170
  ? "border-primary bg-primary/5 ring-1 ring-primary shadow-[0_0_15px_rgba(0,255,65,0.3)]"
334
171
  : "border-border",
335
172
  )}
@@ -342,24 +179,12 @@ export function BuyCryptoCreditPanel() {
342
179
  {error && <p className="text-sm text-destructive">{error}</p>}
343
180
 
344
181
  <Button
345
- onClick={
346
- method === "btc"
347
- ? handleBtcCheckout
348
- : method === "eth"
349
- ? handleEthCheckout
350
- : handleStablecoinCheckout
351
- }
352
- disabled={selected === null || loading}
182
+ onClick={handleCheckout}
183
+ disabled={selectedAmount === null || !selectedMethod || loading}
353
184
  variant="outline"
354
185
  className="w-full sm:w-auto"
355
186
  >
356
- {loading
357
- ? "Creating checkout..."
358
- : method === "btc"
359
- ? "Pay with BTC"
360
- : method === "eth"
361
- ? "Pay with ETH"
362
- : `Pay with ${selectedToken.label}`}
187
+ {loading ? "Creating checkout..." : `Pay with ${selectedMethod?.token ?? "Crypto"}`}
363
188
  </Button>
364
189
  </>
365
190
  )}
package/src/lib/api.ts CHANGED
@@ -1369,6 +1369,71 @@ export async function createEthCheckout(
1369
1369
  return trpcVanilla.billing.ethCheckout.mutate({ amountUsd, chain });
1370
1370
  }
1371
1371
 
1372
+ // --- Supported payment methods (runtime-configured) ---
1373
+
1374
+ export interface SupportedPaymentMethod {
1375
+ id: string;
1376
+ type: string;
1377
+ token: string;
1378
+ chain: string;
1379
+ displayName: string;
1380
+ decimals: number;
1381
+ displayOrder: number;
1382
+ }
1383
+
1384
+ export async function getSupportedPaymentMethods(): Promise<SupportedPaymentMethod[]> {
1385
+ return trpcVanilla.billing.supportedPaymentMethods.query(undefined);
1386
+ }
1387
+
1388
+ // --- Unified checkout (replaces stablecoin/eth/btc-specific endpoints) ---
1389
+
1390
+ export interface CheckoutResult {
1391
+ depositAddress: string;
1392
+ displayAmount: string;
1393
+ amountUsd: number;
1394
+ token: string;
1395
+ chain: string;
1396
+ referenceId: string;
1397
+ priceCents?: number;
1398
+ }
1399
+
1400
+ export async function createCheckout(methodId: string, amountUsd: number): Promise<CheckoutResult> {
1401
+ return trpcVanilla.billing.checkout.mutate({ methodId, amountUsd });
1402
+ }
1403
+
1404
+ // --- Admin payment method management ---
1405
+
1406
+ export interface PaymentMethodAdmin {
1407
+ id: string;
1408
+ type: string;
1409
+ token: string;
1410
+ chain: string;
1411
+ contractAddress: string | null;
1412
+ decimals: number;
1413
+ displayName: string;
1414
+ enabled: boolean;
1415
+ displayOrder: number;
1416
+ rpcUrl: string | null;
1417
+ confirmations: number;
1418
+ }
1419
+
1420
+ export async function adminListPaymentMethods(): Promise<PaymentMethodAdmin[]> {
1421
+ return trpcVanilla.billing.adminListPaymentMethods.query(undefined);
1422
+ }
1423
+
1424
+ export async function adminUpsertPaymentMethod(
1425
+ method: PaymentMethodAdmin,
1426
+ ): Promise<{ ok: boolean }> {
1427
+ return trpcVanilla.billing.adminUpsertPaymentMethod.mutate(method);
1428
+ }
1429
+
1430
+ export async function adminTogglePaymentMethod(
1431
+ id: string,
1432
+ enabled: boolean,
1433
+ ): Promise<{ ok: boolean }> {
1434
+ return trpcVanilla.billing.adminTogglePaymentMethod.mutate({ id, enabled });
1435
+ }
1436
+
1372
1437
  // --- Dividend types ---
1373
1438
 
1374
1439
  export interface DividendWalletStats {
@@ -115,6 +115,11 @@ type AppRouterRecord = {
115
115
  cryptoCheckout: AnyTRPCMutationProcedure;
116
116
  stablecoinCheckout: AnyTRPCMutationProcedure;
117
117
  ethCheckout: AnyTRPCMutationProcedure;
118
+ checkout: AnyTRPCMutationProcedure;
119
+ supportedPaymentMethods: AnyTRPCQueryProcedure;
120
+ adminListPaymentMethods: AnyTRPCQueryProcedure;
121
+ adminUpsertPaymentMethod: AnyTRPCMutationProcedure;
122
+ adminTogglePaymentMethod: AnyTRPCMutationProcedure;
118
123
  autoTopupSettings: AnyTRPCQueryProcedure;
119
124
  updateAutoTopupSettings: AnyTRPCMutationProcedure;
120
125
  accountStatus: AnyTRPCQueryProcedure;