diff --git a/package-lock.json b/package-lock.json
index cd975b4..94d9056 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "plotlink",
- "version": "1.39.0",
+ "version": "1.40.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "plotlink",
- "version": "1.39.0",
+ "version": "1.40.0",
"workspaces": [
"packages/*"
],
diff --git a/package.json b/package.json
index f6ecd8c..ea6f3a6 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "plotlink",
- "version": "1.39.0",
+ "version": "1.40.0",
"private": true,
"workspaces": [
"packages/*"
diff --git a/src/components/airdrop/ClaimCard.tsx b/src/components/airdrop/ClaimCard.tsx
index 29ace46..9bc5edf 100644
--- a/src/components/airdrop/ClaimCard.tsx
+++ b/src/components/airdrop/ClaimCard.tsx
@@ -1,5 +1,20 @@
"use client";
+import { useState, useEffect } from "react";
+import { useAccount, useReadContract, useWriteContract, useWaitForTransactionReceipt } from "wagmi";
+import { useQuery } from "@tanstack/react-query";
+import { formatUnits } from "viem";
+import { EXPLORER_URL } from "../../../lib/contracts/constants";
+
+const MERKLE_CLAIM_ADDRESS = (process.env.NEXT_PUBLIC_MERKLE_CLAIM_ADDRESS ?? "") as `0x${string}`;
+const FINAL_BURN_TX = process.env.NEXT_PUBLIC_AIRDROP_FINAL_BURN_TX ?? null;
+
+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" }] },
+ { type: "function", name: "claimDeadline", stateMutability: "view", inputs: [], outputs: [{ name: "", type: "uint256" }] },
+] as const;
+
interface ClaimCardProps {
mode: "normal" | "final-burn";
finalState?: "sub_bronze" | "zero_recipient" | null;
@@ -7,34 +22,204 @@ interface ClaimCardProps {
export function ClaimCard({ mode, finalState }: ClaimCardProps) {
if (mode === "final-burn") {
- const message = finalState === "sub_bronze"
- ? "Campaign ended below Bronze milestone. All tokens burned."
- : finalState === "zero_recipient"
- ? "No eligible recipients. All tokens burned."
- : "Campaign ended. Watch for Season 2.";
+ return ;
+ }
+ return ;
+}
+
+function FinalBurnCard({ finalState }: { finalState?: "sub_bronze" | "zero_recipient" | null }) {
+ const message = finalState === "sub_bronze"
+ ? "Campaign ended below Bronze milestone. All tokens burned."
+ : finalState === "zero_recipient"
+ ? "No eligible recipients. All tokens burned."
+ : "Campaign ended. Watch for Season 2.";
+
+ return (
+
+ );
+}
+
+function NormalClaimCard() {
+ const { address, isConnected } = useAccount();
+ if (!isConnected || !address) {
+ return (
+
+
Connect your wallet to check your claim.
+
+ );
+ }
+
+ return ;
+}
+
+function ClaimCardInner({ address }: { address: string }) {
+ const [txHash, setTxHash] = useState<`0x${string}` | null>(null);
+ const [now, setNow] = useState(() => Math.floor(Date.now() / 1000));
+
+ useEffect(() => {
+ const id = setInterval(() => setNow(Math.floor(Date.now() / 1000)), 1000);
+ return () => clearInterval(id);
+ }, []);
+
+ const { data: proofData, isLoading: proofLoading } = useQuery<{
+ eligible: boolean;
+ amount: string | null;
+ proof: string[] | null;
+ }>({
+ 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,
+ });
+
+ const { data: projection } = useQuery<{
+ buy_volume: number;
+ qualified_refs: number;
+ has_fc_bonus: boolean;
+ multiplier: number;
+ weighted_spend: number;
+ community_total: number;
+ }>({
+ queryKey: ["airdrop-projection-claim", address],
+ queryFn: async () => {
+ const res = await fetch(`/api/airdrop/projection?address=${address.toLowerCase()}`);
+ if (!res.ok) return null;
+ return res.json();
+ },
+ staleTime: 60_000,
+ });
+
+ const { data: deadline } = useReadContract({
+ address: MERKLE_CLAIM_ADDRESS || undefined,
+ abi: MERKLE_CLAIM_ABI,
+ functionName: "claimDeadline",
+ query: { enabled: !!MERKLE_CLAIM_ADDRESS },
+ });
+
+ const { data: hasClaimed } = useReadContract({
+ address: MERKLE_CLAIM_ADDRESS || undefined,
+ abi: MERKLE_CLAIM_ABI,
+ functionName: "claimed",
+ args: [address as `0x${string}`],
+ query: { enabled: !!proofData?.eligible && !!MERKLE_CLAIM_ADDRESS },
+ });
+
+ const { writeContract, isPending: isClaiming } = useWriteContract();
+ const { isLoading: isConfirming, isSuccess: isConfirmed } = useWaitForTransactionReceipt({ hash: txHash ?? undefined });
+
+ if (proofLoading) {
return (
+
Checking claim eligibility...
+
+ );
+ }
+
+ if (!proofData?.eligible || !proofData.amount || !proofData.proof) {
+ return (
+
Campaign Complete
-
{message}
- {process.env.NEXT_PUBLIC_AIRDROP_FINAL_BURN_TX && (
-
- View burn transaction
-
- )}
+
You were not eligible for this campaign.
);
}
+ const amountFormatted = formatUnits(BigInt(proofData.amount), 18);
+ const amountDisplay = Number(amountFormatted).toLocaleString(undefined, { maximumFractionDigits: 2 });
+ const alreadyClaimed = hasClaimed === true || isConfirmed;
+
+ const deadlineTs = deadline ? Number(deadline) : null;
+ const pastDeadline = deadlineTs ? now > deadlineTs : false;
+ const timeLeft = deadlineTs && !pastDeadline ? deadlineTs - now : 0;
+ const daysLeft = Math.floor(timeLeft / 86400);
+ const hoursLeft = Math.floor((timeLeft % 86400) / 3600);
+
+ const handleClaim = () => {
+ writeContract(
+ {
+ address: MERKLE_CLAIM_ADDRESS as `0x${string}`,
+ abi: MERKLE_CLAIM_ABI,
+ functionName: "claim",
+ args: [BigInt(proofData.amount!), proofData.proof! as `0x${string}`[]],
+ },
+ { onSuccess: (hash) => setTxHash(hash) },
+ );
+ };
+
return (
-
-
Claim Your PLOT
-
The campaign has ended. Claim your share below.
+
+
Claim Your PLOT
+
+ {projection && (
+
+
Live contribution breakdown (may not reflect final settlement)
+
+
{projection.buy_volume.toLocaleString()} PLOT spent × {projection.multiplier.toFixed(1)}× multiplier
+
+ ({projection.qualified_refs} refs{projection.has_fc_bonus ? " + FC bonus" : ""})
+
+
= {projection.weighted_spend.toLocaleString()} weighted spend
+
+
Final claim amount below is from the on-chain settlement.
+
+ )}
+
+
+
+
Your claim
+
{amountDisplay} PLOT
+
+
+ {deadlineTs && !pastDeadline && (
+
+ Claim window: {daysLeft}d {hoursLeft}h remaining
+
+ )}
+
+ {pastDeadline && !alreadyClaimed && (
+
Claim window closed.
+ )}
+
+ {alreadyClaimed ? (
+
+ ) : (
+
+ )}
+
);
}
diff --git a/src/components/airdrop/ClaimPanel.tsx b/src/components/airdrop/ClaimPanel.tsx
index 7a7e4ee..b8e4e51 100644
--- a/src/components/airdrop/ClaimPanel.tsx
+++ b/src/components/airdrop/ClaimPanel.tsx
@@ -1,239 +1,7 @@
"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
({
- 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 (
-
-
- CAMPAIGN COMPLETE
-
-
-
- Milestone achieved:{" "}
-
- {data.milestone === "None" ? "None — full burn" : `${data.milestone} (${data.distributedPct}%)`}
-
-
-
- Distributed:{" "}
- {data.distributedPlot.toLocaleString()} PLOT
- to {data.recipients} recipients
-
-
- Burned:{" "}
- {data.burnedPlot.toLocaleString()} PLOT
-
- {BURN_TX && (
-
- View burn transaction
-
- )}
-
-
- );
-}
-
-interface ProofData {
- eligible: boolean;
- amount: string | null;
- proof: string[] | null;
- merkleRoot: string | null;
-}
-
-interface StatusData {
- latestPriceUsd: number | null;
-}
+import { ClaimCard } from "./ClaimCard";
export function ClaimPanel() {
- const { address, isConnected } = useAccount();
-
- return (
- <>
-
- {!isConnected || !address ? (
-
-
Connect your wallet to check your claim.
-
- ) : (
-
- )}
- >
- );
-}
-
-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
-
{usdValue || `${amountDisplay} PLOT`}
- {usdValue &&
({amountDisplay} PLOT)
}
-
-
- {alreadyClaimed ? (
-
- ) : (
-
- )}
-
-
- );
+ return ;
}