From 6b33e01891f1564939158d4797c07d1210fc82da Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Tue, 21 Apr 2026 15:27:24 +0900 Subject: [PATCH 1/4] [#894] Add claim UI, proof API, and campaign end state Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/airdrop/page.tsx | 6 +- src/app/api/airdrop/proof/route.ts | 37 ++++++ src/components/airdrop/ClaimPanel.tsx | 178 ++++++++++++++++++++++++++ 3 files changed, 220 insertions(+), 1 deletion(-) create mode 100644 src/app/api/airdrop/proof/route.ts create mode 100644 src/components/airdrop/ClaimPanel.tsx diff --git a/src/app/airdrop/page.tsx b/src/app/airdrop/page.tsx index 7b068cd6..a63a7f51 100644 --- a/src/app/airdrop/page.tsx +++ b/src/app/airdrop/page.tsx @@ -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.", }; +const campaignEnded = new Date() > AIRDROP_CONFIG.CAMPAIGN_END; + export default function AirdropPage() { return (
- + {campaignEnded ? : } diff --git a/src/app/api/airdrop/proof/route.ts b/src/app/api/airdrop/proof/route.ts new file mode 100644 index 00000000..82f52cf5 --- /dev/null +++ b/src/app/api/airdrop/proof/route.ts @@ -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 + }); +} diff --git a/src/components/airdrop/ClaimPanel.tsx b/src/components/airdrop/ClaimPanel.tsx new file mode 100644 index 00000000..883a1f80 --- /dev/null +++ b/src/components/airdrop/ClaimPanel.tsx @@ -0,0 +1,178 @@ +"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 ProofData { + eligible: boolean; + amount: string | null; + proof: string[] | null; + merkleRoot: string | null; +} + +interface StatusData { + latestPriceUsd: number | null; +} + +export function ClaimPanel() { + const { address, isConnected } = useAccount(); + + if (!isConnected || !address) { + return ( +
+

Connect your wallet to check your claim.

+
+ ); + } + + return ; +} + +function ClaimPanelInner({ address }: { address: string }) { + const [txHash, setTxHash] = useState<`0x${string}` | null>(null); + + // Fetch proof from API + const { data: proofData, isLoading } = useQuery({ + 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({ + 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 ( +
+
Checking claim eligibility...
+
+ ); + } + + // Not eligible + if (!proofData.eligible || !proofData.amount || !proofData.proof) { + return ( +
+
Campaign Complete
+

You did not earn any PLOT in this campaign.

+
+ ); + } + + 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 ( +
+
Claim Your PLOT
+ +
+
+
You earned
+
{amountDisplay} PLOT
+ {usdValue &&
({usdValue})
} +
+ + {alreadyClaimed ? ( +
+
Claimed
+ {txHash && ( + + View transaction + + )} +
+ ) : ( + + )} +
+
+ ); +} From 442757e9e3191fee6584115c6eeead94a7518c72 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Tue, 21 Apr 2026 15:28:43 +0900 Subject: [PATCH 2/4] [#894] Move campaignEnded check inside component for per-request eval Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/airdrop/page.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/airdrop/page.tsx b/src/app/airdrop/page.tsx index a63a7f51..ddb35592 100644 --- a/src/app/airdrop/page.tsx +++ b/src/app/airdrop/page.tsx @@ -12,9 +12,9 @@ export const metadata: Metadata = { description: "Earn PL points through trading, writing, and referrals.", }; -const campaignEnded = new Date() > AIRDROP_CONFIG.CAMPAIGN_END; - export default function AirdropPage() { + const campaignEnded = new Date() > AIRDROP_CONFIG.CAMPAIGN_END; + return (
From 0ca36afa9f42419d961c9584d1e0408c488552dc Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Tue, 21 Apr 2026 15:30:15 +0900 Subject: [PATCH 3/4] [#894] Add CampaignResults section and fix claimed explorer link Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/airdrop/ClaimPanel.tsx | 110 +++++++++++++++++++++----- 1 file changed, 91 insertions(+), 19 deletions(-) diff --git a/src/components/airdrop/ClaimPanel.tsx b/src/components/airdrop/ClaimPanel.tsx index 883a1f80..84dda0b3 100644 --- a/src/components/airdrop/ClaimPanel.tsx +++ b/src/components/airdrop/ClaimPanel.tsx @@ -29,6 +29,77 @@ const MERKLE_CLAIM_ABI = [ }, ] as const; +interface StatusDataFull { + poolAmount: number; + milestones: { + bronze: { mcap: number; pct: number; reached: boolean }; + silver: { mcap: number; pct: number; reached: boolean }; + gold: { mcap: number; pct: number; reached: boolean }; + }; + latestPriceUsd: number | null; +} + +const BURN_TX = process.env.NEXT_PUBLIC_BURN_TX ?? null; + +function CampaignResults() { + const { data } = useQuery({ + 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, + }); + + if (!data) return null; + + const milestone = data.milestones.gold.reached + ? { tier: "Gold", pct: data.milestones.gold.pct } + : data.milestones.silver.reached + ? { tier: "Silver", pct: data.milestones.silver.pct } + : data.milestones.bronze.reached + ? { tier: "Bronze", pct: data.milestones.bronze.pct } + : { tier: "None", pct: 0 }; + + const distributed = data.poolAmount * (milestone.pct / 100); + const burned = data.poolAmount - distributed; + + return ( +
+
+ CAMPAIGN COMPLETE +
+
+
+ Milestone achieved:{" "} + + {milestone.tier === "None" ? "None — full burn" : `${milestone.tier} (${milestone.pct}%)`} + +
+
+ Distributed:{" "} + {distributed.toLocaleString()} PLOT +
+
+ Burned:{" "} + {burned.toLocaleString()} PLOT +
+ {BURN_TX && ( + + View burn transaction + + )} +
+
+ ); +} + interface ProofData { eligible: boolean; amount: string | null; @@ -43,15 +114,18 @@ interface StatusData { export function ClaimPanel() { const { address, isConnected } = useAccount(); - if (!isConnected || !address) { - return ( -
-

Connect your wallet to check your claim.

-
- ); - } - - return ; + return ( + <> + + {!isConnected || !address ? ( +
+

Connect your wallet to check your claim.

+
+ ) : ( + + )} + + ); } function ClaimPanelInner({ address }: { address: string }) { @@ -151,16 +225,14 @@ function ClaimPanelInner({ address }: { address: string }) { {alreadyClaimed ? ( ) : (