@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 +1 -1
- package/src/__tests__/buy-crypto-credits-panel.test.tsx +74 -20
- package/src/__tests__/settings.test.tsx +9 -2
- package/src/app/admin/payment-methods/page.tsx +249 -0
- package/src/components/billing/buy-crypto-credits-panel.tsx +72 -247
- package/src/lib/api.ts +65 -0
- package/src/lib/trpc-types.ts +5 -0
package/package.json
CHANGED
|
@@ -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 } =
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
209
|
+
await user.click(screen.getByRole("button", { name: "Pay with BTC" }));
|
|
155
210
|
|
|
156
|
-
expect(await screen.findByText("
|
|
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
|
|
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: () => ({
|
|
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 {
|
|
5
|
-
import { useCallback, useEffect,
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
type
|
|
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
|
|
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 [
|
|
130
|
-
const [
|
|
131
|
-
const [
|
|
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 [
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
|
172
|
-
if (
|
|
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
|
|
177
|
-
|
|
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
|
-
|
|
191
|
-
|
|
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
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
{
|
|
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
|
|
126
|
+
Send{" "}
|
|
273
127
|
<span className="font-mono font-bold text-foreground">
|
|
274
|
-
{
|
|
128
|
+
{checkout.displayAmount}
|
|
275
129
|
</span>{" "}
|
|
276
|
-
|
|
130
|
+
to:
|
|
277
131
|
</p>
|
|
278
132
|
<Badge variant="outline" className="text-xs">
|
|
279
|
-
|
|
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
|
-
{
|
|
138
|
+
{checkout.depositAddress}
|
|
285
139
|
</code>
|
|
286
|
-
<CopyButton text={
|
|
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
|
-
|
|
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
|
-
{
|
|
159
|
+
{AMOUNTS.map((amt) => (
|
|
323
160
|
<motion.button
|
|
324
161
|
key={amt.value}
|
|
325
162
|
type="button"
|
|
326
|
-
onClick={() =>
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
package/src/lib/trpc-types.ts
CHANGED
|
@@ -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;
|