Skip to content

Commit 37989a9

Browse files
authored
feat: build merchant dashboard UI (#94) (#141)
- Next.js 16 app with Tailwind CSS - Login with user_id + PIN matching backend auth - Overview page with live stats (polls /admin/dashboard/stats) - Transactions page with filters, search, pagination, CSV export - Payouts page with request form and history - QR code generator with PNG download - Analytics page with area, pie, and bar charts - JWT auth context with route guard - Polling hook for real-time updates
1 parent 1cf15e0 commit 37989a9

33 files changed

Lines changed: 8500 additions & 0 deletions

dashboard/.gitignore

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2+
3+
# dependencies
4+
/node_modules
5+
/.pnp
6+
.pnp.*
7+
.yarn/*
8+
!.yarn/patches
9+
!.yarn/plugins
10+
!.yarn/releases
11+
!.yarn/versions
12+
13+
# testing
14+
/coverage
15+
16+
# next.js
17+
/.next/
18+
/out/
19+
20+
# production
21+
/build
22+
23+
# misc
24+
.DS_Store
25+
*.pem
26+
27+
# debug
28+
npm-debug.log*
29+
yarn-debug.log*
30+
yarn-error.log*
31+
.pnpm-debug.log*
32+
33+
# env files (can opt-in for committing if needed)
34+
.env*
35+
36+
# vercel
37+
.vercel
38+
39+
# typescript
40+
*.tsbuildinfo
41+
next-env.d.ts

dashboard/AGENTS.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<!-- BEGIN:nextjs-agent-rules -->
2+
# This is NOT the Next.js you know
3+
4+
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
5+
<!-- END:nextjs-agent-rules -->

dashboard/CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
@AGENTS.md

dashboard/README.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
2+
3+
## Getting Started
4+
5+
First, run the development server:
6+
7+
```bash
8+
npm run dev
9+
# or
10+
yarn dev
11+
# or
12+
pnpm dev
13+
# or
14+
bun dev
15+
```
16+
17+
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
18+
19+
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
20+
21+
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
22+
23+
## Learn More
24+
25+
To learn more about Next.js, take a look at the following resources:
26+
27+
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
28+
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
29+
30+
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
31+
32+
## Deploy on Vercel
33+
34+
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
35+
36+
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
"use client";
2+
import { useMemo } from "react";
3+
import { usePolling } from "@/lib/use-polling";
4+
import { api, Transaction } from "@/lib/api";
5+
import {
6+
AreaChart, Area, BarChart, Bar, PieChart, Pie, Cell,
7+
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend,
8+
} from "recharts";
9+
import { format, parseISO, startOfDay } from "date-fns";
10+
11+
const COLORS = ["#6366f1", "#22c55e", "#f59e0b", "#ef4444", "#8b5cf6"];
12+
13+
export default function AnalyticsPage() {
14+
const { data: txs, loading } = usePolling(() => api.transactions(), 30000);
15+
16+
const { dailyVolume, statusDist, assetDist } = useMemo(() => {
17+
if (!txs) return { dailyVolume: [], statusDist: [], assetDist: [] };
18+
19+
// Daily volume (last 30 days)
20+
const byDay: Record<string, number> = {};
21+
txs.forEach((t) => {
22+
const day = format(startOfDay(parseISO(t.created_at)), "MMM d");
23+
byDay[day] = (byDay[day] ?? 0) + t.send_amount / 1_000_000;
24+
});
25+
const dailyVolume = Object.entries(byDay)
26+
.slice(-30)
27+
.map(([date, volume]) => ({ date, volume: Number(volume.toFixed(2)) }));
28+
29+
// Status distribution
30+
const bySt: Record<string, number> = {};
31+
txs.forEach((t) => { bySt[t.status] = (bySt[t.status] ?? 0) + 1; });
32+
const statusDist = Object.entries(bySt).map(([name, value]) => ({ name, value }));
33+
34+
// Asset distribution
35+
const byAsset: Record<string, number> = {};
36+
txs.forEach((t) => { byAsset[t.send_asset] = (byAsset[t.send_asset] ?? 0) + t.send_amount / 1_000_000; });
37+
const assetDist = Object.entries(byAsset).map(([name, value]) => ({ name, value: Number(value.toFixed(2)) }));
38+
39+
return { dailyVolume, statusDist, assetDist };
40+
}, [txs]);
41+
42+
if (loading && !txs) {
43+
return (
44+
<div>
45+
<h1 className="text-2xl font-bold text-slate-900 mb-6">Analytics</h1>
46+
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
47+
{Array.from({ length: 3 }).map((_, i) => (
48+
<div key={i} className="bg-white border border-slate-200 rounded-xl p-5 h-72 animate-pulse" />
49+
))}
50+
</div>
51+
</div>
52+
);
53+
}
54+
55+
return (
56+
<div>
57+
<h1 className="text-2xl font-bold text-slate-900 mb-6">Analytics</h1>
58+
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
59+
60+
{/* Daily Volume */}
61+
<div className="bg-white border border-slate-200 rounded-xl p-5 shadow-sm lg:col-span-2">
62+
<h2 className="font-semibold text-slate-800 mb-4">Daily Transaction Volume</h2>
63+
<ResponsiveContainer width="100%" height={240}>
64+
<AreaChart data={dailyVolume}>
65+
<defs>
66+
<linearGradient id="vol" x1="0" y1="0" x2="0" y2="1">
67+
<stop offset="5%" stopColor="#6366f1" stopOpacity={0.2} />
68+
<stop offset="95%" stopColor="#6366f1" stopOpacity={0} />
69+
</linearGradient>
70+
</defs>
71+
<CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" />
72+
<XAxis dataKey="date" tick={{ fontSize: 11 }} />
73+
<YAxis tick={{ fontSize: 11 }} />
74+
<Tooltip formatter={(v) => [`${Number(v).toLocaleString()}`, "Volume"]} />
75+
<Area type="monotone" dataKey="volume" stroke="#6366f1" fill="url(#vol)" strokeWidth={2} />
76+
</AreaChart>
77+
</ResponsiveContainer>
78+
</div>
79+
80+
{/* Status Distribution */}
81+
<div className="bg-white border border-slate-200 rounded-xl p-5 shadow-sm">
82+
<h2 className="font-semibold text-slate-800 mb-4">Transaction Status</h2>
83+
<ResponsiveContainer width="100%" height={220}>
84+
<PieChart>
85+
<Pie data={statusDist} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={80} label={({ name, percent }) => `${name} ${((percent ?? 0) * 100).toFixed(0)}%`}>
86+
{statusDist.map((_, i) => <Cell key={i} fill={COLORS[i % COLORS.length]} />)}
87+
</Pie>
88+
<Tooltip />
89+
</PieChart>
90+
</ResponsiveContainer>
91+
</div>
92+
93+
{/* Asset Distribution */}
94+
<div className="bg-white border border-slate-200 rounded-xl p-5 shadow-sm">
95+
<h2 className="font-semibold text-slate-800 mb-4">Volume by Asset</h2>
96+
<ResponsiveContainer width="100%" height={220}>
97+
<BarChart data={assetDist}>
98+
<CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" />
99+
<XAxis dataKey="name" tick={{ fontSize: 11 }} />
100+
<YAxis tick={{ fontSize: 11 }} />
101+
<Tooltip formatter={(v) => [Number(v).toLocaleString(), "Volume"]} />
102+
<Bar dataKey="value" radius={[4, 4, 0, 0]}>
103+
{assetDist.map((_, i) => <Cell key={i} fill={COLORS[i % COLORS.length]} />)}
104+
</Bar>
105+
</BarChart>
106+
</ResponsiveContainer>
107+
</div>
108+
109+
</div>
110+
</div>
111+
);
112+
}

dashboard/app/dashboard/layout.tsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
"use client";
2+
import { useEffect } from "react";
3+
import { useRouter } from "next/navigation";
4+
import { useAuth } from "@/lib/auth-context";
5+
import Sidebar from "@/components/Sidebar";
6+
7+
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
8+
const { token } = useAuth();
9+
const router = useRouter();
10+
11+
useEffect(() => {
12+
if (token === null && typeof window !== "undefined" && !localStorage.getItem("token")) {
13+
router.replace("/login");
14+
}
15+
}, [token, router]);
16+
17+
return (
18+
<div className="flex min-h-screen">
19+
<Sidebar />
20+
<main className="ml-60 flex-1 p-6 overflow-auto">{children}</main>
21+
</div>
22+
);
23+
}

dashboard/app/dashboard/page.tsx

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
"use client";
2+
import { usePolling } from "@/lib/use-polling";
3+
import { api } from "@/lib/api";
4+
import StatCard from "@/components/StatCard";
5+
6+
export default function OverviewPage() {
7+
const { data, loading, error } = usePolling(() => api.dashboardStats(), 15000);
8+
9+
return (
10+
<div>
11+
<h1 className="text-2xl font-bold text-slate-900 mb-6">Overview</h1>
12+
13+
{error && (
14+
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
15+
{error} — showing cached data
16+
</div>
17+
)}
18+
19+
{loading && !data ? (
20+
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
21+
{Array.from({ length: 5 }).map((_, i) => (
22+
<div key={i} className="bg-white rounded-xl border border-slate-200 p-5 h-24 animate-pulse" />
23+
))}
24+
</div>
25+
) : (
26+
<div className="grid grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-4">
27+
<StatCard label="Total Users" value={data?.total_users ?? 0} />
28+
<StatCard label="Total Payments" value={data?.total_payments ?? 0} color="text-indigo-600" />
29+
<StatCard label="Transfers" value={data?.total_transfers ?? 0} />
30+
<StatCard label="Withdrawals" value={data?.total_withdrawals ?? 0} />
31+
<StatCard label="Active Merchants" value={data?.active_merchants ?? 0} color="text-green-600" />
32+
</div>
33+
)}
34+
35+
<p className="mt-4 text-xs text-slate-400">Auto-refreshes every 15 seconds</p>
36+
</div>
37+
);
38+
}
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
"use client";
2+
import { useState } from "react";
3+
import { usePolling } from "@/lib/use-polling";
4+
import { api, Payout } from "@/lib/api";
5+
import StatusBadge from "@/components/StatusBadge";
6+
import { format } from "date-fns";
7+
8+
export default function PayoutsPage() {
9+
const { data, loading, error, refresh } = usePolling(() => api.payoutHistory(20, 0), 30000);
10+
const payouts: Payout[] = data?.payouts ?? [];
11+
12+
const [form, setForm] = useState({ amount: "", asset: "USDC", bankAccountId: "", anchorId: "" });
13+
const [submitting, setSubmitting] = useState(false);
14+
const [msg, setMsg] = useState<{ type: "ok" | "err"; text: string } | null>(null);
15+
16+
const submit = async (e: React.FormEvent) => {
17+
e.preventDefault();
18+
setSubmitting(true);
19+
setMsg(null);
20+
try {
21+
await api.requestPayout({
22+
amount: String(Math.round(Number(form.amount) * 1_000_000)),
23+
asset: form.asset,
24+
bankAccountId: form.bankAccountId,
25+
anchorId: form.anchorId,
26+
});
27+
setMsg({ type: "ok", text: "Payout requested successfully." });
28+
setForm({ amount: "", asset: "USDC", bankAccountId: "", anchorId: "" });
29+
refresh();
30+
} catch (e) {
31+
setMsg({ type: "err", text: e instanceof Error ? e.message : "Failed" });
32+
} finally {
33+
setSubmitting(false);
34+
}
35+
};
36+
37+
return (
38+
<div>
39+
<h1 className="text-2xl font-bold text-slate-900 mb-6">Payouts</h1>
40+
41+
{/* Request payout form */}
42+
<div className="bg-white border border-slate-200 rounded-xl p-5 mb-6 shadow-sm max-w-lg">
43+
<h2 className="font-semibold text-slate-800 mb-4">Request Payout</h2>
44+
{msg && (
45+
<div className={`mb-3 p-3 rounded-lg text-sm ${msg.type === "ok" ? "bg-green-50 text-green-700 border border-green-200" : "bg-red-50 text-red-700 border border-red-200"}`}>
46+
{msg.text}
47+
</div>
48+
)}
49+
<form onSubmit={submit} className="space-y-3">
50+
<div className="flex gap-2">
51+
<input
52+
required
53+
type="number"
54+
min="0.01"
55+
step="0.01"
56+
placeholder="Amount"
57+
value={form.amount}
58+
onChange={(e) => setForm((f) => ({ ...f, amount: e.target.value }))}
59+
className="flex-1 border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
60+
/>
61+
<select
62+
value={form.asset}
63+
onChange={(e) => setForm((f) => ({ ...f, asset: e.target.value }))}
64+
className="border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
65+
>
66+
{["USDC", "USDT", "XLM"].map((a) => <option key={a}>{a}</option>)}
67+
</select>
68+
</div>
69+
<input
70+
required
71+
placeholder="Bank Account ID (UUID)"
72+
value={form.bankAccountId}
73+
onChange={(e) => setForm((f) => ({ ...f, bankAccountId: e.target.value }))}
74+
className="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
75+
/>
76+
<input
77+
required
78+
placeholder="Anchor ID"
79+
value={form.anchorId}
80+
onChange={(e) => setForm((f) => ({ ...f, anchorId: e.target.value }))}
81+
className="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
82+
/>
83+
<button
84+
type="submit"
85+
disabled={submitting}
86+
className="w-full bg-indigo-600 text-white py-2 rounded-lg text-sm font-medium hover:bg-indigo-700 disabled:opacity-50 transition-colors"
87+
>
88+
{submitting ? "Requesting…" : "Request Payout"}
89+
</button>
90+
</form>
91+
</div>
92+
93+
{/* History */}
94+
<h2 className="font-semibold text-slate-800 mb-3">Payout History</h2>
95+
{error && <div className="mb-3 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">{error}</div>}
96+
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden shadow-sm">
97+
<table className="w-full text-sm">
98+
<thead className="bg-slate-50 border-b border-slate-200">
99+
<tr>
100+
{["ID", "Date", "Amount", "Asset", "Status", "Anchor"].map((h) => (
101+
<th key={h} className="px-4 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">{h}</th>
102+
))}
103+
</tr>
104+
</thead>
105+
<tbody className="divide-y divide-slate-100">
106+
{loading && payouts.length === 0 ? (
107+
Array.from({ length: 5 }).map((_, i) => (
108+
<tr key={i}>{Array.from({ length: 6 }).map((_, j) => (
109+
<td key={j} className="px-4 py-3"><div className="h-4 bg-slate-100 rounded animate-pulse" /></td>
110+
))}</tr>
111+
))
112+
) : payouts.length === 0 ? (
113+
<tr><td colSpan={6} className="px-4 py-10 text-center text-slate-400">No payouts yet</td></tr>
114+
) : (
115+
payouts.map((p) => (
116+
<tr key={p.id} className="hover:bg-slate-50">
117+
<td className="px-4 py-3 font-mono text-xs text-slate-500">{p.id.slice(0, 8)}</td>
118+
<td className="px-4 py-3 text-slate-600 whitespace-nowrap">{format(new Date(p.createdAt), "MMM d, yyyy")}</td>
119+
<td className="px-4 py-3 font-medium">{(Number(p.amount) / 1_000_000).toFixed(2)}</td>
120+
<td className="px-4 py-3">{p.asset}</td>
121+
<td className="px-4 py-3"><StatusBadge status={p.status} /></td>
122+
<td className="px-4 py-3 text-slate-400 text-xs">{p.anchorId}</td>
123+
</tr>
124+
))
125+
)}
126+
</tbody>
127+
</table>
128+
</div>
129+
</div>
130+
);
131+
}

0 commit comments

Comments
 (0)