Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 16 additions & 8 deletions src/app/api/airdrop/leaderboard/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,16 @@ export async function GET(req: NextRequest) {
}

const userAddress = req.nextUrl.searchParams.get("address")?.toLowerCase();
const page = Math.max(1, parseInt(req.nextUrl.searchParams.get("page") ?? "1", 10) || 1);
const limit = Math.min(50, Math.max(1, parseInt(req.nextUrl.searchParams.get("limit") ?? "20", 10) || 20));

// Aggregate points per address
const { data: allPoints } = await supabase
.from("pl_points")
.select("address, points");

if (!allPoints || allPoints.length === 0) {
return NextResponse.json({ entries: [], userRank: null, totalParticipants: 0 });
return NextResponse.json({ entries: [], userRank: null, totalParticipants: 0, page: 1, totalPages: 0, limit });
}

// Sum points by address
Expand All @@ -36,34 +38,40 @@ export async function GET(req: NextRequest) {
const sorted = [...pointsByAddress.entries()]
.sort((a, b) => b[1] - a[1]);

// Look up usernames for top 50
const top50Addresses = sorted.slice(0, 50).map(([addr]) => addr);
// Paginate
const totalParticipants = pointsByAddress.size;
const totalPages = Math.ceil(totalParticipants / limit);
const start = (page - 1) * limit;
const pageSlice = sorted.slice(start, start + limit);

// Look up usernames for current page
const pageAddresses = pageSlice.map(([addr]) => addr);

const { data: users } = await supabase
.from("pl_referral_codes")
.select("address, code, is_farcaster_username")
.in("address", top50Addresses);
.in("address", pageAddresses);

const usernameMap = new Map(
(users ?? []).map((u) => [u.address.toLowerCase(), u.is_farcaster_username ? u.code : null]),
);

const entries = sorted.slice(0, 50).map(([addr, pts], i) => ({
rank: i + 1,
const entries = pageSlice.map(([addr, pts], i) => ({
rank: start + i + 1,
address: addr,
username: usernameMap.get(addr) ?? null,
totalPoints: Math.round(pts * 100) / 100,
sharePercent: globalTotal > 0 ? Math.round((pts / globalTotal) * 10000) / 100 : 0,
}));

// Find user's rank if requested and not in top 50
// Find user's rank if requested
let userRank: number | null = null;
if (userAddress) {
const idx = sorted.findIndex(([addr]) => addr === userAddress);
userRank = idx >= 0 ? idx + 1 : null;
}

return NextResponse.json({ entries, userRank, totalParticipants: pointsByAddress.size }, {
return NextResponse.json({ entries, userRank, totalParticipants, page, totalPages, limit }, {
headers: { "Cache-Control": "public, s-maxage=30, stale-while-revalidate=15" },
});
}
43 changes: 36 additions & 7 deletions src/components/airdrop/Leaderboard.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"use client";

import { useState } from "react";
import { useAccount } from "wagmi";
import { useQuery } from "@tanstack/react-query";

Expand All @@ -15,6 +16,9 @@ interface LeaderboardData {
entries: LeaderboardEntry[];
userRank: number | null;
totalParticipants: number;
page: number;
totalPages: number;
limit: number;
}

function truncateAddress(addr: string) {
Expand All @@ -23,12 +27,14 @@ function truncateAddress(addr: string) {

export function Leaderboard() {
const { address, isConnected } = useAccount();
const [page, setPage] = useState(1);

const { data, isLoading } = useQuery<LeaderboardData>({
queryKey: ["airdrop-leaderboard", address],
queryKey: ["airdrop-leaderboard", address, page],
queryFn: async () => {
const params = address ? `?address=${address.toLowerCase()}` : "";
const res = await fetch(`/api/airdrop/leaderboard${params}`);
const params = new URLSearchParams({ page: String(page), limit: "20" });
if (address) params.set("address", address.toLowerCase());
const res = await fetch(`/api/airdrop/leaderboard?${params}`);
if (!res.ok) throw new Error("Failed to fetch leaderboard");
return res.json();
},
Expand All @@ -44,7 +50,7 @@ export function Leaderboard() {
);
}

if (data.entries.length === 0) {
if (data.entries.length === 0 && data.totalParticipants === 0) {
return (
<div className="border-border rounded border p-4">
<h3 className="text-foreground text-sm font-bold mb-2">Leaderboard</h3>
Expand All @@ -54,7 +60,7 @@ export function Leaderboard() {
}

const userAddr = address?.toLowerCase();
const inTop50 = userAddr && data.entries.some((e) => e.address.toLowerCase() === userAddr);
const onCurrentPage = userAddr && data.entries.some((e) => e.address.toLowerCase() === userAddr);

return (
<div className="border-border rounded border p-4">
Expand Down Expand Up @@ -99,8 +105,31 @@ export function Leaderboard() {
</table>
</div>

{/* User's rank if outside top 50 */}
{isConnected && !inTop50 && data.userRank && (
{/* Pagination */}
{data.totalPages > 1 && (
<div className="border-border mt-3 border-t pt-2 flex items-center justify-center gap-3 text-xs">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={data.page <= 1}
className="text-accent disabled:text-muted disabled:cursor-not-allowed"
>
&larr; Prev
</button>
<span className="text-muted">
{data.page}/{data.totalPages}
</span>
<button
onClick={() => setPage((p) => Math.min(data.totalPages, p + 1))}
disabled={data.page >= data.totalPages}
className="text-accent disabled:text-muted disabled:cursor-not-allowed"
>
Next &rarr;
</button>
</div>
)}

{/* User's rank if outside current page */}
{isConnected && !onCurrentPage && data.userRank && (
<div className="border-border mt-3 border-t pt-2 text-center text-xs">
<span className="text-muted">Your rank: </span>
<span className="text-foreground font-medium">#{data.userRank}</span>
Expand Down
Loading