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
6 changes: 5 additions & 1 deletion src/app/airdrop/page.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,24 @@
import type { Metadata } from "next";
import { CampaignHero } from "../../components/airdrop/CampaignHero";
import { UserPoints } from "../../components/airdrop/UserPoints";
import { ClaimPanel } from "../../components/airdrop/ClaimPanel";
import { Leaderboard } from "../../components/airdrop/Leaderboard";
import { WeeklySnapshots } from "../../components/airdrop/WeeklySnapshots";
import { MilestoneTrack } from "../../components/airdrop/MilestoneTrack";
import { AIRDROP_CONFIG } from "../../../lib/airdrop/config";

export const metadata: Metadata = {
title: "PLOT 10x Airdrop | PlotLink",
description: "Earn PL points through trading, writing, and referrals.",
};

export default function AirdropPage() {
const campaignEnded = new Date() > AIRDROP_CONFIG.CAMPAIGN_END;

return (
<main className="mx-auto max-w-xl px-4 py-8 space-y-6">
<CampaignHero />
<UserPoints />
{campaignEnded ? <ClaimPanel /> : <UserPoints />}
<Leaderboard />
<WeeklySnapshots />
<MilestoneTrack />
Expand Down
37 changes: 37 additions & 0 deletions src/app/api/airdrop/proof/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/**
* Merkle proof for airdrop claim (#894)
* GET /api/airdrop/proof?address=0x...
*/

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 address = req.nextUrl.searchParams.get("address")?.toLowerCase();
if (!address) {
return NextResponse.json({ error: "Missing address param" }, { status: 400 });
}

const { data, error } = await supabase
.from("pl_airdrop_proofs" as never)
.select("amount, proof, merkle_root")
.eq("address" as never, address)
.single() as { data: { amount: string; proof: string; merkle_root: string } | null; error: unknown };

if (error || !data) {
return NextResponse.json({ eligible: false, amount: null, proof: null, claimed: false });
}

return NextResponse.json({
eligible: true,
amount: data.amount,
proof: JSON.parse(data.proof),
merkleRoot: data.merkle_root,
claimed: false, // On-chain claim status checked client-side via contract read
});
}
59 changes: 59 additions & 0 deletions src/app/api/airdrop/results/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/**
* Finalized campaign results (#894)
* GET /api/airdrop/results
*
* Derives final distribution from pl_airdrop_proofs (written by finalize script).
*/

import { NextResponse } from "next/server";
import { createServerClient } from "../../../../../lib/supabase";
import { AIRDROP_CONFIG } from "../../../../../lib/airdrop/config";
import { formatUnits } from "viem";

export async function GET() {
const supabase = createServerClient();
if (!supabase) {
return NextResponse.json({ error: "Supabase not configured" }, { status: 500 });
}

// Read all finalized proof amounts
const { data, error } = await supabase
.from("pl_airdrop_proofs" as never)
.select("amount") as { data: { amount: string }[] | null; error: unknown };

if (error || !data || data.length === 0) {
return NextResponse.json({ finalized: false });
}

// Sum all distributed amounts (stored as wei strings)
let totalDistributedWei = BigInt(0);
for (const row of data) {
totalDistributedWei += BigInt(row.amount);
}
const distributedPlot = Number(formatUnits(totalDistributedWei, 18));

const poolAmount = AIRDROP_CONFIG.POOL_AMOUNT;
const burnedPlot = poolAmount - distributedPlot;

// Determine milestone from distributed percentage
const distributedPct = (distributedPlot / poolAmount) * 100;
let milestone: string;
if (distributedPct >= AIRDROP_CONFIG.MILESTONES.GOLD.pct - 0.1) {
milestone = "Gold";
} else if (distributedPct >= AIRDROP_CONFIG.MILESTONES.SILVER.pct - 0.1) {
milestone = "Silver";
} else if (distributedPct >= AIRDROP_CONFIG.MILESTONES.BRONZE.pct - 0.1) {
milestone = "Bronze";
} else {
milestone = "None";
}

return NextResponse.json({
finalized: true,
milestone,
distributedPct: Math.round(distributedPct),
distributedPlot: Math.round(distributedPlot),
burnedPlot: Math.round(burnedPlot),
recipients: data.length,
});
}
239 changes: 239 additions & 0 deletions src/components/airdrop/ClaimPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
"use client";

import { useState } from "react";
import { useAccount, useReadContract, useWriteContract, useWaitForTransactionReceipt } from "wagmi";
import { useQuery } from "@tanstack/react-query";
import { formatUnits } from "viem";
import { formatUsdValue } from "../../../lib/usd-price";
import { EXPLORER_URL } from "../../../lib/contracts/constants";

const MERKLE_CLAIM_ADDRESS = (process.env.NEXT_PUBLIC_MERKLE_CLAIM_ADDRESS ?? "0x0000000000000000000000000000000000000000") as `0x${string}`;

const MERKLE_CLAIM_ABI = [
{
type: "function",
name: "claim",
stateMutability: "nonpayable",
inputs: [
{ name: "amount", type: "uint256" },
{ name: "proof", type: "bytes32[]" },
],
outputs: [],
},
{
type: "function",
name: "claimed",
stateMutability: "view",
inputs: [{ name: "", type: "address" }],
outputs: [{ name: "", type: "bool" }],
},
] as const;

interface ResultsData {
finalized: boolean;
milestone: string;
distributedPct: number;
distributedPlot: number;
burnedPlot: number;
recipients: number;
}

const BURN_TX = process.env.NEXT_PUBLIC_BURN_TX ?? null;

function CampaignResults() {
const { data } = useQuery<ResultsData>({
queryKey: ["airdrop-results"],
queryFn: async () => {
const res = await fetch("/api/airdrop/results");
if (!res.ok) throw new Error("Failed to fetch results");
return res.json();
},
staleTime: Infinity,
});

if (!data || !data.finalized) return null;

return (
<div className="border-border rounded border p-4 space-y-2">
<div className="flex items-center gap-2">
<span className="bg-accent text-bg rounded px-2 py-0.5 text-[10px] font-bold">CAMPAIGN COMPLETE</span>
</div>
<div className="text-xs space-y-1">
<div className="text-muted">
Milestone achieved:{" "}
<span className="text-foreground font-medium">
{data.milestone === "None" ? "None — full burn" : `${data.milestone} (${data.distributedPct}%)`}
</span>
</div>
<div className="text-muted">
Distributed:{" "}
<span className="text-foreground font-medium">{data.distributedPlot.toLocaleString()} PLOT</span>
<span className="text-muted"> to {data.recipients} recipients</span>
</div>
<div className="text-muted">
Burned:{" "}
<span className="text-foreground font-medium">{data.burnedPlot.toLocaleString()} PLOT</span>
</div>
{BURN_TX && (
<a
href={`${EXPLORER_URL}/tx/${BURN_TX}`}
target="_blank"
rel="noopener noreferrer"
className="text-accent text-xs hover:underline"
>
View burn transaction
</a>
)}
</div>
</div>
);
}

interface ProofData {
eligible: boolean;
amount: string | null;
proof: string[] | null;
merkleRoot: string | null;
}

interface StatusData {
latestPriceUsd: number | null;
}

export function ClaimPanel() {
const { address, isConnected } = useAccount();

return (
<>
<CampaignResults />
{!isConnected || !address ? (
<div className="border-border rounded border p-4 text-center">
<p className="text-muted text-sm">Connect your wallet to check your claim.</p>
</div>
) : (
<ClaimPanelInner address={address} />
)}
</>
);
}

function ClaimPanelInner({ address }: { address: string }) {
const [txHash, setTxHash] = useState<`0x${string}` | null>(null);

// Fetch proof from API
const { data: proofData, isLoading } = useQuery<ProofData>({
queryKey: ["airdrop-proof", address],
queryFn: async () => {
const res = await fetch(`/api/airdrop/proof?address=${address.toLowerCase()}`);
if (!res.ok) throw new Error("Failed to fetch proof");
return res.json();
},
staleTime: Infinity,
});

// Fetch price for USD display
const { data: statusData } = useQuery<StatusData>({
queryKey: ["airdrop-status"],
queryFn: async () => {
const res = await fetch("/api/airdrop/status");
if (!res.ok) throw new Error("Failed to fetch status");
return res.json();
},
staleTime: 60_000,
});

// Check on-chain claimed status
const { data: hasClaimed } = useReadContract({
address: MERKLE_CLAIM_ADDRESS,
abi: MERKLE_CLAIM_ABI,
functionName: "claimed",
args: [address as `0x${string}`],
query: {
enabled: !!proofData?.eligible,
},
});

// Write contract for claim
const { writeContract, isPending: isClaiming } = useWriteContract();

// Wait for tx confirmation
const { isLoading: isConfirming, isSuccess: isConfirmed } = useWaitForTransactionReceipt({
hash: txHash ?? undefined,
});

if (isLoading || !proofData) {
return (
<div className="border-border rounded border p-4">
<div className="text-muted text-sm">Checking claim eligibility...</div>
</div>
);
}

// Not eligible
if (!proofData.eligible || !proofData.amount || !proofData.proof) {
return (
<div className="border-border rounded border p-4 text-center">
<div className="text-foreground text-sm font-bold mb-1">Campaign Complete</div>
<p className="text-muted text-xs">You did not earn any PLOT in this campaign.</p>
</div>
);
}

const amountFormatted = formatUnits(BigInt(proofData.amount), 18);
const amountDisplay = Number(amountFormatted).toLocaleString(undefined, { maximumFractionDigits: 2 });
const price = statusData?.latestPriceUsd ?? null;
const usdValue = price ? formatUsdValue(Number(amountFormatted) * price) : null;

const alreadyClaimed = hasClaimed === true || isConfirmed;

const handleClaim = () => {
writeContract(
{
address: MERKLE_CLAIM_ADDRESS,
abi: MERKLE_CLAIM_ABI,
functionName: "claim",
args: [BigInt(proofData.amount!), proofData.proof! as `0x${string}`[]],
},
{
onSuccess: (hash) => setTxHash(hash),
},
);
};

return (
<div className="border-border rounded border p-4">
<div className="text-foreground text-sm font-bold mb-3">Claim Your PLOT</div>

<div className="text-center space-y-2">
<div>
<div className="text-muted text-[10px]">You earned</div>
<div className="text-foreground text-xl font-bold">{amountDisplay} PLOT</div>
{usdValue && <div className="text-muted text-xs">({usdValue})</div>}
</div>

{alreadyClaimed ? (
<div className="space-y-1">
<div className="text-accent text-sm font-medium">Claimed</div>
<a
href={txHash ? `${EXPLORER_URL}/tx/${txHash}` : `${EXPLORER_URL}/address/${MERKLE_CLAIM_ADDRESS}#events`}
target="_blank"
rel="noopener noreferrer"
className="text-accent text-xs hover:underline"
>
{txHash ? "View transaction" : "View on explorer"}
</a>
</div>
) : (
<button
type="button"
onClick={handleClaim}
disabled={isClaiming || isConfirming}
className="bg-accent text-bg rounded px-6 py-2 text-sm font-medium disabled:opacity-50 cursor-pointer"
>
{isClaiming ? "Sign transaction..." : isConfirming ? "Confirming..." : "Claim PLOT"}
</button>
)}
</div>
</div>
);
}
Loading