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 ( +
+

Campaign Complete

+

{message}

+ {FINAL_BURN_TX && ( + + View burn transaction + + )} +
+ ); +} + +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 ; }