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..01618846 --- /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 + unique participants + const { data: allPoints } = await supabase + .from("pl_points") + .select("address, points"); + + 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; + 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.min(100, Math.round((elapsedMs / totalMs) * 100)) : 0, + poolAmount: AIRDROP_CONFIG.POOL_AMOUNT, + currentMcap, + latestPriceUsd: latestPrice?.price_usd ?? null, + milestones, + totalPointsEarned, + totalParticipants, + lockerId: AIRDROP_CONFIG.LOCKER_ID, + }); +}