@wopr-network/platform-ui-core 1.25.0 → 1.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/__tests__/amount-selector.test.tsx +36 -0
- package/src/__tests__/billing.test.tsx +2 -2
- package/src/__tests__/confirmation-tracker.test.tsx +57 -0
- package/src/__tests__/crypto-checkout.test.tsx +79 -0
- package/src/__tests__/deposit-view.test.tsx +43 -0
- package/src/__tests__/payment-method-picker.test.tsx +88 -0
- package/src/app/(dashboard)/billing/credits/page.tsx +2 -2
- package/src/app/(dashboard)/onboarding/page.tsx +0 -1
- package/src/app/(dashboard)/settings/profile/page.tsx +1 -0
- package/src/app/admin/pool-config/page.tsx +5 -0
- package/src/components/admin/admin-nav.tsx +1 -0
- package/src/components/admin/pool-config-dashboard.tsx +139 -0
- package/src/components/billing/amount-selector.tsx +66 -0
- package/src/components/billing/buy-crypto-credits-panel.tsx +1 -302
- package/src/components/billing/confirmation-tracker.tsx +92 -0
- package/src/components/billing/crypto-checkout.tsx +185 -0
- package/src/components/billing/deposit-view.tsx +90 -0
- package/src/components/billing/payment-method-picker.tsx +129 -0
- package/src/lib/admin-pool-api.ts +15 -0
- package/src/lib/trpc-types.ts +2 -0
|
@@ -1,304 +1,3 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
import { Check, CircleDollarSign, Copy } from "lucide-react";
|
|
5
|
-
import { useCallback, useEffect, useState } from "react";
|
|
6
|
-
import { Badge } from "@/components/ui/badge";
|
|
7
|
-
import { Button } from "@/components/ui/button";
|
|
8
|
-
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
9
|
-
import {
|
|
10
|
-
type CheckoutResult,
|
|
11
|
-
createCheckout,
|
|
12
|
-
getChargeStatus,
|
|
13
|
-
getSupportedPaymentMethods,
|
|
14
|
-
type SupportedPaymentMethod,
|
|
15
|
-
} from "@/lib/api";
|
|
16
|
-
import { cn } from "@/lib/utils";
|
|
17
|
-
|
|
18
|
-
type PaymentProgress = {
|
|
19
|
-
status: "waiting" | "partial" | "confirming" | "credited" | "expired" | "failed";
|
|
20
|
-
amountExpectedCents: number;
|
|
21
|
-
amountReceivedCents: number;
|
|
22
|
-
confirmations: number;
|
|
23
|
-
confirmationsRequired: number;
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
const AMOUNTS = [
|
|
27
|
-
{ value: 10, label: "$10" },
|
|
28
|
-
{ value: 25, label: "$25" },
|
|
29
|
-
{ value: 50, label: "$50" },
|
|
30
|
-
{ value: 100, label: "$100" },
|
|
31
|
-
];
|
|
32
|
-
|
|
33
|
-
function CopyButton({ text }: { text: string }) {
|
|
34
|
-
const [copied, setCopied] = useState(false);
|
|
35
|
-
const handleCopy = useCallback(() => {
|
|
36
|
-
navigator.clipboard.writeText(text);
|
|
37
|
-
setCopied(true);
|
|
38
|
-
setTimeout(() => setCopied(false), 2000);
|
|
39
|
-
}, [text]);
|
|
40
|
-
|
|
41
|
-
return (
|
|
42
|
-
<Button variant="ghost" size="sm" onClick={handleCopy} className="h-7 px-2">
|
|
43
|
-
{copied ? <Check className="h-3.5 w-3.5 text-primary" /> : <Copy className="h-3.5 w-3.5" />}
|
|
44
|
-
</Button>
|
|
45
|
-
);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export function BuyCryptoCreditPanel() {
|
|
49
|
-
const [methods, setMethods] = useState<SupportedPaymentMethod[]>([]);
|
|
50
|
-
const [selectedMethod, setSelectedMethod] = useState<SupportedPaymentMethod | null>(null);
|
|
51
|
-
const [selectedAmount, setSelectedAmount] = useState<number | null>(null);
|
|
52
|
-
const [loading, setLoading] = useState(false);
|
|
53
|
-
const [error, setError] = useState<string | null>(null);
|
|
54
|
-
const [checkout, setCheckout] = useState<CheckoutResult | null>(null);
|
|
55
|
-
const [paymentProgress, setPaymentProgress] = useState<PaymentProgress | null>(null);
|
|
56
|
-
|
|
57
|
-
// Poll charge status after checkout
|
|
58
|
-
useEffect(() => {
|
|
59
|
-
if (!checkout?.referenceId) {
|
|
60
|
-
setPaymentProgress(null);
|
|
61
|
-
return;
|
|
62
|
-
}
|
|
63
|
-
setPaymentProgress({
|
|
64
|
-
status: "waiting",
|
|
65
|
-
amountExpectedCents: 0,
|
|
66
|
-
amountReceivedCents: 0,
|
|
67
|
-
confirmations: 0,
|
|
68
|
-
confirmationsRequired: 0,
|
|
69
|
-
});
|
|
70
|
-
const interval = setInterval(async () => {
|
|
71
|
-
try {
|
|
72
|
-
const res = await getChargeStatus(checkout.referenceId);
|
|
73
|
-
let status: PaymentProgress["status"] = "waiting";
|
|
74
|
-
if (res.credited) {
|
|
75
|
-
status = "credited";
|
|
76
|
-
} else if (res.status === "expired" || res.status === "failed") {
|
|
77
|
-
status = res.status;
|
|
78
|
-
} else if (
|
|
79
|
-
res.amountReceivedCents >= res.amountExpectedCents &&
|
|
80
|
-
res.amountReceivedCents > 0
|
|
81
|
-
) {
|
|
82
|
-
status = "confirming";
|
|
83
|
-
} else if (res.amountReceivedCents > 0) {
|
|
84
|
-
status = "partial";
|
|
85
|
-
}
|
|
86
|
-
setPaymentProgress({
|
|
87
|
-
status,
|
|
88
|
-
amountExpectedCents: res.amountExpectedCents,
|
|
89
|
-
amountReceivedCents: res.amountReceivedCents,
|
|
90
|
-
confirmations: res.confirmations,
|
|
91
|
-
confirmationsRequired: res.confirmationsRequired,
|
|
92
|
-
});
|
|
93
|
-
if (status === "credited" || status === "expired" || status === "failed") {
|
|
94
|
-
clearInterval(interval);
|
|
95
|
-
}
|
|
96
|
-
} catch {
|
|
97
|
-
// Ignore poll errors
|
|
98
|
-
}
|
|
99
|
-
}, 5000);
|
|
100
|
-
return () => clearInterval(interval);
|
|
101
|
-
}, [checkout?.referenceId]);
|
|
102
|
-
|
|
103
|
-
// Fetch available payment methods from backend on mount
|
|
104
|
-
useEffect(() => {
|
|
105
|
-
getSupportedPaymentMethods()
|
|
106
|
-
.then((m) => {
|
|
107
|
-
if (m.length > 0) {
|
|
108
|
-
setMethods(m);
|
|
109
|
-
setSelectedMethod(m[0]);
|
|
110
|
-
}
|
|
111
|
-
})
|
|
112
|
-
.catch(() => {
|
|
113
|
-
// Backend unavailable — panel stays empty
|
|
114
|
-
});
|
|
115
|
-
}, []);
|
|
116
|
-
|
|
117
|
-
async function handleCheckout() {
|
|
118
|
-
if (selectedAmount === null || !selectedMethod) return;
|
|
119
|
-
setLoading(true);
|
|
120
|
-
setError(null);
|
|
121
|
-
try {
|
|
122
|
-
const result = await createCheckout(selectedMethod.id, selectedAmount);
|
|
123
|
-
setCheckout(result);
|
|
124
|
-
} catch {
|
|
125
|
-
setError("Checkout failed. Please try again.");
|
|
126
|
-
} finally {
|
|
127
|
-
setLoading(false);
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
function handleReset() {
|
|
132
|
-
setCheckout(null);
|
|
133
|
-
setSelectedAmount(null);
|
|
134
|
-
setError(null);
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
if (methods.length === 0) return null;
|
|
138
|
-
|
|
139
|
-
return (
|
|
140
|
-
<motion.div
|
|
141
|
-
initial={{ opacity: 0, y: 8 }}
|
|
142
|
-
animate={{ opacity: 1, y: 0 }}
|
|
143
|
-
transition={{ duration: 0.4, ease: "easeOut", delay: 0.1 }}
|
|
144
|
-
>
|
|
145
|
-
<Card>
|
|
146
|
-
<CardHeader>
|
|
147
|
-
<CardTitle className="flex items-center gap-2">
|
|
148
|
-
<CircleDollarSign className="h-4 w-4 text-primary" />
|
|
149
|
-
Pay with Crypto
|
|
150
|
-
</CardTitle>
|
|
151
|
-
<div className="flex flex-wrap gap-2 pt-1">
|
|
152
|
-
{methods.map((m) => (
|
|
153
|
-
<button
|
|
154
|
-
key={m.id}
|
|
155
|
-
type="button"
|
|
156
|
-
onClick={() => {
|
|
157
|
-
setSelectedMethod(m);
|
|
158
|
-
handleReset();
|
|
159
|
-
}}
|
|
160
|
-
className={cn(
|
|
161
|
-
"flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium transition-colors",
|
|
162
|
-
selectedMethod?.id === m.id
|
|
163
|
-
? "bg-primary/10 text-primary"
|
|
164
|
-
: "text-muted-foreground hover:text-foreground",
|
|
165
|
-
)}
|
|
166
|
-
>
|
|
167
|
-
{/* biome-ignore lint/performance/noImgElement: external dynamic URLs from backend, not static assets */}
|
|
168
|
-
<img
|
|
169
|
-
src={m.iconUrl}
|
|
170
|
-
alt={m.token}
|
|
171
|
-
className="h-4 w-4"
|
|
172
|
-
loading="lazy"
|
|
173
|
-
onError={(e) => {
|
|
174
|
-
e.currentTarget.style.display = "none";
|
|
175
|
-
}}
|
|
176
|
-
/>
|
|
177
|
-
{m.token}
|
|
178
|
-
</button>
|
|
179
|
-
))}
|
|
180
|
-
</div>
|
|
181
|
-
</CardHeader>
|
|
182
|
-
<CardContent className="space-y-4">
|
|
183
|
-
{checkout ? (
|
|
184
|
-
<motion.div
|
|
185
|
-
initial={{ opacity: 0, y: 8 }}
|
|
186
|
-
animate={{ opacity: 1, y: 0 }}
|
|
187
|
-
className="space-y-4"
|
|
188
|
-
>
|
|
189
|
-
<div className="rounded-md border p-4 space-y-3">
|
|
190
|
-
<div className="flex items-center justify-between">
|
|
191
|
-
<p className="text-sm text-muted-foreground">
|
|
192
|
-
Send{" "}
|
|
193
|
-
<span className="font-mono font-bold text-foreground">
|
|
194
|
-
{checkout.displayAmount}
|
|
195
|
-
</span>{" "}
|
|
196
|
-
to:
|
|
197
|
-
</p>
|
|
198
|
-
<Badge variant="outline" className="text-xs">
|
|
199
|
-
{checkout.token} on {checkout.chain}
|
|
200
|
-
</Badge>
|
|
201
|
-
</div>
|
|
202
|
-
<div className="flex items-center gap-2 rounded-md bg-muted/50 px-3 py-2">
|
|
203
|
-
<code className="flex-1 text-xs font-mono break-all text-foreground">
|
|
204
|
-
{checkout.depositAddress}
|
|
205
|
-
</code>
|
|
206
|
-
<CopyButton text={checkout.depositAddress} />
|
|
207
|
-
</div>
|
|
208
|
-
{checkout.priceCents && (
|
|
209
|
-
<p className="text-xs text-muted-foreground">
|
|
210
|
-
Price at checkout: ${(checkout.priceCents / 100).toFixed(2)} per{" "}
|
|
211
|
-
{checkout.token}
|
|
212
|
-
</p>
|
|
213
|
-
)}
|
|
214
|
-
<p className="text-xs text-muted-foreground">
|
|
215
|
-
Only send {checkout.token} on the {checkout.chain} network.
|
|
216
|
-
</p>
|
|
217
|
-
{paymentProgress?.status === "waiting" && (
|
|
218
|
-
<p className="text-xs text-yellow-500 animate-pulse">Waiting for payment...</p>
|
|
219
|
-
)}
|
|
220
|
-
{paymentProgress?.status === "partial" && (
|
|
221
|
-
<p className="text-xs text-blue-500">
|
|
222
|
-
Received ${(paymentProgress.amountReceivedCents / 100).toFixed(2)} of $
|
|
223
|
-
{(paymentProgress.amountExpectedCents / 100).toFixed(2)} — send $
|
|
224
|
-
{(
|
|
225
|
-
(paymentProgress.amountExpectedCents - paymentProgress.amountReceivedCents) /
|
|
226
|
-
100
|
|
227
|
-
).toFixed(2)}{" "}
|
|
228
|
-
more
|
|
229
|
-
</p>
|
|
230
|
-
)}
|
|
231
|
-
{paymentProgress?.status === "confirming" && (
|
|
232
|
-
<p className="text-xs text-blue-500">
|
|
233
|
-
Payment received. Confirming ({paymentProgress.confirmations} of{" "}
|
|
234
|
-
{paymentProgress.confirmationsRequired})...
|
|
235
|
-
</p>
|
|
236
|
-
)}
|
|
237
|
-
{paymentProgress?.status === "credited" && (
|
|
238
|
-
<p className="text-xs text-green-500 font-medium">
|
|
239
|
-
Payment confirmed! Credits added.
|
|
240
|
-
</p>
|
|
241
|
-
)}
|
|
242
|
-
{paymentProgress?.status === "expired" && (
|
|
243
|
-
<p className="text-xs text-red-500">Payment expired.</p>
|
|
244
|
-
)}
|
|
245
|
-
{paymentProgress?.status === "failed" && (
|
|
246
|
-
<p className="text-xs text-red-500">Payment failed.</p>
|
|
247
|
-
)}
|
|
248
|
-
</div>
|
|
249
|
-
{paymentProgress?.status === "credited" ? (
|
|
250
|
-
<Button variant="outline" size="sm" onClick={handleReset}>
|
|
251
|
-
Done
|
|
252
|
-
</Button>
|
|
253
|
-
) : paymentProgress?.status === "expired" || paymentProgress?.status === "failed" ? (
|
|
254
|
-
<div className="flex gap-2">
|
|
255
|
-
<Button variant="outline" size="sm" onClick={handleReset}>
|
|
256
|
-
Try Again
|
|
257
|
-
</Button>
|
|
258
|
-
</div>
|
|
259
|
-
) : (
|
|
260
|
-
<Button variant="ghost" size="sm" onClick={handleReset}>
|
|
261
|
-
Cancel
|
|
262
|
-
</Button>
|
|
263
|
-
)}
|
|
264
|
-
</motion.div>
|
|
265
|
-
) : (
|
|
266
|
-
<>
|
|
267
|
-
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
|
268
|
-
{AMOUNTS.map((amt) => (
|
|
269
|
-
<motion.button
|
|
270
|
-
key={amt.value}
|
|
271
|
-
type="button"
|
|
272
|
-
onClick={() => setSelectedAmount(amt.value)}
|
|
273
|
-
whileHover={{ scale: 1.03 }}
|
|
274
|
-
whileTap={{ scale: 0.98 }}
|
|
275
|
-
transition={{ type: "spring", stiffness: 400, damping: 25 }}
|
|
276
|
-
className={cn(
|
|
277
|
-
"flex flex-col items-center gap-1 rounded-md border p-3 text-sm font-medium transition-colors hover:bg-accent",
|
|
278
|
-
selectedAmount === amt.value
|
|
279
|
-
? "border-primary bg-primary/5 ring-1 ring-primary shadow-[0_0_15px_rgba(0,255,65,0.3)]"
|
|
280
|
-
: "border-border",
|
|
281
|
-
)}
|
|
282
|
-
>
|
|
283
|
-
<span className="text-lg font-bold">{amt.label}</span>
|
|
284
|
-
</motion.button>
|
|
285
|
-
))}
|
|
286
|
-
</div>
|
|
287
|
-
|
|
288
|
-
{error && <p className="text-sm text-destructive">{error}</p>}
|
|
289
|
-
|
|
290
|
-
<Button
|
|
291
|
-
onClick={handleCheckout}
|
|
292
|
-
disabled={selectedAmount === null || !selectedMethod || loading}
|
|
293
|
-
variant="outline"
|
|
294
|
-
className="w-full sm:w-auto"
|
|
295
|
-
>
|
|
296
|
-
{loading ? "Creating checkout..." : `Pay with ${selectedMethod?.token ?? "Crypto"}`}
|
|
297
|
-
</Button>
|
|
298
|
-
</>
|
|
299
|
-
)}
|
|
300
|
-
</CardContent>
|
|
301
|
-
</Card>
|
|
302
|
-
</motion.div>
|
|
303
|
-
);
|
|
304
|
-
}
|
|
3
|
+
export { CryptoCheckout as BuyCryptoCreditPanel } from "./crypto-checkout";
|
|
@@ -0,0 +1,92 @@
|
|
|
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 ? <Check className="h-2.5 w-2.5" /> : detected ? <span>·</span> : null}
|
|
72
|
+
</div>
|
|
73
|
+
<span className={`text-xs ${detected ? "text-foreground" : "text-muted-foreground/50"}`}>
|
|
74
|
+
{credited ? "Confirmed" : "Confirming on chain"}
|
|
75
|
+
</span>
|
|
76
|
+
</div>
|
|
77
|
+
<div className="flex items-center gap-2">
|
|
78
|
+
<div
|
|
79
|
+
className={`flex h-4 w-4 items-center justify-center rounded-full text-[10px] ${credited ? "bg-green-500 text-white" : "bg-muted"}`}
|
|
80
|
+
>
|
|
81
|
+
{credited && <Check className="h-2.5 w-2.5" />}
|
|
82
|
+
</div>
|
|
83
|
+
<span className={`text-xs ${credited ? "text-foreground" : "text-muted-foreground/50"}`}>
|
|
84
|
+
Credits applied
|
|
85
|
+
</span>
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
|
|
89
|
+
{txHash && <p className="text-xs text-muted-foreground font-mono truncate">tx: {txHash}</p>}
|
|
90
|
+
</div>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
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
|
+
// silently fall back to empty methods list
|
|
37
|
+
});
|
|
38
|
+
}, []);
|
|
39
|
+
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
if (!checkout?.referenceId) return;
|
|
42
|
+
const interval = setInterval(async () => {
|
|
43
|
+
try {
|
|
44
|
+
const res = await getChargeStatus(checkout.referenceId);
|
|
45
|
+
setConfirmations(res.confirmations);
|
|
46
|
+
setConfirmationsRequired(res.confirmationsRequired);
|
|
47
|
+
if (res.credited) {
|
|
48
|
+
setStatus("credited");
|
|
49
|
+
setStep("confirming");
|
|
50
|
+
clearInterval(interval);
|
|
51
|
+
} else if (res.status === "expired" || res.status === "failed") {
|
|
52
|
+
setStatus(res.status as PaymentStatus);
|
|
53
|
+
clearInterval(interval);
|
|
54
|
+
} else if (
|
|
55
|
+
res.amountReceivedCents > 0 &&
|
|
56
|
+
res.amountReceivedCents >= res.amountExpectedCents
|
|
57
|
+
) {
|
|
58
|
+
setStatus("confirming");
|
|
59
|
+
setStep("confirming");
|
|
60
|
+
} else if (res.amountReceivedCents > 0) {
|
|
61
|
+
setStatus("partial");
|
|
62
|
+
}
|
|
63
|
+
} catch {
|
|
64
|
+
/* ignore poll errors */
|
|
65
|
+
}
|
|
66
|
+
}, 5000);
|
|
67
|
+
return () => clearInterval(interval);
|
|
68
|
+
}, [checkout?.referenceId]);
|
|
69
|
+
|
|
70
|
+
const handleAmount = useCallback((amt: number) => {
|
|
71
|
+
setAmountUsd(amt);
|
|
72
|
+
setStep("method");
|
|
73
|
+
}, []);
|
|
74
|
+
|
|
75
|
+
const handleMethod = useCallback(
|
|
76
|
+
async (method: SupportedPaymentMethod) => {
|
|
77
|
+
setLoading(true);
|
|
78
|
+
try {
|
|
79
|
+
const result = await createCheckout(method.id, amountUsd);
|
|
80
|
+
setCheckout(result);
|
|
81
|
+
setStatus("waiting");
|
|
82
|
+
setStep("deposit");
|
|
83
|
+
} catch {
|
|
84
|
+
// Stay on method step
|
|
85
|
+
} finally {
|
|
86
|
+
setLoading(false);
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
[amountUsd],
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
const handleReset = useCallback(() => {
|
|
93
|
+
setStep("amount");
|
|
94
|
+
setCheckout(null);
|
|
95
|
+
setStatus("waiting");
|
|
96
|
+
setAmountUsd(0);
|
|
97
|
+
setConfirmations(0);
|
|
98
|
+
}, []);
|
|
99
|
+
|
|
100
|
+
if (methods.length === 0) return null;
|
|
101
|
+
|
|
102
|
+
return (
|
|
103
|
+
<motion.div
|
|
104
|
+
initial={{ opacity: 0, y: 8 }}
|
|
105
|
+
animate={{ opacity: 1, y: 0 }}
|
|
106
|
+
transition={{ duration: 0.3 }}
|
|
107
|
+
>
|
|
108
|
+
<Card>
|
|
109
|
+
<CardHeader>
|
|
110
|
+
<CardTitle className="flex items-center gap-2">
|
|
111
|
+
<CircleDollarSign className="h-4 w-4 text-primary" />
|
|
112
|
+
Pay with Crypto
|
|
113
|
+
</CardTitle>
|
|
114
|
+
</CardHeader>
|
|
115
|
+
<CardContent>
|
|
116
|
+
<AnimatePresence mode="wait">
|
|
117
|
+
{step === "amount" && (
|
|
118
|
+
<motion.div
|
|
119
|
+
key="amount"
|
|
120
|
+
initial={{ opacity: 0, x: -20 }}
|
|
121
|
+
animate={{ opacity: 1, x: 0 }}
|
|
122
|
+
exit={{ opacity: 0, x: 20 }}
|
|
123
|
+
>
|
|
124
|
+
<AmountSelector onSelect={handleAmount} />
|
|
125
|
+
</motion.div>
|
|
126
|
+
)}
|
|
127
|
+
{step === "method" && (
|
|
128
|
+
<motion.div
|
|
129
|
+
key="method"
|
|
130
|
+
initial={{ opacity: 0, x: -20 }}
|
|
131
|
+
animate={{ opacity: 1, x: 0 }}
|
|
132
|
+
exit={{ opacity: 0, x: 20 }}
|
|
133
|
+
>
|
|
134
|
+
<PaymentMethodPicker
|
|
135
|
+
methods={methods}
|
|
136
|
+
onSelect={handleMethod}
|
|
137
|
+
onBack={() => setStep("amount")}
|
|
138
|
+
/>
|
|
139
|
+
{loading && (
|
|
140
|
+
<p className="mt-2 text-xs text-muted-foreground animate-pulse">
|
|
141
|
+
Creating checkout...
|
|
142
|
+
</p>
|
|
143
|
+
)}
|
|
144
|
+
</motion.div>
|
|
145
|
+
)}
|
|
146
|
+
{step === "deposit" && checkout && (
|
|
147
|
+
<motion.div
|
|
148
|
+
key="deposit"
|
|
149
|
+
initial={{ opacity: 0, x: -20 }}
|
|
150
|
+
animate={{ opacity: 1, x: 0 }}
|
|
151
|
+
exit={{ opacity: 0, x: 20 }}
|
|
152
|
+
>
|
|
153
|
+
<DepositView checkout={checkout} status={status} onBack={() => setStep("method")} />
|
|
154
|
+
</motion.div>
|
|
155
|
+
)}
|
|
156
|
+
{step === "confirming" && checkout && (
|
|
157
|
+
<motion.div
|
|
158
|
+
key="confirming"
|
|
159
|
+
initial={{ opacity: 0, x: -20 }}
|
|
160
|
+
animate={{ opacity: 1, x: 0 }}
|
|
161
|
+
exit={{ opacity: 0, x: 20 }}
|
|
162
|
+
>
|
|
163
|
+
<ConfirmationTracker
|
|
164
|
+
confirmations={confirmations}
|
|
165
|
+
confirmationsRequired={confirmationsRequired}
|
|
166
|
+
displayAmount={checkout.displayAmount}
|
|
167
|
+
credited={status === "credited"}
|
|
168
|
+
/>
|
|
169
|
+
{status === "credited" && (
|
|
170
|
+
<button
|
|
171
|
+
type="button"
|
|
172
|
+
onClick={handleReset}
|
|
173
|
+
className="mt-4 text-sm text-primary hover:underline"
|
|
174
|
+
>
|
|
175
|
+
Done — buy more credits
|
|
176
|
+
</button>
|
|
177
|
+
)}
|
|
178
|
+
</motion.div>
|
|
179
|
+
)}
|
|
180
|
+
</AnimatePresence>
|
|
181
|
+
</CardContent>
|
|
182
|
+
</Card>
|
|
183
|
+
</motion.div>
|
|
184
|
+
);
|
|
185
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Check, Copy } from "lucide-react";
|
|
4
|
+
import { QRCodeSVG } from "qrcode.react";
|
|
5
|
+
import { useCallback, useEffect, useState } from "react";
|
|
6
|
+
import { Button } from "@/components/ui/button";
|
|
7
|
+
import type { CheckoutResult } from "@/lib/api";
|
|
8
|
+
|
|
9
|
+
interface DepositViewProps {
|
|
10
|
+
checkout: CheckoutResult;
|
|
11
|
+
status: "waiting" | "partial" | "confirming" | "credited" | "expired" | "failed";
|
|
12
|
+
onBack: () => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function DepositView({ checkout, status, onBack }: DepositViewProps) {
|
|
16
|
+
const [copied, setCopied] = useState(false);
|
|
17
|
+
const [timeLeft, setTimeLeft] = useState(30 * 60);
|
|
18
|
+
|
|
19
|
+
const handleCopy = useCallback(() => {
|
|
20
|
+
navigator.clipboard.writeText(checkout.depositAddress);
|
|
21
|
+
setCopied(true);
|
|
22
|
+
setTimeout(() => setCopied(false), 2000);
|
|
23
|
+
}, [checkout.depositAddress]);
|
|
24
|
+
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
if (status !== "waiting") return;
|
|
27
|
+
const timer = setInterval(() => setTimeLeft((t) => Math.max(0, t - 1)), 1000);
|
|
28
|
+
return () => clearInterval(timer);
|
|
29
|
+
}, [status]);
|
|
30
|
+
|
|
31
|
+
const mins = Math.floor(timeLeft / 60);
|
|
32
|
+
const secs = timeLeft % 60;
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<div className="space-y-4 text-center">
|
|
36
|
+
<button
|
|
37
|
+
type="button"
|
|
38
|
+
onClick={onBack}
|
|
39
|
+
className="text-sm text-muted-foreground hover:text-foreground self-start"
|
|
40
|
+
>
|
|
41
|
+
← Back
|
|
42
|
+
</button>
|
|
43
|
+
<p className="text-sm text-muted-foreground">Send exactly</p>
|
|
44
|
+
<p className="text-2xl font-semibold">{checkout.displayAmount}</p>
|
|
45
|
+
<p className="text-xs text-muted-foreground">
|
|
46
|
+
on {checkout.chain} · ${checkout.amountUsd.toFixed(2)} USD
|
|
47
|
+
</p>
|
|
48
|
+
<div
|
|
49
|
+
className="mx-auto w-fit rounded-lg border border-border bg-background p-3"
|
|
50
|
+
aria-hidden="true"
|
|
51
|
+
>
|
|
52
|
+
<QRCodeSVG
|
|
53
|
+
value={checkout.depositAddress}
|
|
54
|
+
size={140}
|
|
55
|
+
bgColor="hsl(var(--background))"
|
|
56
|
+
fgColor="hsl(var(--foreground))"
|
|
57
|
+
/>
|
|
58
|
+
</div>
|
|
59
|
+
<div className="flex items-center gap-2 rounded-lg border border-border bg-muted/50 px-3 py-2">
|
|
60
|
+
<code className="flex-1 truncate text-xs font-mono">{checkout.depositAddress}</code>
|
|
61
|
+
<Button variant="ghost" size="sm" onClick={handleCopy} aria-label="Copy address">
|
|
62
|
+
{copied ? (
|
|
63
|
+
<Check className="h-3.5 w-3.5 text-primary" />
|
|
64
|
+
) : (
|
|
65
|
+
<Copy className="h-3.5 w-3.5" />
|
|
66
|
+
)}
|
|
67
|
+
</Button>
|
|
68
|
+
</div>
|
|
69
|
+
<div className="flex items-center justify-center gap-2 rounded-lg border border-border p-2">
|
|
70
|
+
{status === "waiting" && (
|
|
71
|
+
<>
|
|
72
|
+
<span className="h-2 w-2 animate-pulse rounded-full bg-yellow-500" />
|
|
73
|
+
<span className="text-xs text-yellow-500">Waiting for payment...</span>
|
|
74
|
+
<span className="text-xs text-muted-foreground">
|
|
75
|
+
· {mins}:{secs.toString().padStart(2, "0")}
|
|
76
|
+
</span>
|
|
77
|
+
</>
|
|
78
|
+
)}
|
|
79
|
+
{status === "partial" && (
|
|
80
|
+
<>
|
|
81
|
+
<span className="h-2 w-2 rounded-full bg-blue-500" />
|
|
82
|
+
<span className="text-xs text-blue-500">Partial payment received</span>
|
|
83
|
+
</>
|
|
84
|
+
)}
|
|
85
|
+
{status === "expired" && <span className="text-xs text-destructive">Payment expired</span>}
|
|
86
|
+
{status === "failed" && <span className="text-xs text-destructive">Payment failed</span>}
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
);
|
|
90
|
+
}
|