@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.
- package/package.json +1 -1
- package/src/__tests__/amount-selector.test.tsx +36 -0
- 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/admin/products/error.tsx +26 -0
- package/src/app/admin/products/page.tsx +234 -0
- package/src/components/admin/products/billing-form.tsx +156 -0
- package/src/components/admin/products/brand-form.tsx +102 -0
- package/src/components/admin/products/features-form.tsx +126 -0
- package/src/components/admin/products/fleet-form.tsx +171 -0
- package/src/components/admin/products/nav-editor.tsx +185 -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 +104 -0
- package/src/components/billing/crypto-checkout.tsx +183 -0
- package/src/components/billing/deposit-view.tsx +82 -0
- package/src/components/billing/payment-method-picker.tsx +133 -0
- package/src/lib/brand-config.ts +30 -0
|
@@ -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
|
-
|
|
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>·</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
|
+
}
|