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
67 changes: 67 additions & 0 deletions src/app/api/airdrop/leaderboard/route.ts
Original file line number Diff line number Diff line change
@@ -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<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;
}

// 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 });
}
113 changes: 113 additions & 0 deletions src/app/api/airdrop/points/route.ts
Original file line number Diff line number Diff line change
@@ -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,
});
}
37 changes: 37 additions & 0 deletions src/app/api/airdrop/snapshots/route.ts
Original file line number Diff line number Diff line change
@@ -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,
})),
});
}
77 changes: 77 additions & 0 deletions src/app/api/airdrop/status/route.ts
Original file line number Diff line number Diff line change
@@ -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<string>();
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,
});
}
Loading