From 5f4f36861fb470f2ebe5055d39ef641de89dc103 Mon Sep 17 00:00:00 2001 From: dev-agent Date: Tue, 26 May 2026 08:02:21 +0000 Subject: [PATCH 1/4] [#1250] Replace /airdrop 2-column grid with 3-state machine States: Paused (env flag, highest precedence), Pre-activation (not connected or not activated), Mining (activated + campaign active), Settlement-Normal (MERKLE_CLAIM_ADDRESS set), Settlement- FinalBurn (FINAL_BURN_TX set). Derives state from env vars + wallet connection + /activation-status API. Adds stub components: ActivationFlow, ContributionPanel, ReferralCTA, MilestoneClimb, ClaimCard (fleshed out in T3.2-T3.8). Removes Leaderboard and WeeklySnapshots from main layout. CampaignHero renders across all states. Closes #1250 Co-Authored-By: Claude Opus 4.7 (1M context) --- package-lock.json | 4 +- package.json | 2 +- src/app/airdrop/AirdropStateMachine.tsx | 139 +++++++++++++++++++ src/app/airdrop/page.tsx | 30 +--- src/components/airdrop/ActivationFlow.tsx | 10 ++ src/components/airdrop/ClaimCard.tsx | 40 ++++++ src/components/airdrop/ContributionPanel.tsx | 10 ++ src/components/airdrop/MilestoneClimb.tsx | 10 ++ src/components/airdrop/ReferralCTA.tsx | 10 ++ 9 files changed, 226 insertions(+), 29 deletions(-) create mode 100644 src/app/airdrop/AirdropStateMachine.tsx create mode 100644 src/components/airdrop/ActivationFlow.tsx create mode 100644 src/components/airdrop/ClaimCard.tsx create mode 100644 src/components/airdrop/ContributionPanel.tsx create mode 100644 src/components/airdrop/MilestoneClimb.tsx create mode 100644 src/components/airdrop/ReferralCTA.tsx diff --git a/package-lock.json b/package-lock.json index f38f9c1d..50ef49b5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "plotlink", - "version": "1.34.1", + "version": "1.35.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "plotlink", - "version": "1.34.1", + "version": "1.35.0", "workspaces": [ "packages/*" ], diff --git a/package.json b/package.json index 2c86e178..05b44a3e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "plotlink", - "version": "1.34.1", + "version": "1.35.0", "private": true, "workspaces": [ "packages/*" diff --git a/src/app/airdrop/AirdropStateMachine.tsx b/src/app/airdrop/AirdropStateMachine.tsx new file mode 100644 index 00000000..470e49ae --- /dev/null +++ b/src/app/airdrop/AirdropStateMachine.tsx @@ -0,0 +1,139 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useAccount } from "wagmi"; +import { CampaignHero } from "../../components/airdrop/CampaignHero"; +import { ActivationFlow } from "../../components/airdrop/ActivationFlow"; +import { ContributionPanel } from "../../components/airdrop/ContributionPanel"; +import { ReferralCTA } from "../../components/airdrop/ReferralCTA"; +import { MilestoneClimb } from "../../components/airdrop/MilestoneClimb"; +import { ClaimCard } from "../../components/airdrop/ClaimCard"; + +type AirdropState = + | "paused" + | "pre-activation" + | "mining" + | "settlement-normal" + | "settlement-final-burn"; + +function deriveState( + isConnected: boolean, + activatedAt: string | null, +): AirdropState { + if (process.env.NEXT_PUBLIC_AIRDROP_PAUSED === "1") { + return "paused"; + } + + if (process.env.NEXT_PUBLIC_MERKLE_CLAIM_ADDRESS) { + return "settlement-normal"; + } + + if (process.env.NEXT_PUBLIC_AIRDROP_FINAL_BURN_TX) { + return "settlement-final-burn"; + } + + if (!isConnected || !activatedAt) { + return "pre-activation"; + } + + return "mining"; +} + +export function AirdropStateMachine() { + const { address, isConnected } = useAccount(); + const [activatedAt, setActivatedAt] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!isConnected || !address) { + setActivatedAt(null); + setLoading(false); + return; + } + + setLoading(true); + fetch(`/api/airdrop/activation-status?address=${address.toLowerCase()}`) + .then(res => res.json()) + .then(data => setActivatedAt(data.activated_at ?? null)) + .catch(() => setActivatedAt(null)) + .finally(() => setLoading(false)); + }, [isConnected, address]); + + const state = deriveState(isConnected, activatedAt); + + if (state === "paused") { + return ( + <> + +
+

Campaign temporarily paused. Will resume shortly.

+
+ + ); + } + + if (state === "settlement-normal") { + return ( + <> + +
+ +
+ + ); + } + + if (state === "settlement-final-burn") { + const finalState = (process.env.NEXT_PUBLIC_AIRDROP_FINAL_STATE as "sub_bronze" | "zero_recipient" | undefined) ?? null; + return ( + <> + +
+ +
+ + ); + } + + if (state === "pre-activation") { + return ( + <> + +
+ {!isConnected ? ( +
+

Connect your wallet to get started.

+
+ ) : ( + + )} +
+ + ); + } + + // mining state + if (loading) { + return ( + <> + +
+

Loading...

+
+ + ); + } + + return ( + <> + +
+ +
+ + +
+
+ + ); +} diff --git a/src/app/airdrop/page.tsx b/src/app/airdrop/page.tsx index 2db516d6..769d75b9 100644 --- a/src/app/airdrop/page.tsx +++ b/src/app/airdrop/page.tsx @@ -1,37 +1,15 @@ 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 { getAirdropConfig } from "../../../lib/airdrop/config"; +import { AirdropStateMachine } from "./AirdropStateMachine"; export const metadata: Metadata = { - title: "PLOT Big or Nothing Airdrop | PlotLink", - description: "Earn PL points through trading, writing, and referrals.", + title: "PlotLink Buy-Back Sprint | PlotLink", + description: "Activate, trade, and earn your share of the PLOT airdrop pool.", }; export default function AirdropPage() { - const campaignEnded = new Date() > getAirdropConfig().CAMPAIGN_END; - return (
- {/* Hero spans full width */} - - - {/* 2-column grid below hero */} -
- {/* Left column: user-specific */} -
- {campaignEnded ? : } -
- - {/* Right column: global sections */} -
- - -
-
+
); } diff --git a/src/components/airdrop/ActivationFlow.tsx b/src/components/airdrop/ActivationFlow.tsx new file mode 100644 index 00000000..c6a5105b --- /dev/null +++ b/src/components/airdrop/ActivationFlow.tsx @@ -0,0 +1,10 @@ +"use client"; + +export function ActivationFlow() { + return ( +
+

Activate Your Wallet

+

Connect your X account and follow PlotLink to activate.

+
+ ); +} diff --git a/src/components/airdrop/ClaimCard.tsx b/src/components/airdrop/ClaimCard.tsx new file mode 100644 index 00000000..29ace462 --- /dev/null +++ b/src/components/airdrop/ClaimCard.tsx @@ -0,0 +1,40 @@ +"use client"; + +interface ClaimCardProps { + mode: "normal" | "final-burn"; + finalState?: "sub_bronze" | "zero_recipient" | null; +} + +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 ( +
+

Campaign Complete

+

{message}

+ {process.env.NEXT_PUBLIC_AIRDROP_FINAL_BURN_TX && ( + + View burn transaction + + )} +
+ ); + } + + return ( +
+

Claim Your PLOT

+

The campaign has ended. Claim your share below.

+
+ ); +} diff --git a/src/components/airdrop/ContributionPanel.tsx b/src/components/airdrop/ContributionPanel.tsx new file mode 100644 index 00000000..dc3ab6d6 --- /dev/null +++ b/src/components/airdrop/ContributionPanel.tsx @@ -0,0 +1,10 @@ +"use client"; + +export function ContributionPanel() { + return ( +
+

Your Contribution

+

Buy PLOT tokens to earn your share of the airdrop pool.

+
+ ); +} diff --git a/src/components/airdrop/MilestoneClimb.tsx b/src/components/airdrop/MilestoneClimb.tsx new file mode 100644 index 00000000..f74e5c2e --- /dev/null +++ b/src/components/airdrop/MilestoneClimb.tsx @@ -0,0 +1,10 @@ +"use client"; + +export function MilestoneClimb() { + return ( +
+

Milestone Climb

+

Track community progress toward milestone tiers.

+
+ ); +} diff --git a/src/components/airdrop/ReferralCTA.tsx b/src/components/airdrop/ReferralCTA.tsx new file mode 100644 index 00000000..965e8a74 --- /dev/null +++ b/src/components/airdrop/ReferralCTA.tsx @@ -0,0 +1,10 @@ +"use client"; + +export function ReferralCTA() { + return ( +
+

Invite Friends

+

Share your referral link to boost your multiplier.

+
+ ); +} From c22d7f738dc329713379258b4befa9c0536241d6 Mon Sep 17 00:00:00 2001 From: dev-agent Date: Tue, 26 May 2026 09:11:38 +0000 Subject: [PATCH 2/4] [#1250] Paused state fetch-free, settlement uses existing ClaimPanel Paused state: no CampaignHero (which fetches /status), no activation-status fetch. Effect skips all fetches when paused or in settlement states. Settlement-normal: renders existing ClaimPanel (has proof fetch + claim button) instead of static stub. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/airdrop/AirdropStateMachine.tsx | 67 ++++++++++++------------- 1 file changed, 31 insertions(+), 36 deletions(-) diff --git a/src/app/airdrop/AirdropStateMachine.tsx b/src/app/airdrop/AirdropStateMachine.tsx index 470e49ae..a452d95f 100644 --- a/src/app/airdrop/AirdropStateMachine.tsx +++ b/src/app/airdrop/AirdropStateMachine.tsx @@ -8,6 +8,7 @@ import { ContributionPanel } from "../../components/airdrop/ContributionPanel"; import { ReferralCTA } from "../../components/airdrop/ReferralCTA"; import { MilestoneClimb } from "../../components/airdrop/MilestoneClimb"; import { ClaimCard } from "../../components/airdrop/ClaimCard"; +import { ClaimPanel } from "../../components/airdrop/ClaimPanel"; type AirdropState = | "paused" @@ -16,26 +17,19 @@ type AirdropState = | "settlement-normal" | "settlement-final-burn"; +const IS_PAUSED = process.env.NEXT_PUBLIC_AIRDROP_PAUSED === "1"; +const MERKLE_CLAIM_ADDRESS = process.env.NEXT_PUBLIC_MERKLE_CLAIM_ADDRESS; +const FINAL_BURN_TX = process.env.NEXT_PUBLIC_AIRDROP_FINAL_BURN_TX; +const FINAL_STATE = process.env.NEXT_PUBLIC_AIRDROP_FINAL_STATE as "sub_bronze" | "zero_recipient" | undefined; + function deriveState( isConnected: boolean, activatedAt: string | null, ): AirdropState { - if (process.env.NEXT_PUBLIC_AIRDROP_PAUSED === "1") { - return "paused"; - } - - if (process.env.NEXT_PUBLIC_MERKLE_CLAIM_ADDRESS) { - return "settlement-normal"; - } - - if (process.env.NEXT_PUBLIC_AIRDROP_FINAL_BURN_TX) { - return "settlement-final-burn"; - } - - if (!isConnected || !activatedAt) { - return "pre-activation"; - } - + if (IS_PAUSED) return "paused"; + if (MERKLE_CLAIM_ADDRESS) return "settlement-normal"; + if (FINAL_BURN_TX) return "settlement-final-burn"; + if (!isConnected || !activatedAt) return "pre-activation"; return "mining"; } @@ -45,6 +39,11 @@ export function AirdropStateMachine() { const [loading, setLoading] = useState(true); useEffect(() => { + if (IS_PAUSED || MERKLE_CLAIM_ADDRESS || FINAL_BURN_TX) { + setLoading(false); + return; + } + if (!isConnected || !address) { setActivatedAt(null); setLoading(false); @@ -62,34 +61,42 @@ export function AirdropStateMachine() { const state = deriveState(isConnected, activatedAt); if (state === "paused") { + return ( +
+

Campaign Paused

+

Campaign temporarily paused. Will resume shortly.

+
+ ); + } + + if (state === "settlement-normal") { return ( <> -
-

Campaign temporarily paused. Will resume shortly.

+
+
); } - if (state === "settlement-normal") { + if (state === "settlement-final-burn") { return ( <>
- +
); } - if (state === "settlement-final-burn") { - const finalState = (process.env.NEXT_PUBLIC_AIRDROP_FINAL_STATE as "sub_bronze" | "zero_recipient" | undefined) ?? null; + if (loading) { return ( <> -
- +
+

Loading...

); @@ -112,18 +119,6 @@ export function AirdropStateMachine() { ); } - // mining state - if (loading) { - return ( - <> - -
-

Loading...

-
- - ); - } - return ( <> From 7cbe93e2f7bb3669fdcd1353b895f1f25f8cae7e Mon Sep 17 00:00:00 2001 From: dev-agent Date: Tue, 26 May 2026 09:27:08 +0000 Subject: [PATCH 3/4] =?UTF-8?q?[#1250]=20Fix=20sync=20setState=20in=20useE?= =?UTF-8?q?ffect=20=E2=80=94=20skip=20fetch=20for=20static=20states?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move needsFetch check outside effect to avoid synchronous setState. Initialize loading based on whether fetch is needed. Add cleanup function for cancelled fetches. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/airdrop/AirdropStateMachine.tsx | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/src/app/airdrop/AirdropStateMachine.tsx b/src/app/airdrop/AirdropStateMachine.tsx index a452d95f..a2934ed1 100644 --- a/src/app/airdrop/AirdropStateMachine.tsx +++ b/src/app/airdrop/AirdropStateMachine.tsx @@ -33,29 +33,27 @@ function deriveState( return "mining"; } +const needsFetch = !IS_PAUSED && !MERKLE_CLAIM_ADDRESS && !FINAL_BURN_TX; + export function AirdropStateMachine() { const { address, isConnected } = useAccount(); const [activatedAt, setActivatedAt] = useState(null); - const [loading, setLoading] = useState(true); + const [loading, setLoading] = useState(needsFetch && isConnected); useEffect(() => { - if (IS_PAUSED || MERKLE_CLAIM_ADDRESS || FINAL_BURN_TX) { - setLoading(false); - return; - } - - if (!isConnected || !address) { + if (!needsFetch || !isConnected || !address) { setActivatedAt(null); - setLoading(false); return; } + let cancelled = false; setLoading(true); fetch(`/api/airdrop/activation-status?address=${address.toLowerCase()}`) .then(res => res.json()) - .then(data => setActivatedAt(data.activated_at ?? null)) - .catch(() => setActivatedAt(null)) - .finally(() => setLoading(false)); + .then(data => { if (!cancelled) setActivatedAt(data.activated_at ?? null); }) + .catch(() => { if (!cancelled) setActivatedAt(null); }) + .finally(() => { if (!cancelled) setLoading(false); }); + return () => { cancelled = true; }; }, [isConnected, address]); const state = deriveState(isConnected, activatedAt); From 7a10237b913038fb3ae206953f57a883863f0fde Mon Sep 17 00:00:00 2001 From: dev-agent Date: Tue, 26 May 2026 09:30:05 +0000 Subject: [PATCH 4/4] [#1250] Eliminate all sync setState from useEffect body Replace separate activatedAt + loading states with single fetchResult state. Only setState call in effect is in async callbacks (fetch .then/.catch). Derive loading and activatedAt from fetchResult + connection state outside the effect. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/airdrop/AirdropStateMachine.tsx | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/src/app/airdrop/AirdropStateMachine.tsx b/src/app/airdrop/AirdropStateMachine.tsx index a2934ed1..b75b3f3a 100644 --- a/src/app/airdrop/AirdropStateMachine.tsx +++ b/src/app/airdrop/AirdropStateMachine.tsx @@ -37,25 +37,22 @@ const needsFetch = !IS_PAUSED && !MERKLE_CLAIM_ADDRESS && !FINAL_BURN_TX; export function AirdropStateMachine() { const { address, isConnected } = useAccount(); - const [activatedAt, setActivatedAt] = useState(null); - const [loading, setLoading] = useState(needsFetch && isConnected); + const [fetchResult, setFetchResult] = useState<{ activatedAt: string | null; done: boolean }>({ activatedAt: null, done: !needsFetch }); useEffect(() => { - if (!needsFetch || !isConnected || !address) { - setActivatedAt(null); - return; - } + if (!needsFetch || !isConnected || !address) return; let cancelled = false; - setLoading(true); fetch(`/api/airdrop/activation-status?address=${address.toLowerCase()}`) .then(res => res.json()) - .then(data => { if (!cancelled) setActivatedAt(data.activated_at ?? null); }) - .catch(() => { if (!cancelled) setActivatedAt(null); }) - .finally(() => { if (!cancelled) setLoading(false); }); - return () => { cancelled = true; }; + .then(data => { if (!cancelled) setFetchResult({ activatedAt: data.activated_at ?? null, done: true }); }) + .catch(() => { if (!cancelled) setFetchResult({ activatedAt: null, done: true }); }); + return () => { cancelled = true; setFetchResult({ activatedAt: null, done: false }); }; }, [isConnected, address]); + const activatedAt = (needsFetch && isConnected) ? fetchResult.activatedAt : null; + const loading = needsFetch && isConnected && !fetchResult.done; + const state = deriveState(isConnected, activatedAt); if (state === "paused") {