@wopr-network/platform-ui-core 1.24.1 → 1.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,185 @@
1
+ "use client";
2
+
3
+ import { ArrowDown, ArrowUp, Plus, Trash2 } from "lucide-react";
4
+ import { useState } from "react";
5
+ import { toast } from "sonner";
6
+ import { Button } from "@/components/ui/button";
7
+ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
8
+ import { Checkbox } from "@/components/ui/checkbox";
9
+ import { Input } from "@/components/ui/input";
10
+ import { Label } from "@/components/ui/label";
11
+ import { toUserMessage } from "@/lib/errors";
12
+
13
+ interface NavItem {
14
+ id: string;
15
+ label: string;
16
+ href: string;
17
+ icon: string | null;
18
+ sortOrder: number;
19
+ requiresRole: string | null;
20
+ enabled: boolean;
21
+ }
22
+
23
+ interface NavEditorProps {
24
+ initial: NavItem[];
25
+ onSave: (endpoint: string, data: unknown) => Promise<void>;
26
+ }
27
+
28
+ function newItem(sortOrder: number): NavItem {
29
+ return {
30
+ id: crypto.randomUUID(),
31
+ label: "",
32
+ href: "",
33
+ icon: null,
34
+ sortOrder,
35
+ requiresRole: null,
36
+ enabled: true,
37
+ };
38
+ }
39
+
40
+ export function NavEditor({ initial, onSave }: NavEditorProps) {
41
+ const [items, setItems] = useState<NavItem[]>(
42
+ [...initial].sort((a, b) => a.sortOrder - b.sortOrder),
43
+ );
44
+ const [saving, setSaving] = useState(false);
45
+
46
+ function update(id: string, patch: Partial<NavItem>) {
47
+ setItems((prev) => prev.map((item) => (item.id === id ? { ...item, ...patch } : item)));
48
+ }
49
+
50
+ function remove(id: string) {
51
+ setItems((prev) => prev.filter((item) => item.id !== id));
52
+ }
53
+
54
+ function move(index: number, direction: "up" | "down") {
55
+ setItems((prev) => {
56
+ const next = [...prev];
57
+ const target = direction === "up" ? index - 1 : index + 1;
58
+ if (target < 0 || target >= next.length) return prev;
59
+ [next[index], next[target]] = [next[target], next[index]];
60
+ return next.map((item, i) => ({ ...item, sortOrder: i }));
61
+ });
62
+ }
63
+
64
+ function addItem() {
65
+ setItems((prev) => [...prev, newItem(prev.length)]);
66
+ }
67
+
68
+ async function handleSave() {
69
+ setSaving(true);
70
+ try {
71
+ const normalized = items.map((item, i) => ({ ...item, sortOrder: i }));
72
+ await onSave("updateNavItems", normalized);
73
+ toast.success("Navigation saved.");
74
+ } catch (err) {
75
+ toast.error(toUserMessage(err, "Failed to save navigation"));
76
+ } finally {
77
+ setSaving(false);
78
+ }
79
+ }
80
+
81
+ return (
82
+ <Card>
83
+ <CardHeader>
84
+ <CardTitle>Navigation Items</CardTitle>
85
+ </CardHeader>
86
+ <CardContent className="space-y-3">
87
+ {items.length === 0 && (
88
+ <p className="text-sm text-muted-foreground">No navigation items. Add one below.</p>
89
+ )}
90
+ {items.map((item, index) => (
91
+ <div
92
+ key={item.id}
93
+ className="flex items-start gap-3 rounded-md border border-border bg-muted/30 p-3"
94
+ >
95
+ <div className="flex flex-col gap-1 pt-1">
96
+ <Button
97
+ variant="ghost"
98
+ size="sm"
99
+ className="h-6 w-6 p-0"
100
+ onClick={() => move(index, "up")}
101
+ disabled={index === 0}
102
+ >
103
+ <ArrowUp className="h-3 w-3" />
104
+ </Button>
105
+ <Button
106
+ variant="ghost"
107
+ size="sm"
108
+ className="h-6 w-6 p-0"
109
+ onClick={() => move(index, "down")}
110
+ disabled={index === items.length - 1}
111
+ >
112
+ <ArrowDown className="h-3 w-3" />
113
+ </Button>
114
+ </div>
115
+ <div className="grid flex-1 grid-cols-2 gap-2 sm:grid-cols-4">
116
+ <div className="space-y-1">
117
+ <Label className="text-xs text-muted-foreground">Label</Label>
118
+ <Input
119
+ value={item.label}
120
+ onChange={(e) => update(item.id, { label: e.target.value })}
121
+ placeholder="Dashboard"
122
+ className="h-8"
123
+ />
124
+ </div>
125
+ <div className="space-y-1">
126
+ <Label className="text-xs text-muted-foreground">Href</Label>
127
+ <Input
128
+ value={item.href}
129
+ onChange={(e) => update(item.id, { href: e.target.value })}
130
+ placeholder="/dashboard"
131
+ className="h-8"
132
+ />
133
+ </div>
134
+ <div className="space-y-1">
135
+ <Label className="text-xs text-muted-foreground">Icon</Label>
136
+ <Input
137
+ value={item.icon ?? ""}
138
+ onChange={(e) => update(item.id, { icon: e.target.value || null })}
139
+ placeholder="LayoutDashboard"
140
+ className="h-8"
141
+ />
142
+ </div>
143
+ <div className="space-y-1">
144
+ <Label className="text-xs text-muted-foreground">Requires Role</Label>
145
+ <Input
146
+ value={item.requiresRole ?? ""}
147
+ onChange={(e) => update(item.id, { requiresRole: e.target.value || null })}
148
+ placeholder="platform_admin"
149
+ className="h-8"
150
+ />
151
+ </div>
152
+ </div>
153
+ <div className="flex items-center gap-2 pt-5">
154
+ <Checkbox
155
+ id={`nav-enabled-${item.id}`}
156
+ checked={item.enabled}
157
+ onCheckedChange={(checked) => update(item.id, { enabled: Boolean(checked) })}
158
+ />
159
+ <Label htmlFor={`nav-enabled-${item.id}`} className="text-xs">
160
+ Enabled
161
+ </Label>
162
+ </div>
163
+ <Button
164
+ variant="ghost"
165
+ size="sm"
166
+ className="mt-4 h-7 w-7 p-0 text-destructive hover:text-destructive"
167
+ onClick={() => remove(item.id)}
168
+ >
169
+ <Trash2 className="h-3.5 w-3.5" />
170
+ </Button>
171
+ </div>
172
+ ))}
173
+ <div className="flex items-center justify-between pt-2">
174
+ <Button variant="outline" size="sm" onClick={addItem}>
175
+ <Plus className="mr-1.5 h-4 w-4" />
176
+ Add Item
177
+ </Button>
178
+ <Button onClick={handleSave} disabled={saving}>
179
+ {saving ? "Saving…" : "Save Navigation"}
180
+ </Button>
181
+ </div>
182
+ </CardContent>
183
+ </Card>
184
+ );
185
+ }
@@ -0,0 +1,66 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { Button } from "@/components/ui/button";
5
+ import { Input } from "@/components/ui/input";
6
+ import { cn } from "@/lib/utils";
7
+
8
+ const PRESETS = [10, 25, 50, 100];
9
+
10
+ interface AmountSelectorProps {
11
+ onSelect: (amount: number) => void;
12
+ }
13
+
14
+ export function AmountSelector({ onSelect }: AmountSelectorProps) {
15
+ const [selected, setSelected] = useState<number | null>(null);
16
+ const [custom, setCustom] = useState("");
17
+
18
+ const activeAmount = custom ? Number(custom) : selected;
19
+ const isValid = activeAmount !== null && activeAmount >= 10 && Number.isFinite(activeAmount);
20
+
21
+ return (
22
+ <div className="space-y-4">
23
+ <div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
24
+ {PRESETS.map((amt) => (
25
+ <button
26
+ key={amt}
27
+ type="button"
28
+ onClick={() => {
29
+ setSelected(amt);
30
+ setCustom("");
31
+ }}
32
+ className={cn(
33
+ "rounded-md border p-3 text-lg font-bold transition-colors hover:bg-accent",
34
+ selected === amt && !custom
35
+ ? "border-primary bg-primary/5 ring-1 ring-primary"
36
+ : "border-border",
37
+ )}
38
+ >
39
+ ${amt}
40
+ </button>
41
+ ))}
42
+ </div>
43
+ <Input
44
+ type="number"
45
+ min={10}
46
+ placeholder="Custom amount..."
47
+ value={custom}
48
+ onChange={(e) => {
49
+ setCustom(e.target.value);
50
+ setSelected(null);
51
+ }}
52
+ />
53
+ <Button
54
+ onClick={() => {
55
+ if (isValid && activeAmount !== null) {
56
+ onSelect(activeAmount);
57
+ }
58
+ }}
59
+ disabled={!isValid}
60
+ className="w-full"
61
+ >
62
+ Continue to payment
63
+ </Button>
64
+ </div>
65
+ );
66
+ }
@@ -1,304 +1,3 @@
1
1
  "use client";
2
2
 
3
- import { motion } from "framer-motion";
4
- import { Check, CircleDollarSign, Copy } from "lucide-react";
5
- import { useCallback, useEffect, useState } from "react";
6
- import { Badge } from "@/components/ui/badge";
7
- import { Button } from "@/components/ui/button";
8
- import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
9
- import {
10
- type CheckoutResult,
11
- createCheckout,
12
- getChargeStatus,
13
- getSupportedPaymentMethods,
14
- type SupportedPaymentMethod,
15
- } from "@/lib/api";
16
- import { cn } from "@/lib/utils";
17
-
18
- type PaymentProgress = {
19
- status: "waiting" | "partial" | "confirming" | "credited" | "expired" | "failed";
20
- amountExpectedCents: number;
21
- amountReceivedCents: number;
22
- confirmations: number;
23
- confirmationsRequired: number;
24
- };
25
-
26
- const AMOUNTS = [
27
- { value: 10, label: "$10" },
28
- { value: 25, label: "$25" },
29
- { value: 50, label: "$50" },
30
- { value: 100, label: "$100" },
31
- ];
32
-
33
- function CopyButton({ text }: { text: string }) {
34
- const [copied, setCopied] = useState(false);
35
- const handleCopy = useCallback(() => {
36
- navigator.clipboard.writeText(text);
37
- setCopied(true);
38
- setTimeout(() => setCopied(false), 2000);
39
- }, [text]);
40
-
41
- return (
42
- <Button variant="ghost" size="sm" onClick={handleCopy} className="h-7 px-2">
43
- {copied ? <Check className="h-3.5 w-3.5 text-primary" /> : <Copy className="h-3.5 w-3.5" />}
44
- </Button>
45
- );
46
- }
47
-
48
- export function BuyCryptoCreditPanel() {
49
- const [methods, setMethods] = useState<SupportedPaymentMethod[]>([]);
50
- const [selectedMethod, setSelectedMethod] = useState<SupportedPaymentMethod | null>(null);
51
- const [selectedAmount, setSelectedAmount] = useState<number | null>(null);
52
- const [loading, setLoading] = useState(false);
53
- const [error, setError] = useState<string | null>(null);
54
- const [checkout, setCheckout] = useState<CheckoutResult | null>(null);
55
- const [paymentProgress, setPaymentProgress] = useState<PaymentProgress | null>(null);
56
-
57
- // Poll charge status after checkout
58
- useEffect(() => {
59
- if (!checkout?.referenceId) {
60
- setPaymentProgress(null);
61
- return;
62
- }
63
- setPaymentProgress({
64
- status: "waiting",
65
- amountExpectedCents: 0,
66
- amountReceivedCents: 0,
67
- confirmations: 0,
68
- confirmationsRequired: 0,
69
- });
70
- const interval = setInterval(async () => {
71
- try {
72
- const res = await getChargeStatus(checkout.referenceId);
73
- let status: PaymentProgress["status"] = "waiting";
74
- if (res.credited) {
75
- status = "credited";
76
- } else if (res.status === "expired" || res.status === "failed") {
77
- status = res.status;
78
- } else if (
79
- res.amountReceivedCents >= res.amountExpectedCents &&
80
- res.amountReceivedCents > 0
81
- ) {
82
- status = "confirming";
83
- } else if (res.amountReceivedCents > 0) {
84
- status = "partial";
85
- }
86
- setPaymentProgress({
87
- status,
88
- amountExpectedCents: res.amountExpectedCents,
89
- amountReceivedCents: res.amountReceivedCents,
90
- confirmations: res.confirmations,
91
- confirmationsRequired: res.confirmationsRequired,
92
- });
93
- if (status === "credited" || status === "expired" || status === "failed") {
94
- clearInterval(interval);
95
- }
96
- } catch {
97
- // Ignore poll errors
98
- }
99
- }, 5000);
100
- return () => clearInterval(interval);
101
- }, [checkout?.referenceId]);
102
-
103
- // Fetch available payment methods from backend on mount
104
- useEffect(() => {
105
- getSupportedPaymentMethods()
106
- .then((m) => {
107
- if (m.length > 0) {
108
- setMethods(m);
109
- setSelectedMethod(m[0]);
110
- }
111
- })
112
- .catch(() => {
113
- // Backend unavailable — panel stays empty
114
- });
115
- }, []);
116
-
117
- async function handleCheckout() {
118
- if (selectedAmount === null || !selectedMethod) return;
119
- setLoading(true);
120
- setError(null);
121
- try {
122
- const result = await createCheckout(selectedMethod.id, selectedAmount);
123
- setCheckout(result);
124
- } catch {
125
- setError("Checkout failed. Please try again.");
126
- } finally {
127
- setLoading(false);
128
- }
129
- }
130
-
131
- function handleReset() {
132
- setCheckout(null);
133
- setSelectedAmount(null);
134
- setError(null);
135
- }
136
-
137
- if (methods.length === 0) return null;
138
-
139
- return (
140
- <motion.div
141
- initial={{ opacity: 0, y: 8 }}
142
- animate={{ opacity: 1, y: 0 }}
143
- transition={{ duration: 0.4, ease: "easeOut", delay: 0.1 }}
144
- >
145
- <Card>
146
- <CardHeader>
147
- <CardTitle className="flex items-center gap-2">
148
- <CircleDollarSign className="h-4 w-4 text-primary" />
149
- Pay with Crypto
150
- </CardTitle>
151
- <div className="flex flex-wrap gap-2 pt-1">
152
- {methods.map((m) => (
153
- <button
154
- key={m.id}
155
- type="button"
156
- onClick={() => {
157
- setSelectedMethod(m);
158
- handleReset();
159
- }}
160
- className={cn(
161
- "flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium transition-colors",
162
- selectedMethod?.id === m.id
163
- ? "bg-primary/10 text-primary"
164
- : "text-muted-foreground hover:text-foreground",
165
- )}
166
- >
167
- {/* biome-ignore lint/performance/noImgElement: external dynamic URLs from backend, not static assets */}
168
- <img
169
- src={m.iconUrl}
170
- alt={m.token}
171
- className="h-4 w-4"
172
- loading="lazy"
173
- onError={(e) => {
174
- e.currentTarget.style.display = "none";
175
- }}
176
- />
177
- {m.token}
178
- </button>
179
- ))}
180
- </div>
181
- </CardHeader>
182
- <CardContent className="space-y-4">
183
- {checkout ? (
184
- <motion.div
185
- initial={{ opacity: 0, y: 8 }}
186
- animate={{ opacity: 1, y: 0 }}
187
- className="space-y-4"
188
- >
189
- <div className="rounded-md border p-4 space-y-3">
190
- <div className="flex items-center justify-between">
191
- <p className="text-sm text-muted-foreground">
192
- Send{" "}
193
- <span className="font-mono font-bold text-foreground">
194
- {checkout.displayAmount}
195
- </span>{" "}
196
- to:
197
- </p>
198
- <Badge variant="outline" className="text-xs">
199
- {checkout.token} on {checkout.chain}
200
- </Badge>
201
- </div>
202
- <div className="flex items-center gap-2 rounded-md bg-muted/50 px-3 py-2">
203
- <code className="flex-1 text-xs font-mono break-all text-foreground">
204
- {checkout.depositAddress}
205
- </code>
206
- <CopyButton text={checkout.depositAddress} />
207
- </div>
208
- {checkout.priceCents && (
209
- <p className="text-xs text-muted-foreground">
210
- Price at checkout: ${(checkout.priceCents / 100).toFixed(2)} per{" "}
211
- {checkout.token}
212
- </p>
213
- )}
214
- <p className="text-xs text-muted-foreground">
215
- Only send {checkout.token} on the {checkout.chain} network.
216
- </p>
217
- {paymentProgress?.status === "waiting" && (
218
- <p className="text-xs text-yellow-500 animate-pulse">Waiting for payment...</p>
219
- )}
220
- {paymentProgress?.status === "partial" && (
221
- <p className="text-xs text-blue-500">
222
- Received ${(paymentProgress.amountReceivedCents / 100).toFixed(2)} of $
223
- {(paymentProgress.amountExpectedCents / 100).toFixed(2)} — send $
224
- {(
225
- (paymentProgress.amountExpectedCents - paymentProgress.amountReceivedCents) /
226
- 100
227
- ).toFixed(2)}{" "}
228
- more
229
- </p>
230
- )}
231
- {paymentProgress?.status === "confirming" && (
232
- <p className="text-xs text-blue-500">
233
- Payment received. Confirming ({paymentProgress.confirmations} of{" "}
234
- {paymentProgress.confirmationsRequired})...
235
- </p>
236
- )}
237
- {paymentProgress?.status === "credited" && (
238
- <p className="text-xs text-green-500 font-medium">
239
- Payment confirmed! Credits added.
240
- </p>
241
- )}
242
- {paymentProgress?.status === "expired" && (
243
- <p className="text-xs text-red-500">Payment expired.</p>
244
- )}
245
- {paymentProgress?.status === "failed" && (
246
- <p className="text-xs text-red-500">Payment failed.</p>
247
- )}
248
- </div>
249
- {paymentProgress?.status === "credited" ? (
250
- <Button variant="outline" size="sm" onClick={handleReset}>
251
- Done
252
- </Button>
253
- ) : paymentProgress?.status === "expired" || paymentProgress?.status === "failed" ? (
254
- <div className="flex gap-2">
255
- <Button variant="outline" size="sm" onClick={handleReset}>
256
- Try Again
257
- </Button>
258
- </div>
259
- ) : (
260
- <Button variant="ghost" size="sm" onClick={handleReset}>
261
- Cancel
262
- </Button>
263
- )}
264
- </motion.div>
265
- ) : (
266
- <>
267
- <div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
268
- {AMOUNTS.map((amt) => (
269
- <motion.button
270
- key={amt.value}
271
- type="button"
272
- onClick={() => setSelectedAmount(amt.value)}
273
- whileHover={{ scale: 1.03 }}
274
- whileTap={{ scale: 0.98 }}
275
- transition={{ type: "spring", stiffness: 400, damping: 25 }}
276
- className={cn(
277
- "flex flex-col items-center gap-1 rounded-md border p-3 text-sm font-medium transition-colors hover:bg-accent",
278
- selectedAmount === amt.value
279
- ? "border-primary bg-primary/5 ring-1 ring-primary shadow-[0_0_15px_rgba(0,255,65,0.3)]"
280
- : "border-border",
281
- )}
282
- >
283
- <span className="text-lg font-bold">{amt.label}</span>
284
- </motion.button>
285
- ))}
286
- </div>
287
-
288
- {error && <p className="text-sm text-destructive">{error}</p>}
289
-
290
- <Button
291
- onClick={handleCheckout}
292
- disabled={selectedAmount === null || !selectedMethod || loading}
293
- variant="outline"
294
- className="w-full sm:w-auto"
295
- >
296
- {loading ? "Creating checkout..." : `Pay with ${selectedMethod?.token ?? "Crypto"}`}
297
- </Button>
298
- </>
299
- )}
300
- </CardContent>
301
- </Card>
302
- </motion.div>
303
- );
304
- }
3
+ export { CryptoCheckout as BuyCryptoCreditPanel } from "./crypto-checkout";
@@ -0,0 +1,104 @@
1
+ "use client";
2
+
3
+ import { Check } from "lucide-react";
4
+
5
+ interface ConfirmationTrackerProps {
6
+ confirmations: number;
7
+ confirmationsRequired: number;
8
+ displayAmount: string;
9
+ credited: boolean;
10
+ txHash?: string;
11
+ }
12
+
13
+ export function ConfirmationTracker({
14
+ confirmations,
15
+ confirmationsRequired,
16
+ displayAmount,
17
+ credited,
18
+ txHash,
19
+ }: ConfirmationTrackerProps) {
20
+ const pct =
21
+ confirmationsRequired > 0
22
+ ? Math.min(100, Math.round((confirmations / confirmationsRequired) * 100))
23
+ : 0;
24
+ const detected = confirmations > 0 || credited;
25
+
26
+ return (
27
+ <div className="space-y-4 text-center">
28
+ <p className="text-sm text-muted-foreground">
29
+ {credited ? "Payment complete!" : "Payment received!"}
30
+ </p>
31
+ <p className="text-xl font-semibold">{displayAmount}</p>
32
+
33
+ <div className="rounded-lg border border-border p-3 space-y-2">
34
+ <div className="flex justify-between text-xs">
35
+ <span className="text-muted-foreground">Confirmations</span>
36
+ <span>
37
+ {confirmations} / {confirmationsRequired}
38
+ </span>
39
+ </div>
40
+ <div
41
+ className="h-1.5 rounded-full bg-muted overflow-hidden"
42
+ role="progressbar"
43
+ aria-valuenow={pct}
44
+ aria-valuemin={0}
45
+ aria-valuemax={100}
46
+ >
47
+ <div
48
+ className="h-full rounded-full bg-primary transition-all duration-500"
49
+ style={{ width: `${pct}%` }}
50
+ />
51
+ </div>
52
+ </div>
53
+
54
+ <div className="space-y-2 text-left">
55
+ <div className="flex items-center gap-2">
56
+ <div
57
+ className={`flex h-4 w-4 items-center justify-center rounded-full text-[10px] ${detected ? "bg-green-500 text-white" : "bg-muted"}`}
58
+ >
59
+ {detected && <Check className="h-2.5 w-2.5" />}
60
+ </div>
61
+ <span
62
+ className={`text-xs ${detected ? "text-muted-foreground" : "text-muted-foreground/50"}`}
63
+ >
64
+ Payment detected
65
+ </span>
66
+ </div>
67
+ <div className="flex items-center gap-2">
68
+ <div
69
+ className={`flex h-4 w-4 items-center justify-center rounded-full text-[10px] ${credited ? "bg-green-500 text-white" : detected ? "bg-primary text-white animate-pulse" : "bg-muted"}`}
70
+ >
71
+ {credited ? (
72
+ <Check className="h-2.5 w-2.5" />
73
+ ) : detected ? (
74
+ <span>&middot;</span>
75
+ ) : null}
76
+ </div>
77
+ <span
78
+ className={`text-xs ${detected ? "text-foreground" : "text-muted-foreground/50"}`}
79
+ >
80
+ {credited ? "Confirmed" : "Confirming on chain"}
81
+ </span>
82
+ </div>
83
+ <div className="flex items-center gap-2">
84
+ <div
85
+ className={`flex h-4 w-4 items-center justify-center rounded-full text-[10px] ${credited ? "bg-green-500 text-white" : "bg-muted"}`}
86
+ >
87
+ {credited && <Check className="h-2.5 w-2.5" />}
88
+ </div>
89
+ <span
90
+ className={`text-xs ${credited ? "text-foreground" : "text-muted-foreground/50"}`}
91
+ >
92
+ Credits applied
93
+ </span>
94
+ </div>
95
+ </div>
96
+
97
+ {txHash && (
98
+ <p className="text-xs text-muted-foreground font-mono truncate">
99
+ tx: {txHash}
100
+ </p>
101
+ )}
102
+ </div>
103
+ );
104
+ }