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
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "plotlink",
"version": "1.33.1",
"version": "1.34.0",
"private": true,
"workspaces": [
"packages/*"
Expand Down
70 changes: 36 additions & 34 deletions src/app/api/airdrop/leaderboard/route.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
/**
* Points leaderboard (#885)
* GET /api/airdrop/leaderboard?address=0x... (optional)
*/

import { NextResponse, type NextRequest } from "next/server";
import { createServerClient } from "../../../../../lib/supabase";
import { getAirdropConfig } from "../../../../../lib/airdrop/config";

export async function GET(req: NextRequest) {
const supabase = createServerClient();
Expand All @@ -16,37 +12,39 @@ export async function GET(req: NextRequest) {
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");
const config = getAirdropConfig();

if (!allPoints || allPoints.length === 0) {
return NextResponse.json({ entries: [], userRank: null, totalParticipants: 0, page: 1, totalPages: 0, limit });
}
const { data: rows, error } = await supabase.rpc("weighted_spend", {
p_campaign_start: config.CAMPAIGN_START.toISOString(),
p_campaign_end: config.CAMPAIGN_END.toISOString(),
p_min_referral_threshold: config.MIN_REFERRAL_THRESHOLD,
p_multiplier_per_ref: config.REFERRAL_MULTIPLIER_PER_REF,
p_multiplier_cap: config.REFERRAL_MULTIPLIER_CAP,
});

// Sum points by address
const pointsByAddress = new Map<string, number>();
let globalTotal = 0;
for (const row of allPoints) {
const addr = row.address.toLowerCase();
pointsByAddress.set(addr, (pointsByAddress.get(addr) ?? 0) + row.points);
globalTotal += row.points;
if (error) {
console.error("[leaderboard] weighted_spend RPC failed:", error.message);
return NextResponse.json({ error: "Failed to compute leaderboard" }, { status: 500 });
}

// Sort descending by points
const sorted = [...pointsByAddress.entries()]
.sort((a, b) => b[1] - a[1]);
const allRows = (rows ?? []) as Array<{
address: string;
weighted_spend: number;
buy_volume: number;
qualified_refs: number;
has_fc_bonus: number;
multiplier: number;
community_total: number;
}>;

// Paginate
const totalParticipants = pointsByAddress.size;
const sorted = [...allRows].sort((a, b) => Number(b.weighted_spend) - Number(a.weighted_spend));

const totalParticipants = sorted.length;
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 pageAddresses = pageSlice.map(r => r.address);
const { data: users } = await supabase
.from("pl_referral_codes")
.select("address, code, is_farcaster_username")
Expand All @@ -56,18 +54,22 @@ export async function GET(req: NextRequest) {
(users ?? []).map((u) => [u.address.toLowerCase(), u.is_farcaster_username ? u.code : null]),
);

const entries = pageSlice.map(([addr, pts], i) => ({
const communityTotal = Number(sorted[0]?.community_total ?? 0);

const entries = pageSlice.map((row, 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,
address: row.address,
username: usernameMap.get(row.address) ?? null,
weighted_spend: Number(row.weighted_spend),
totalPoints: Number(row.weighted_spend),
buy_volume: Number(row.buy_volume),
multiplier: Number(row.multiplier),
sharePercent: communityTotal > 0 ? Math.round((Number(row.weighted_spend) / communityTotal) * 10000) / 100 : 0,
}));

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

Expand Down
96 changes: 34 additions & 62 deletions src/app/api/airdrop/points/route.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,6 @@
/**
* User points breakdown (#885)
* GET /api/airdrop/points?address=0x...
*/

import { NextResponse, type NextRequest } from "next/server";
import { createServerClient } from "../../../../../lib/supabase";
import { AIRDROP_CONFIG } from "../../../../../lib/airdrop/config";
import { getStreakBoost, getNextTier } from "../../../../../lib/airdrop/streak";
import { getAirdropConfig } from "../../../../../lib/airdrop/config";

export async function GET(req: NextRequest) {
const supabase = createServerClient();
Expand All @@ -19,70 +13,42 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ error: "Missing address param" }, { status: 400 });
}

// Points breakdown by action
const { data: points } = await supabase
.from("pl_points")
.select("action, points")
.eq("address", address);
const config = getAirdropConfig();

const [pointsRes, allPointsRes, streakRes, referralCodeRes, referredByRes, referredCountRes] = await Promise.all([
supabase.from("pl_points").select("action, points").eq("address", address),
supabase.from("pl_points").select("points"),
supabase.from("pl_streaks").select("current_streak, last_checkin, longest_streak").eq("address", address).single(),
supabase.from("pl_referral_codes").select("code, is_farcaster_username").eq("address", address).single(),
supabase.from("pl_referrals").select("referral_code").eq("referred_address", address).single(),
supabase.from("pl_referrals").select("id", { count: "exact", head: true }).eq("referrer_address", address),
]);

const breakdown = { buy: 0, referral: 0, write: 0, rate: 0 };
let totalPoints = 0;
for (const row of points ?? []) {
for (const row of pointsRes.data ?? []) {
const action = row.action as keyof typeof breakdown;
if (action in breakdown) {
breakdown[action] += row.points;
}
totalPoints += row.points;
}

// Total points across all users (for share %)
const { data: allPoints } = await supabase
.from("pl_points")
.select("points");
const globalTotal = (allPoints ?? []).reduce((sum, r) => sum + r.points, 0);
const globalTotal = (allPointsRes.data ?? []).reduce((sum, r) => sum + r.points, 0);
const sharePercent = globalTotal > 0 ? (totalPoints / globalTotal) * 100 : 0;

// Streak info
const { data: streak } = await supabase
.from("pl_streaks")
.select("current_streak, last_checkin, longest_streak")
.eq("address", address)
.single();

const currentStreak = streak?.current_streak ?? 0;
const boostPercent = getStreakBoost(currentStreak) * 100;
const nextTier = getNextTier(currentStreak);

const currentStreak = streakRes.data?.current_streak ?? 0;
const todayUtc = new Date().toISOString().slice(0, 10);
const checkedInToday = streak?.last_checkin
? new Date(streak.last_checkin).toISOString().slice(0, 10) === todayUtc
const checkedInToday = streakRes.data?.last_checkin
? new Date(streakRes.data.last_checkin).toISOString().slice(0, 10) === todayUtc
: false;

// Referral info
const { data: referralCode } = await supabase
.from("pl_referral_codes")
.select("code, is_farcaster_username")
.eq("address", address)
.single();

const { data: referredBy } = await supabase
.from("pl_referrals")
.select("referral_code")
.eq("referred_address", address)
.single();

const { count: referredUsersCount } = await supabase
.from("pl_referrals")
.select("id", { count: "exact", head: true })
.eq("referrer_address", address);

// Estimated airdrop per milestone tier
const estimatedAirdrop = sharePercent > 0
? {
bronze: Math.round((sharePercent / 100) * AIRDROP_CONFIG.POOL_AMOUNT * (AIRDROP_CONFIG.MILESTONES.BRONZE.pct / 100)),
silver: Math.round((sharePercent / 100) * AIRDROP_CONFIG.POOL_AMOUNT * (AIRDROP_CONFIG.MILESTONES.SILVER.pct / 100)),
gold: Math.round((sharePercent / 100) * AIRDROP_CONFIG.POOL_AMOUNT * (AIRDROP_CONFIG.MILESTONES.GOLD.pct / 100)),
diamond: Math.round((sharePercent / 100) * AIRDROP_CONFIG.POOL_AMOUNT * (AIRDROP_CONFIG.MILESTONES.DIAMOND.pct / 100)),
bronze: Math.round((sharePercent / 100) * config.POOL_AMOUNT * (config.MILESTONES.BRONZE.pct / 100)),
silver: Math.round((sharePercent / 100) * config.POOL_AMOUNT * (config.MILESTONES.SILVER.pct / 100)),
gold: Math.round((sharePercent / 100) * config.POOL_AMOUNT * (config.MILESTONES.GOLD.pct / 100)),
diamond: Math.round((sharePercent / 100) * config.POOL_AMOUNT * (config.MILESTONES.DIAMOND.pct / 100)),
}
: { bronze: 0, silver: 0, gold: 0, diamond: 0 };

Expand All @@ -98,19 +64,25 @@ export async function GET(req: NextRequest) {
},
streak: {
currentStreak,
boostPercent,
nextTier,
boostPercent: 0,
nextTier: null,
checkedInToday,
lastCheckin: streak?.last_checkin ?? null,
lastCheckin: streakRes.data?.last_checkin ?? null,
},
referral: {
code: referralCode?.code ?? null,
isFarcasterUsername: referralCode?.is_farcaster_username ?? false,
referredBy: referredBy?.referral_code ?? null,
referredUsersCount: referredUsersCount ?? 0,
code: referralCodeRes.data?.code ?? null,
isFarcasterUsername: referralCodeRes.data?.is_farcaster_username ?? false,
referredBy: referredByRes.data?.referral_code ?? null,
referredUsersCount: referredCountRes.count ?? 0,
},
estimatedAirdrop,
buy_volume_plot: breakdown.buy,
fetched_at: new Date().toISOString(),
}, {
headers: { "Cache-Control": "public, s-maxage=10, stale-while-revalidate=5" },
headers: {
"Cache-Control": "public, s-maxage=10, stale-while-revalidate=5",
"Deprecation": "true",
"Link": "</api/airdrop/projection>; rel=\"successor-version\"",
},
});
}
Loading
Loading