From c105ae5eec023bb65260b64b2fc6266515b6476f Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Tue, 21 Apr 2026 10:44:08 +0900 Subject: [PATCH 1/3] [#885] Add points API endpoints for airdrop page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /api/airdrop/status — campaign overview, milestones, mcap, totals - GET /api/airdrop/points?address= — user breakdown, streak, referral, estimated airdrop - GET /api/airdrop/leaderboard?address= — top 50 by points with optional user rank - GET /api/airdrop/snapshots — weekly campaign stats history Fixes #885 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/api/airdrop/leaderboard/route.ts | 67 ++++++++++++++ src/app/api/airdrop/points/route.ts | 113 +++++++++++++++++++++++ src/app/api/airdrop/snapshots/route.ts | 37 ++++++++ src/app/api/airdrop/status/route.ts | 77 +++++++++++++++ 4 files changed, 294 insertions(+) create mode 100644 src/app/api/airdrop/leaderboard/route.ts create mode 100644 src/app/api/airdrop/points/route.ts create mode 100644 src/app/api/airdrop/snapshots/route.ts create mode 100644 src/app/api/airdrop/status/route.ts diff --git a/src/app/api/airdrop/leaderboard/route.ts b/src/app/api/airdrop/leaderboard/route.ts new file mode 100644 index 00000000..d2e476b7 --- /dev/null +++ b/src/app/api/airdrop/leaderboard/route.ts @@ -0,0 +1,67 @@ +/** + * Points leaderboard (#885) + * GET /api/airdrop/leaderboard?address=0x... (optional) + */ + +import { NextResponse, type NextRequest } from "next/server"; +import { createServerClient } from "../../../../../lib/supabase"; + +export async function GET(req: NextRequest) { + const supabase = createServerClient(); + if (!supabase) { + return NextResponse.json({ error: "Supabase not configured" }, { status: 500 }); + } + + const userAddress = req.nextUrl.searchParams.get("address")?.toLowerCase(); + + // 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 }); + } + + // Sum points by address + const pointsByAddress = new Map(); + 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; + } + + // Sort descending by points + 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); + + const { data: users } = await supabase + .from("pl_referral_codes") + .select("address, code, is_farcaster_username") + .in("address", top50Addresses); + + 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, + 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 + 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 }); +} diff --git a/src/app/api/airdrop/points/route.ts b/src/app/api/airdrop/points/route.ts new file mode 100644 index 00000000..04452fd9 --- /dev/null +++ b/src/app/api/airdrop/points/route.ts @@ -0,0 +1,113 @@ +/** + * 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"; + +export async function GET(req: NextRequest) { + const supabase = createServerClient(); + if (!supabase) { + return NextResponse.json({ error: "Supabase not configured" }, { status: 500 }); + } + + const address = req.nextUrl.searchParams.get("address")?.toLowerCase(); + if (!address) { + 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 breakdown = { buy: 0, referral: 0, write: 0, rate: 0 }; + let totalPoints = 0; + for (const row of points ?? []) { + 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 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 todayUtc = new Date().toISOString().slice(0, 10); + const checkedInToday = streak?.last_checkin + ? new Date(streak.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)), + } + : { bronze: 0, silver: 0, gold: 0 }; + + return NextResponse.json({ + address, + totalPoints: Math.round(totalPoints * 100) / 100, + sharePercent: Math.round(sharePercent * 100) / 100, + breakdown: { + buy: Math.round(breakdown.buy * 100) / 100, + referral: Math.round(breakdown.referral * 100) / 100, + write: Math.round(breakdown.write * 100) / 100, + rate: Math.round(breakdown.rate * 100) / 100, + }, + streak: { + currentStreak, + boostPercent, + nextTier, + checkedInToday, + lastCheckin: streak?.last_checkin ?? null, + }, + referral: { + code: referralCode?.code ?? null, + isFarcasterUsername: referralCode?.is_farcaster_username ?? false, + referredBy: referredBy?.referral_code ?? null, + referredUsersCount: referredUsersCount ?? 0, + }, + estimatedAirdrop, + }); +} diff --git a/src/app/api/airdrop/snapshots/route.ts b/src/app/api/airdrop/snapshots/route.ts new file mode 100644 index 00000000..df3bb1e8 --- /dev/null +++ b/src/app/api/airdrop/snapshots/route.ts @@ -0,0 +1,37 @@ +/** + * Weekly snapshot history (#885) + * GET /api/airdrop/snapshots + */ + +import { NextResponse } from "next/server"; +import { createServerClient } from "../../../../../lib/supabase"; + +export async function GET() { + const supabase = createServerClient(); + if (!supabase) { + return NextResponse.json({ error: "Supabase not configured" }, { status: 500 }); + } + + const { data: snapshots, error } = await supabase + .from("pl_weekly_snapshots") + .select("week_number, week_start, new_stories, token_buys, new_referrals, mcap_start, mcap_end, total_pl_earned") + .order("week_number", { ascending: false }); + + if (error) { + console.error("[airdrop/snapshots] Query failed:", error.message); + return NextResponse.json({ error: "Failed to fetch snapshots" }, { status: 500 }); + } + + return NextResponse.json({ + snapshots: (snapshots ?? []).map((s) => ({ + weekNumber: s.week_number, + weekStart: s.week_start, + newStories: s.new_stories, + tokenBuys: s.token_buys, + newReferrals: s.new_referrals, + mcapStart: s.mcap_start, + mcapEnd: s.mcap_end, + totalPlEarned: s.total_pl_earned, + })), + }); +} diff --git a/src/app/api/airdrop/status/route.ts b/src/app/api/airdrop/status/route.ts new file mode 100644 index 00000000..76e63535 --- /dev/null +++ b/src/app/api/airdrop/status/route.ts @@ -0,0 +1,77 @@ +/** + * Campaign status overview (#885) + * GET /api/airdrop/status — no auth required + */ + +import { NextResponse } from "next/server"; +import { createServerClient } from "../../../../../lib/supabase"; +import { AIRDROP_CONFIG } from "../../../../../lib/airdrop/config"; + +export async function GET() { + const supabase = createServerClient(); + if (!supabase) { + return NextResponse.json({ error: "Supabase not configured" }, { status: 500 }); + } + + const now = new Date(); + const start = AIRDROP_CONFIG.CAMPAIGN_START; + const end = AIRDROP_CONFIG.CAMPAIGN_END; + const totalMs = end.getTime() - start.getTime(); + const elapsedMs = Math.max(0, now.getTime() - start.getTime()); + const remainingMs = Math.max(0, end.getTime() - now.getTime()); + + // Latest price from pl_daily_prices + const { data: latestPrice } = await supabase + .from("pl_daily_prices") + .select("price_usd, mcap_usd") + .order("recorded_at", { ascending: false }) + .limit(1) + .single(); + + // Total points earned across all users + const { data: totalPointsData } = await supabase + .from("pl_points") + .select("points") + .then(({ data }) => ({ + data: data?.reduce((sum, r) => sum + r.points, 0) ?? 0, + })); + + // Total unique participants + const { count: totalParticipants } = await supabase + .from("pl_points") + .select("address", { count: "exact", head: true }); + + // Milestone status + const currentMcap = latestPrice?.mcap_usd ?? 0; + const milestones = { + bronze: { + mcap: AIRDROP_CONFIG.MILESTONES.BRONZE.mcap, + pct: AIRDROP_CONFIG.MILESTONES.BRONZE.pct, + reached: currentMcap >= AIRDROP_CONFIG.MILESTONES.BRONZE.mcap, + }, + silver: { + mcap: AIRDROP_CONFIG.MILESTONES.SILVER.mcap, + pct: AIRDROP_CONFIG.MILESTONES.SILVER.pct, + reached: currentMcap >= AIRDROP_CONFIG.MILESTONES.SILVER.mcap, + }, + gold: { + mcap: AIRDROP_CONFIG.MILESTONES.GOLD.mcap, + pct: AIRDROP_CONFIG.MILESTONES.GOLD.pct, + reached: currentMcap >= AIRDROP_CONFIG.MILESTONES.GOLD.mcap, + }, + }; + + return NextResponse.json({ + campaignStart: start.toISOString().slice(0, 10), + campaignEnd: end.toISOString().slice(0, 10), + timeRemainingDays: Math.ceil(remainingMs / (1000 * 60 * 60 * 24)), + timeElapsedPercent: totalMs > 0 ? Math.round((elapsedMs / totalMs) * 100) : 0, + poolAmount: AIRDROP_CONFIG.POOL_AMOUNT, + currentMcap, + latestPriceUsd: latestPrice?.price_usd ?? null, + milestones, + totalPointsEarned: totalPointsData ?? 0, + totalParticipants: totalParticipants ?? 0, + lockerId: AIRDROP_CONFIG.LOCKER_ID, + }); +} From dd645ab7bb899393202d691d17e8f3a09f34a9d8 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Tue, 21 Apr 2026 10:45:36 +0900 Subject: [PATCH 2/3] [#885] Fix totalParticipants to count distinct addresses Use Set-based dedup instead of row count to get unique participant count. Also simplified total points calculation into single query. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/api/airdrop/status/route.ts | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/app/api/airdrop/status/route.ts b/src/app/api/airdrop/status/route.ts index 76e63535..cfbbaf40 100644 --- a/src/app/api/airdrop/status/route.ts +++ b/src/app/api/airdrop/status/route.ts @@ -28,18 +28,18 @@ export async function GET() { .limit(1) .single(); - // Total points earned across all users - const { data: totalPointsData } = await supabase + // Total points earned + unique participants + const { data: allPoints } = await supabase .from("pl_points") - .select("points") - .then(({ data }) => ({ - data: data?.reduce((sum, r) => sum + r.points, 0) ?? 0, - })); + .select("address, points"); - // Total unique participants - const { count: totalParticipants } = await supabase - .from("pl_points") - .select("address", { count: "exact", head: true }); + let totalPointsEarned = 0; + const uniqueAddresses = new Set(); + for (const row of allPoints ?? []) { + totalPointsEarned += row.points; + uniqueAddresses.add(row.address); + } + const totalParticipants = uniqueAddresses.size; // Milestone status const currentMcap = latestPrice?.mcap_usd ?? 0; @@ -70,8 +70,8 @@ export async function GET() { currentMcap, latestPriceUsd: latestPrice?.price_usd ?? null, milestones, - totalPointsEarned: totalPointsData ?? 0, - totalParticipants: totalParticipants ?? 0, + totalPointsEarned, + totalParticipants, lockerId: AIRDROP_CONFIG.LOCKER_ID, }); } From 70e74db209c3aa71a7a94ec51361bf674d298b13 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Tue, 21 Apr 2026 10:46:41 +0900 Subject: [PATCH 3/3] [#885] Clamp timeElapsedPercent to 100 after campaign end Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/api/airdrop/status/route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/api/airdrop/status/route.ts b/src/app/api/airdrop/status/route.ts index cfbbaf40..01618846 100644 --- a/src/app/api/airdrop/status/route.ts +++ b/src/app/api/airdrop/status/route.ts @@ -65,7 +65,7 @@ export async function GET() { campaignStart: start.toISOString().slice(0, 10), campaignEnd: end.toISOString().slice(0, 10), timeRemainingDays: Math.ceil(remainingMs / (1000 * 60 * 60 * 24)), - timeElapsedPercent: totalMs > 0 ? Math.round((elapsedMs / totalMs) * 100) : 0, + timeElapsedPercent: totalMs > 0 ? Math.min(100, Math.round((elapsedMs / totalMs) * 100)) : 0, poolAmount: AIRDROP_CONFIG.POOL_AMOUNT, currentMcap, latestPriceUsd: latestPrice?.price_usd ?? null,