opencard 1.0.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/API.md +564 -0
- package/DATABASE.md +143 -0
- package/README.md +106 -0
- package/admin/index.html +18 -0
- package/admin/package-lock.json +2663 -0
- package/admin/package.json +25 -0
- package/admin/postcss.config.js +6 -0
- package/admin/src/App.tsx +198 -0
- package/admin/src/index.css +44 -0
- package/admin/src/main.tsx +10 -0
- package/admin/src/pages/Cards.tsx +181 -0
- package/admin/src/pages/Dashboard.tsx +139 -0
- package/admin/src/pages/Transactions.tsx +223 -0
- package/admin/tailwind.config.js +27 -0
- package/admin/tsconfig.json +20 -0
- package/admin/tsconfig.tsbuildinfo +1 -0
- package/admin/vite.config.ts +17 -0
- package/drizzle.config.ts +11 -0
- package/examples/agent-client-sdk.ts +36 -0
- package/examples/agent-client.ts +111 -0
- package/examples/agent-fund-sdk.ts +35 -0
- package/package.json +41 -0
- package/sdk/README.md +139 -0
- package/sdk/package-lock.json +240 -0
- package/sdk/package.json +43 -0
- package/sdk/src/client.ts +194 -0
- package/sdk/src/errors.ts +66 -0
- package/sdk/src/index.ts +35 -0
- package/sdk/src/types.ts +138 -0
- package/sdk/src/x402.ts +158 -0
- package/sdk/tsconfig.json +20 -0
- package/src/config/env.ts +45 -0
- package/src/config/tiers.ts +51 -0
- package/src/db/index.ts +9 -0
- package/src/db/migrate.ts +16 -0
- package/src/db/schema.ts +82 -0
- package/src/index.ts +89 -0
- package/src/middleware/errorHandler.ts +27 -0
- package/src/middleware/rateLimit.ts +54 -0
- package/src/middleware/walletAuth.ts +89 -0
- package/src/middleware/x402.ts +194 -0
- package/src/routes/admin.ts +150 -0
- package/src/routes/cards.ts +120 -0
- package/src/routes/paid.ts +154 -0
- package/src/routes/public.ts +40 -0
- package/src/services/cardService.ts +395 -0
- package/src/services/kripicard.ts +128 -0
- package/src/services/walletService.ts +78 -0
- package/src/types/index.ts +128 -0
- package/src/utils/logger.ts +19 -0
- package/src/utils/pricing.ts +75 -0
- package/tsconfig.json +21 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "opencard-admin",
|
|
3
|
+
"private": true,
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "vite",
|
|
8
|
+
"build": "tsc -b && vite build",
|
|
9
|
+
"preview": "vite preview"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"react": "^18.3.0",
|
|
13
|
+
"react-dom": "^18.3.0"
|
|
14
|
+
},
|
|
15
|
+
"devDependencies": {
|
|
16
|
+
"@types/react": "^18.3.0",
|
|
17
|
+
"@types/react-dom": "^18.3.0",
|
|
18
|
+
"@vitejs/plugin-react": "^4.3.0",
|
|
19
|
+
"autoprefixer": "^10.4.0",
|
|
20
|
+
"postcss": "^8.4.0",
|
|
21
|
+
"tailwindcss": "^3.4.0",
|
|
22
|
+
"typescript": "^5.5.0",
|
|
23
|
+
"vite": "^5.4.0"
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback } from "react";
|
|
2
|
+
import Dashboard from "./pages/Dashboard";
|
|
3
|
+
import Transactions from "./pages/Transactions";
|
|
4
|
+
import Cards from "./pages/Cards";
|
|
5
|
+
|
|
6
|
+
type Page = "dashboard" | "transactions" | "cards";
|
|
7
|
+
|
|
8
|
+
interface Stats {
|
|
9
|
+
cards: { totalCards: number; activeCards: number; frozenCards: number };
|
|
10
|
+
transactions: {
|
|
11
|
+
totalTransactions: number;
|
|
12
|
+
completedTransactions: number;
|
|
13
|
+
failedTransactions: number;
|
|
14
|
+
pendingTransactions: number;
|
|
15
|
+
totalRevenue: string;
|
|
16
|
+
totalVolume: string;
|
|
17
|
+
};
|
|
18
|
+
wallets: { totalWallets: number };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function App() {
|
|
22
|
+
const [page, setPage] = useState<Page>("dashboard");
|
|
23
|
+
const [token, setToken] = useState(
|
|
24
|
+
() => localStorage.getItem("opencard_admin_token") || "",
|
|
25
|
+
);
|
|
26
|
+
const [authed, setAuthed] = useState(false);
|
|
27
|
+
const [stats, setStats] = useState<Stats | null>(null);
|
|
28
|
+
const [loginInput, setLoginInput] = useState("");
|
|
29
|
+
|
|
30
|
+
const apiBase = "/admin";
|
|
31
|
+
|
|
32
|
+
const fetchStats = useCallback(async () => {
|
|
33
|
+
try {
|
|
34
|
+
const res = await fetch(`${apiBase}/api/stats`, {
|
|
35
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
36
|
+
});
|
|
37
|
+
if (res.ok) {
|
|
38
|
+
const data = await res.json();
|
|
39
|
+
setStats(data);
|
|
40
|
+
setAuthed(true);
|
|
41
|
+
} else {
|
|
42
|
+
setAuthed(false);
|
|
43
|
+
localStorage.removeItem("opencard_admin_token");
|
|
44
|
+
}
|
|
45
|
+
} catch {
|
|
46
|
+
// server not reachable
|
|
47
|
+
}
|
|
48
|
+
}, [token]);
|
|
49
|
+
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
if (token) fetchStats();
|
|
52
|
+
}, [token, fetchStats]);
|
|
53
|
+
|
|
54
|
+
// Auto-refresh stats every 30s
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
if (!authed) return;
|
|
57
|
+
const interval = setInterval(fetchStats, 30000);
|
|
58
|
+
return () => clearInterval(interval);
|
|
59
|
+
}, [authed, fetchStats]);
|
|
60
|
+
|
|
61
|
+
const handleLogin = (e: React.FormEvent) => {
|
|
62
|
+
e.preventDefault();
|
|
63
|
+
localStorage.setItem("opencard_admin_token", loginInput);
|
|
64
|
+
setToken(loginInput);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
if (!authed) {
|
|
68
|
+
return (
|
|
69
|
+
<div
|
|
70
|
+
className="min-h-screen flex items-center justify-center"
|
|
71
|
+
style={{ background: "var(--bg-primary)" }}
|
|
72
|
+
>
|
|
73
|
+
<form
|
|
74
|
+
onSubmit={handleLogin}
|
|
75
|
+
className="w-full max-w-sm p-8 rounded-2xl"
|
|
76
|
+
style={{
|
|
77
|
+
background: "var(--bg-card)",
|
|
78
|
+
border: "1px solid var(--border)",
|
|
79
|
+
}}
|
|
80
|
+
>
|
|
81
|
+
<div className="flex items-center gap-3 mb-8">
|
|
82
|
+
<div
|
|
83
|
+
className="w-10 h-10 rounded-xl flex items-center justify-center text-xl font-bold"
|
|
84
|
+
style={{
|
|
85
|
+
background: "linear-gradient(135deg, #818cf8, #6366f1)",
|
|
86
|
+
}}
|
|
87
|
+
>
|
|
88
|
+
O
|
|
89
|
+
</div>
|
|
90
|
+
<div>
|
|
91
|
+
<h1
|
|
92
|
+
className="text-xl font-bold"
|
|
93
|
+
style={{ color: "var(--text-primary)" }}
|
|
94
|
+
>
|
|
95
|
+
OpenCard
|
|
96
|
+
</h1>
|
|
97
|
+
<p className="text-xs" style={{ color: "var(--text-muted)" }}>
|
|
98
|
+
Admin Dashboard
|
|
99
|
+
</p>
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
102
|
+
<input
|
|
103
|
+
type="password"
|
|
104
|
+
value={loginInput}
|
|
105
|
+
onChange={(e) => setLoginInput(e.target.value)}
|
|
106
|
+
placeholder="Admin secret..."
|
|
107
|
+
className="w-full px-4 py-3 rounded-lg mb-4 outline-none text-sm transition-all focus:ring-2"
|
|
108
|
+
style={{
|
|
109
|
+
background: "var(--bg-secondary)",
|
|
110
|
+
border: "1px solid var(--border)",
|
|
111
|
+
color: "var(--text-primary)",
|
|
112
|
+
}}
|
|
113
|
+
/>
|
|
114
|
+
<button
|
|
115
|
+
type="submit"
|
|
116
|
+
className="w-full py-3 rounded-lg font-semibold text-sm text-white transition-all hover:opacity-90"
|
|
117
|
+
style={{ background: "linear-gradient(135deg, #818cf8, #6366f1)" }}
|
|
118
|
+
>
|
|
119
|
+
Sign In
|
|
120
|
+
</button>
|
|
121
|
+
</form>
|
|
122
|
+
</div>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return (
|
|
127
|
+
<div className="min-h-screen" style={{ background: "var(--bg-primary)" }}>
|
|
128
|
+
{/* Header */}
|
|
129
|
+
<header
|
|
130
|
+
className="sticky top-0 z-50 px-6 py-4 flex items-center justify-between"
|
|
131
|
+
style={{
|
|
132
|
+
background: "rgba(15, 15, 35, 0.85)",
|
|
133
|
+
backdropFilter: "blur(12px)",
|
|
134
|
+
borderBottom: "1px solid var(--border)",
|
|
135
|
+
}}
|
|
136
|
+
>
|
|
137
|
+
<div className="flex items-center gap-3">
|
|
138
|
+
<div
|
|
139
|
+
className="w-9 h-9 rounded-xl flex items-center justify-center text-lg font-bold text-white"
|
|
140
|
+
style={{ background: "linear-gradient(135deg, #818cf8, #6366f1)" }}
|
|
141
|
+
>
|
|
142
|
+
O
|
|
143
|
+
</div>
|
|
144
|
+
<span
|
|
145
|
+
className="text-lg font-bold"
|
|
146
|
+
style={{ color: "var(--text-primary)" }}
|
|
147
|
+
>
|
|
148
|
+
OpenCard
|
|
149
|
+
</span>
|
|
150
|
+
<span
|
|
151
|
+
className="text-xs px-2 py-0.5 rounded-full"
|
|
152
|
+
style={{ background: "var(--accent-glow)", color: "var(--accent)" }}
|
|
153
|
+
>
|
|
154
|
+
Admin
|
|
155
|
+
</span>
|
|
156
|
+
</div>
|
|
157
|
+
<nav className="flex gap-1">
|
|
158
|
+
{(["dashboard", "transactions", "cards"] as Page[]).map((p) => (
|
|
159
|
+
<button
|
|
160
|
+
key={p}
|
|
161
|
+
onClick={() => setPage(p)}
|
|
162
|
+
className="px-4 py-2 rounded-lg text-sm font-medium capitalize transition-all"
|
|
163
|
+
style={{
|
|
164
|
+
background: page === p ? "var(--accent-glow)" : "transparent",
|
|
165
|
+
color: page === p ? "var(--accent)" : "var(--text-secondary)",
|
|
166
|
+
}}
|
|
167
|
+
>
|
|
168
|
+
{p}
|
|
169
|
+
</button>
|
|
170
|
+
))}
|
|
171
|
+
</nav>
|
|
172
|
+
<button
|
|
173
|
+
onClick={() => {
|
|
174
|
+
localStorage.removeItem("opencard_admin_token");
|
|
175
|
+
setAuthed(false);
|
|
176
|
+
setToken("");
|
|
177
|
+
}}
|
|
178
|
+
className="text-xs px-3 py-1.5 rounded-lg transition-all hover:opacity-80"
|
|
179
|
+
style={{
|
|
180
|
+
background: "rgba(248, 113, 113, 0.1)",
|
|
181
|
+
color: "var(--danger)",
|
|
182
|
+
}}
|
|
183
|
+
>
|
|
184
|
+
Logout
|
|
185
|
+
</button>
|
|
186
|
+
</header>
|
|
187
|
+
|
|
188
|
+
{/* Content */}
|
|
189
|
+
<main className="p-6 max-w-7xl mx-auto">
|
|
190
|
+
{page === "dashboard" && <Dashboard stats={stats} />}
|
|
191
|
+
{page === "transactions" && <Transactions token={token} />}
|
|
192
|
+
{page === "cards" && <Cards token={token} />}
|
|
193
|
+
</main>
|
|
194
|
+
</div>
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export default App;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
@tailwind base;
|
|
2
|
+
@tailwind components;
|
|
3
|
+
@tailwind utilities;
|
|
4
|
+
|
|
5
|
+
:root {
|
|
6
|
+
--bg-primary: #0f0f23;
|
|
7
|
+
--bg-secondary: #1a1a3e;
|
|
8
|
+
--bg-card: #1e1e45;
|
|
9
|
+
--bg-card-hover: #252563;
|
|
10
|
+
--accent: #818cf8;
|
|
11
|
+
--accent-glow: rgba(129, 140, 248, 0.15);
|
|
12
|
+
--text-primary: #e2e8f0;
|
|
13
|
+
--text-secondary: #94a3b8;
|
|
14
|
+
--text-muted: #64748b;
|
|
15
|
+
--border: rgba(129, 140, 248, 0.12);
|
|
16
|
+
--success: #34d399;
|
|
17
|
+
--danger: #f87171;
|
|
18
|
+
--warning: #fbbf24;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
* {
|
|
22
|
+
margin: 0;
|
|
23
|
+
padding: 0;
|
|
24
|
+
box-sizing: border-box;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
body {
|
|
28
|
+
font-family: "Inter", system-ui, sans-serif;
|
|
29
|
+
background: var(--bg-primary);
|
|
30
|
+
color: var(--text-primary);
|
|
31
|
+
min-height: 100vh;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/* Scrollbar */
|
|
35
|
+
::-webkit-scrollbar {
|
|
36
|
+
width: 6px;
|
|
37
|
+
}
|
|
38
|
+
::-webkit-scrollbar-track {
|
|
39
|
+
background: var(--bg-secondary);
|
|
40
|
+
}
|
|
41
|
+
::-webkit-scrollbar-thumb {
|
|
42
|
+
background: var(--accent);
|
|
43
|
+
border-radius: 3px;
|
|
44
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { useState, useEffect } from "react";
|
|
2
|
+
|
|
3
|
+
interface Card {
|
|
4
|
+
id: string;
|
|
5
|
+
walletAddress: string;
|
|
6
|
+
kripiCardId: string;
|
|
7
|
+
nameOnCard: string;
|
|
8
|
+
email: string;
|
|
9
|
+
initialAmountUsd: string;
|
|
10
|
+
currentBalanceUsd: string | null;
|
|
11
|
+
status: string;
|
|
12
|
+
createdAt: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export default function Cards({ token }: { token: string }) {
|
|
16
|
+
const [cards, setCards] = useState<Card[]>([]);
|
|
17
|
+
const [loading, setLoading] = useState(true);
|
|
18
|
+
const [search, setSearch] = useState("");
|
|
19
|
+
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
fetchCards();
|
|
22
|
+
}, []);
|
|
23
|
+
|
|
24
|
+
async function fetchCards() {
|
|
25
|
+
setLoading(true);
|
|
26
|
+
try {
|
|
27
|
+
const res = await fetch(`/admin/api/cards`, {
|
|
28
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
29
|
+
});
|
|
30
|
+
if (res.ok) {
|
|
31
|
+
const data = await res.json();
|
|
32
|
+
setCards(data.cards);
|
|
33
|
+
}
|
|
34
|
+
} catch {
|
|
35
|
+
// ignore
|
|
36
|
+
}
|
|
37
|
+
setLoading(false);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const filtered = cards.filter(
|
|
41
|
+
(c) =>
|
|
42
|
+
c.walletAddress.toLowerCase().includes(search.toLowerCase()) ||
|
|
43
|
+
c.kripiCardId.toLowerCase().includes(search.toLowerCase()) ||
|
|
44
|
+
c.nameOnCard.toLowerCase().includes(search.toLowerCase()),
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
const statusColors: Record<string, string> = {
|
|
48
|
+
active: "var(--success)",
|
|
49
|
+
frozen: "var(--warning)",
|
|
50
|
+
depleted: "var(--text-muted)",
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<div className="space-y-6">
|
|
55
|
+
<div className="flex items-center justify-between">
|
|
56
|
+
<h2
|
|
57
|
+
className="text-2xl font-bold"
|
|
58
|
+
style={{ color: "var(--text-primary)" }}
|
|
59
|
+
>
|
|
60
|
+
Cards
|
|
61
|
+
</h2>
|
|
62
|
+
<input
|
|
63
|
+
type="text"
|
|
64
|
+
value={search}
|
|
65
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
66
|
+
placeholder="Search by wallet, card ID, or name..."
|
|
67
|
+
className="px-4 py-2 rounded-lg text-sm outline-none w-80 transition-all focus:ring-2"
|
|
68
|
+
style={{
|
|
69
|
+
background: "var(--bg-secondary)",
|
|
70
|
+
border: "1px solid var(--border)",
|
|
71
|
+
color: "var(--text-primary)",
|
|
72
|
+
}}
|
|
73
|
+
/>
|
|
74
|
+
</div>
|
|
75
|
+
|
|
76
|
+
{loading ? (
|
|
77
|
+
<div className="flex justify-center py-12">
|
|
78
|
+
<div
|
|
79
|
+
className="w-8 h-8 rounded-full border-2 border-t-transparent animate-spin"
|
|
80
|
+
style={{
|
|
81
|
+
borderColor: "var(--accent)",
|
|
82
|
+
borderTopColor: "transparent",
|
|
83
|
+
}}
|
|
84
|
+
/>
|
|
85
|
+
</div>
|
|
86
|
+
) : (
|
|
87
|
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
88
|
+
{filtered.map((card) => (
|
|
89
|
+
<div
|
|
90
|
+
key={card.id}
|
|
91
|
+
className="p-5 rounded-xl transition-all hover:scale-[1.01]"
|
|
92
|
+
style={{
|
|
93
|
+
background: "var(--bg-card)",
|
|
94
|
+
border: "1px solid var(--border)",
|
|
95
|
+
}}
|
|
96
|
+
>
|
|
97
|
+
<div className="flex items-center justify-between mb-4">
|
|
98
|
+
<span
|
|
99
|
+
className="text-sm font-bold"
|
|
100
|
+
style={{ color: "var(--text-primary)" }}
|
|
101
|
+
>
|
|
102
|
+
{card.nameOnCard}
|
|
103
|
+
</span>
|
|
104
|
+
<span
|
|
105
|
+
className="text-xs px-2 py-1 rounded-full font-medium capitalize"
|
|
106
|
+
style={{
|
|
107
|
+
background: `${statusColors[card.status] || "var(--text-muted)"}15`,
|
|
108
|
+
color: statusColors[card.status] || "var(--text-muted)",
|
|
109
|
+
}}
|
|
110
|
+
>
|
|
111
|
+
{card.status}
|
|
112
|
+
</span>
|
|
113
|
+
</div>
|
|
114
|
+
|
|
115
|
+
<div className="space-y-2 text-sm">
|
|
116
|
+
<div className="flex justify-between">
|
|
117
|
+
<span style={{ color: "var(--text-muted)" }}>
|
|
118
|
+
KripiCard ID
|
|
119
|
+
</span>
|
|
120
|
+
<span
|
|
121
|
+
className="font-mono text-xs"
|
|
122
|
+
style={{ color: "var(--text-secondary)" }}
|
|
123
|
+
>
|
|
124
|
+
{card.kripiCardId}
|
|
125
|
+
</span>
|
|
126
|
+
</div>
|
|
127
|
+
<div className="flex justify-between">
|
|
128
|
+
<span style={{ color: "var(--text-muted)" }}>Balance</span>
|
|
129
|
+
<span
|
|
130
|
+
className="font-bold"
|
|
131
|
+
style={{ color: "var(--success)" }}
|
|
132
|
+
>
|
|
133
|
+
$
|
|
134
|
+
{card.currentBalanceUsd
|
|
135
|
+
? parseFloat(card.currentBalanceUsd).toFixed(2)
|
|
136
|
+
: "—"}
|
|
137
|
+
</span>
|
|
138
|
+
</div>
|
|
139
|
+
<div className="flex justify-between">
|
|
140
|
+
<span style={{ color: "var(--text-muted)" }}>
|
|
141
|
+
Initial Load
|
|
142
|
+
</span>
|
|
143
|
+
<span style={{ color: "var(--text-secondary)" }}>
|
|
144
|
+
${parseFloat(card.initialAmountUsd).toFixed(2)}
|
|
145
|
+
</span>
|
|
146
|
+
</div>
|
|
147
|
+
<div className="flex justify-between">
|
|
148
|
+
<span style={{ color: "var(--text-muted)" }}>Wallet</span>
|
|
149
|
+
<span
|
|
150
|
+
className="font-mono text-xs"
|
|
151
|
+
style={{ color: "var(--text-secondary)" }}
|
|
152
|
+
>
|
|
153
|
+
{card.walletAddress.slice(0, 6)}...
|
|
154
|
+
{card.walletAddress.slice(-4)}
|
|
155
|
+
</span>
|
|
156
|
+
</div>
|
|
157
|
+
<div className="flex justify-between">
|
|
158
|
+
<span style={{ color: "var(--text-muted)" }}>Created</span>
|
|
159
|
+
<span
|
|
160
|
+
className="text-xs"
|
|
161
|
+
style={{ color: "var(--text-muted)" }}
|
|
162
|
+
>
|
|
163
|
+
{new Date(card.createdAt).toLocaleDateString()}
|
|
164
|
+
</span>
|
|
165
|
+
</div>
|
|
166
|
+
</div>
|
|
167
|
+
</div>
|
|
168
|
+
))}
|
|
169
|
+
{filtered.length === 0 && (
|
|
170
|
+
<div
|
|
171
|
+
className="col-span-full py-12 text-center"
|
|
172
|
+
style={{ color: "var(--text-muted)" }}
|
|
173
|
+
>
|
|
174
|
+
No cards found
|
|
175
|
+
</div>
|
|
176
|
+
)}
|
|
177
|
+
</div>
|
|
178
|
+
)}
|
|
179
|
+
</div>
|
|
180
|
+
);
|
|
181
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
interface Stats {
|
|
2
|
+
cards: { totalCards: number; activeCards: number; frozenCards: number };
|
|
3
|
+
transactions: {
|
|
4
|
+
totalTransactions: number;
|
|
5
|
+
completedTransactions: number;
|
|
6
|
+
failedTransactions: number;
|
|
7
|
+
pendingTransactions: number;
|
|
8
|
+
totalRevenue: string;
|
|
9
|
+
totalVolume: string;
|
|
10
|
+
};
|
|
11
|
+
wallets: { totalWallets: number };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function StatCard({
|
|
15
|
+
label,
|
|
16
|
+
value,
|
|
17
|
+
accent,
|
|
18
|
+
}: {
|
|
19
|
+
label: string;
|
|
20
|
+
value: string | number;
|
|
21
|
+
accent?: string;
|
|
22
|
+
}) {
|
|
23
|
+
return (
|
|
24
|
+
<div
|
|
25
|
+
className="p-5 rounded-xl transition-all hover:scale-[1.02]"
|
|
26
|
+
style={{
|
|
27
|
+
background: "var(--bg-card)",
|
|
28
|
+
border: "1px solid var(--border)",
|
|
29
|
+
}}
|
|
30
|
+
>
|
|
31
|
+
<p
|
|
32
|
+
className="text-xs font-medium mb-1 uppercase tracking-wider"
|
|
33
|
+
style={{ color: "var(--text-muted)" }}
|
|
34
|
+
>
|
|
35
|
+
{label}
|
|
36
|
+
</p>
|
|
37
|
+
<p
|
|
38
|
+
className="text-2xl font-bold"
|
|
39
|
+
style={{ color: accent || "var(--text-primary)" }}
|
|
40
|
+
>
|
|
41
|
+
{value}
|
|
42
|
+
</p>
|
|
43
|
+
</div>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export default function Dashboard({ stats }: { stats: Stats | null }) {
|
|
48
|
+
if (!stats) {
|
|
49
|
+
return (
|
|
50
|
+
<div className="flex items-center justify-center py-20">
|
|
51
|
+
<div
|
|
52
|
+
className="w-8 h-8 rounded-full border-2 border-t-transparent animate-spin"
|
|
53
|
+
style={{
|
|
54
|
+
borderColor: "var(--accent)",
|
|
55
|
+
borderTopColor: "transparent",
|
|
56
|
+
}}
|
|
57
|
+
/>
|
|
58
|
+
</div>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<div className="space-y-6">
|
|
64
|
+
<h2
|
|
65
|
+
className="text-2xl font-bold"
|
|
66
|
+
style={{ color: "var(--text-primary)" }}
|
|
67
|
+
>
|
|
68
|
+
Dashboard
|
|
69
|
+
</h2>
|
|
70
|
+
|
|
71
|
+
{/* Alert for failed transactions */}
|
|
72
|
+
{stats.transactions.failedTransactions > 0 && (
|
|
73
|
+
<div
|
|
74
|
+
className="p-4 rounded-xl flex items-center gap-3"
|
|
75
|
+
style={{
|
|
76
|
+
background: "rgba(248, 113, 113, 0.1)",
|
|
77
|
+
border: "1px solid rgba(248, 113, 113, 0.2)",
|
|
78
|
+
}}
|
|
79
|
+
>
|
|
80
|
+
<span className="text-lg">⚠️</span>
|
|
81
|
+
<div>
|
|
82
|
+
<p
|
|
83
|
+
className="text-sm font-semibold"
|
|
84
|
+
style={{ color: "var(--danger)" }}
|
|
85
|
+
>
|
|
86
|
+
{stats.transactions.failedTransactions} failed transaction(s)
|
|
87
|
+
</p>
|
|
88
|
+
<p className="text-xs" style={{ color: "var(--text-muted)" }}>
|
|
89
|
+
Agents may have paid but not received their cards. Check
|
|
90
|
+
Transactions tab.
|
|
91
|
+
</p>
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
)}
|
|
95
|
+
|
|
96
|
+
{/* Stats Grid */}
|
|
97
|
+
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
98
|
+
<StatCard label="Total Cards" value={stats.cards.totalCards} />
|
|
99
|
+
<StatCard
|
|
100
|
+
label="Active Cards"
|
|
101
|
+
value={stats.cards.activeCards}
|
|
102
|
+
accent="var(--success)"
|
|
103
|
+
/>
|
|
104
|
+
<StatCard
|
|
105
|
+
label="Frozen Cards"
|
|
106
|
+
value={stats.cards.frozenCards}
|
|
107
|
+
accent="var(--warning)"
|
|
108
|
+
/>
|
|
109
|
+
<StatCard
|
|
110
|
+
label="Total Wallets"
|
|
111
|
+
value={stats.wallets.totalWallets}
|
|
112
|
+
accent="var(--accent)"
|
|
113
|
+
/>
|
|
114
|
+
</div>
|
|
115
|
+
|
|
116
|
+
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
117
|
+
<StatCard
|
|
118
|
+
label="Total Revenue"
|
|
119
|
+
value={`$${parseFloat(stats.transactions.totalRevenue).toFixed(2)}`}
|
|
120
|
+
accent="var(--success)"
|
|
121
|
+
/>
|
|
122
|
+
<StatCard
|
|
123
|
+
label="Total Volume"
|
|
124
|
+
value={`$${parseFloat(stats.transactions.totalVolume).toFixed(2)}`}
|
|
125
|
+
/>
|
|
126
|
+
<StatCard
|
|
127
|
+
label="Completed Txns"
|
|
128
|
+
value={stats.transactions.completedTransactions}
|
|
129
|
+
accent="var(--success)"
|
|
130
|
+
/>
|
|
131
|
+
<StatCard
|
|
132
|
+
label="Pending Txns"
|
|
133
|
+
value={stats.transactions.pendingTransactions}
|
|
134
|
+
accent="var(--warning)"
|
|
135
|
+
/>
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
);
|
|
139
|
+
}
|