Skip to content

Commit 64a1fe5

Browse files
committed
feat(#105): Build invoice creator leaderboard
- Add /leaderboard/creators page accessible without wallet connection - Aggregate creator stats from invoices 1-200 - Display top 20 creators ranked by total USDC invoiced - Show rank, address, total USDC, invoice count, and completion rate - Highlight connected wallet's rank if in top 20 - Loading skeleton shown while aggregating data - Mobile responsive design
1 parent af7b4d0 commit 64a1fe5

1 file changed

Lines changed: 178 additions & 0 deletions

File tree

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
"use client";
2+
3+
import { useEffect, useMemo, useState } from "react";
4+
import { splitClient } from "@/lib/stellar";
5+
import { getFreighterPublicKey } from "@/lib/freighter";
6+
import { formatAmount, truncateAddress } from "@stellar-split/sdk";
7+
import type { Invoice } from "@stellar-split/sdk";
8+
9+
type CreatorStats = {
10+
address: string;
11+
totalUSDC: bigint;
12+
invoiceCount: number;
13+
completedCount: number;
14+
};
15+
16+
export default function CreatorLeaderboardPage() {
17+
const [publicKey, setPublicKey] = useState<string | null>(null);
18+
const [loading, setLoading] = useState(true);
19+
const [error, setError] = useState<string | null>(null);
20+
const [rows, setRows] = useState<(CreatorStats & { rank: number; completionRate: number })[]>([]);
21+
22+
useEffect(() => {
23+
getFreighterPublicKey()
24+
.then(setPublicKey)
25+
.catch(() => setPublicKey(null));
26+
}, []);
27+
28+
useEffect(() => {
29+
let cancelled = false;
30+
31+
const run = async () => {
32+
setLoading(true);
33+
setError(null);
34+
35+
try {
36+
const invoiceList: Invoice[] = [];
37+
for (let id = 1; id <= 200; id++) {
38+
try {
39+
const inv = await splitClient.getInvoice(String(id));
40+
invoiceList.push(inv);
41+
} catch {
42+
break;
43+
}
44+
}
45+
46+
const creatorMap = new Map<
47+
string,
48+
{ totalUSDC: bigint; invoiceIds: Set<string>; completedCount: number }
49+
>();
50+
51+
invoiceList.forEach((inv) => {
52+
const total = inv.recipients.reduce((s, r) => s + r.amount, 0n);
53+
const existing = creatorMap.get(inv.creator) ?? {
54+
totalUSDC: 0n,
55+
invoiceIds: new Set(),
56+
completedCount: 0,
57+
};
58+
59+
existing.totalUSDC += total;
60+
existing.invoiceIds.add(inv.id);
61+
if (inv.status === "Released") {
62+
existing.completedCount += 1;
63+
}
64+
65+
creatorMap.set(inv.creator, existing);
66+
});
67+
68+
const stats: (CreatorStats & { rank: number; completionRate: number })[] = Array.from(
69+
creatorMap.entries()
70+
)
71+
.map(([address, data]) => ({
72+
address,
73+
totalUSDC: data.totalUSDC,
74+
invoiceCount: data.invoiceIds.size,
75+
completedCount: data.completedCount,
76+
rank: 0,
77+
completionRate: data.invoiceIds.size > 0 ? (data.completedCount / data.invoiceIds.size) * 100 : 0,
78+
}))
79+
.sort((a, b) => {
80+
if (b.totalUSDC !== a.totalUSDC) return Number(b.totalUSDC - a.totalUSDC);
81+
return b.invoiceCount - a.invoiceCount;
82+
})
83+
.slice(0, 20)
84+
.map((stat, i) => ({ ...stat, rank: i + 1 }));
85+
86+
if (!cancelled) {
87+
setRows(stats);
88+
}
89+
} catch (err) {
90+
if (!cancelled) {
91+
setError(String(err));
92+
}
93+
} finally {
94+
if (!cancelled) {
95+
setLoading(false);
96+
}
97+
}
98+
};
99+
100+
run();
101+
return () => {
102+
cancelled = true;
103+
};
104+
}, []);
105+
106+
const userRank = useMemo(() => {
107+
if (!publicKey) return null;
108+
return rows.find((r) => r.address === publicKey);
109+
}, [rows, publicKey]);
110+
111+
if (error) {
112+
return (
113+
<main className="max-w-4xl mx-auto w-full px-4 sm:px-6 py-16 overflow-x-hidden">
114+
<h1 className="text-3xl font-bold mb-4">Creator Leaderboard</h1>
115+
<p className="text-red-400" role="alert">{error}</p>
116+
</main>
117+
);
118+
}
119+
120+
return (
121+
<main className="max-w-4xl mx-auto w-full px-4 sm:px-6 py-16 overflow-x-hidden">
122+
<h1 className="text-3xl font-bold mb-2">Creator Leaderboard</h1>
123+
<p className="text-gray-400 mb-8">Top 20 creators by total USDC invoiced</p>
124+
125+
{loading ? (
126+
<div className="space-y-3">
127+
{[...Array(5)].map((_, i) => (
128+
<div key={i} className="h-12 bg-gray-800 rounded-lg animate-pulse" />
129+
))}
130+
</div>
131+
) : (
132+
<div className="overflow-x-auto">
133+
<table className="w-full text-sm">
134+
<thead>
135+
<tr className="border-b border-gray-700">
136+
<th className="text-left py-3 px-4 font-semibold text-gray-300">Rank</th>
137+
<th className="text-left py-3 px-4 font-semibold text-gray-300">Creator</th>
138+
<th className="text-right py-3 px-4 font-semibold text-gray-300">Total USDC</th>
139+
<th className="text-right py-3 px-4 font-semibold text-gray-300">Invoices</th>
140+
<th className="text-right py-3 px-4 font-semibold text-gray-300">Completion Rate</th>
141+
</tr>
142+
</thead>
143+
<tbody>
144+
{rows.map((row) => (
145+
<tr
146+
key={row.address}
147+
className={`border-b border-gray-800 hover:bg-gray-900/50 transition-colors ${
148+
userRank?.address === row.address ? "bg-indigo-900/20" : ""
149+
}`}
150+
>
151+
<td className="py-3 px-4 font-semibold text-indigo-400">#{row.rank}</td>
152+
<td className="py-3 px-4 font-mono text-gray-300 min-w-0">
153+
<span className="sm:hidden">{truncateAddress(row.address)}</span>
154+
<span className="hidden sm:inline truncate">{row.address}</span>
155+
</td>
156+
<td className="py-3 px-4 text-right text-indigo-300 font-semibold">
157+
{formatAmount(row.totalUSDC)}
158+
</td>
159+
<td className="py-3 px-4 text-right text-gray-300">{row.invoiceCount}</td>
160+
<td className="py-3 px-4 text-right text-gray-300">{row.completionRate.toFixed(1)}%</td>
161+
</tr>
162+
))}
163+
</tbody>
164+
</table>
165+
</div>
166+
)}
167+
168+
{userRank && (
169+
<div className="mt-8 p-4 bg-indigo-900/20 border border-indigo-700 rounded-lg">
170+
<p className="text-sm text-gray-300">
171+
Your rank: <span className="font-semibold text-indigo-300">#{userRank.rank}</span> with{" "}
172+
<span className="font-semibold text-indigo-300">{formatAmount(userRank.totalUSDC)} USDC</span> invoiced
173+
</p>
174+
</div>
175+
)}
176+
</main>
177+
);
178+
}

0 commit comments

Comments
 (0)