From 2171646efdd16aaf4df1b5b01524be792b1b0c9c Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Tue, 21 Apr 2026 10:58:32 +0900 Subject: [PATCH 1/3] [#886] Add campaign hero section and milestone progress track MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CampaignHero: time progress bar, mcap display + progress toward next milestone, stats row (participants, PL earned, PLOT price), lockup proof link - MilestoneTrack: visual track Bronze→Silver→Gold with mcap markers, tier details (target, pool %, PLOT amount), current position - Updated /airdrop page to compose hero + milestone + referral sections Fixes #886 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/airdrop/page.tsx | 13 +-- src/components/airdrop/CampaignHero.tsx | 135 ++++++++++++++++++++++ src/components/airdrop/MilestoneTrack.tsx | 116 +++++++++++++++++++ 3 files changed, 257 insertions(+), 7 deletions(-) create mode 100644 src/components/airdrop/CampaignHero.tsx create mode 100644 src/components/airdrop/MilestoneTrack.tsx diff --git a/src/app/airdrop/page.tsx b/src/app/airdrop/page.tsx index d7879477..f145e653 100644 --- a/src/app/airdrop/page.tsx +++ b/src/app/airdrop/page.tsx @@ -1,4 +1,6 @@ import type { Metadata } from "next"; +import { CampaignHero } from "../../components/airdrop/CampaignHero"; +import { MilestoneTrack } from "../../components/airdrop/MilestoneTrack"; import { ReferralInput } from "../../components/ReferralInput"; export const metadata: Metadata = { @@ -8,14 +10,11 @@ export const metadata: Metadata = { export default function AirdropPage() { return ( -
-

PLOT 10x Airdrop

-

- Earn PL points through trading, writing, rating, and referrals. - Campaign details coming soon. -

+
+ + -
+

Referral

diff --git a/src/components/airdrop/CampaignHero.tsx b/src/components/airdrop/CampaignHero.tsx new file mode 100644 index 00000000..accba0ce --- /dev/null +++ b/src/components/airdrop/CampaignHero.tsx @@ -0,0 +1,135 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { formatUsdValue } from "../../../lib/usd-price"; + +interface StatusData { + campaignStart: string; + campaignEnd: string; + timeRemainingDays: number; + timeElapsedPercent: number; + poolAmount: number; + currentMcap: number; + latestPriceUsd: number | null; + milestones: { + bronze: { mcap: number; pct: number; reached: boolean }; + silver: { mcap: number; pct: number; reached: boolean }; + gold: { mcap: number; pct: number; reached: boolean }; + }; + totalPointsEarned: number; + totalParticipants: number; + lockerId: string | null; +} + +function useAirdropStatus() { + return 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, + refetchInterval: 60_000, + }); +} + +export function CampaignHero() { + const { data, isLoading } = useAirdropStatus(); + + if (isLoading || !data) { + return ( +
+
Loading campaign status...
+
+ ); + } + + // Find the next milestone target + const nextMilestone = !data.milestones.bronze.reached + ? { name: "Bronze", mcap: data.milestones.bronze.mcap } + : !data.milestones.silver.reached + ? { name: "Silver", mcap: data.milestones.silver.mcap } + : !data.milestones.gold.reached + ? { name: "Gold", mcap: data.milestones.gold.mcap } + : null; + + const mcapProgress = nextMilestone + ? Math.min(100, (data.currentMcap / nextMilestone.mcap) * 100) + : 100; + + return ( +
+
+

PLOT 10x Airdrop

+

+ {data.poolAmount.toLocaleString()} PLOT locked. Big or nothing. +

+
+ + {/* Time progress */} +
+
+ Time remaining + {data.timeRemainingDays} days +
+
+
+
+
{data.timeElapsedPercent}% elapsed
+
+ + {/* Market Cap progress */} +
+
+ Market Cap + {formatUsdValue(data.currentMcap)} +
+
+
+
+
+ {nextMilestone ? `< ${nextMilestone.name} (${formatUsdValue(nextMilestone.mcap)})` : "Gold reached"} +
+
+ + {/* Stats row */} +
+
+
{data.totalParticipants}
+
Participants
+
+
+
{Math.round(data.totalPointsEarned).toLocaleString()}
+
PL Earned
+
+
+
+ {data.latestPriceUsd ? formatUsdValue(data.latestPriceUsd) : "—"} +
+
PLOT Price
+
+
+ + {/* Lockup proof */} + {data.lockerId && ( + + )} +
+ ); +} diff --git a/src/components/airdrop/MilestoneTrack.tsx b/src/components/airdrop/MilestoneTrack.tsx new file mode 100644 index 00000000..18af953b --- /dev/null +++ b/src/components/airdrop/MilestoneTrack.tsx @@ -0,0 +1,116 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { formatUsdValue } from "../../../lib/usd-price"; + +interface StatusData { + poolAmount: number; + currentMcap: number; + milestones: { + bronze: { mcap: number; pct: number; reached: boolean }; + silver: { mcap: number; pct: number; reached: boolean }; + gold: { mcap: number; pct: number; reached: boolean }; + }; +} + +const TIERS = [ + { key: "bronze" as const, label: "Bronze", color: "text-[#cd7f32]" }, + { key: "silver" as const, label: "Silver", color: "text-[#c0c0c0]" }, + { key: "gold" as const, label: "Gold", color: "text-[#ffd700]" }, +]; + +export function MilestoneTrack() { + const { data, isLoading } = 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 (isLoading || !data) { + return ( +
+
Loading milestones...
+
+ ); + } + + // Progress across all three tiers (0–100 mapped to full track) + const goldMcap = data.milestones.gold.mcap; + const overallProgress = Math.min(100, (data.currentMcap / goldMcap) * 100); + + return ( +
+

Milestone Progress

+ + {/* Track bar */} +
+
+
+
+ + {/* Milestone markers */} + {TIERS.map((tier) => { + const milestone = data.milestones[tier.key]; + const position = (milestone.mcap / goldMcap) * 100; + return ( +
+
+
+ ); + })} +
+ + {/* Tier details */} +
+ {TIERS.map((tier) => { + const milestone = data.milestones[tier.key]; + const poolValue = data.poolAmount * (milestone.pct / 100); + return ( +
+
+ {tier.label} + {milestone.reached && " \u2713"} +
+
+ {formatUsdValue(milestone.mcap)} +
+
MCap target
+
+ {milestone.pct}% · {poolValue.toLocaleString()} PLOT +
+
+ ); + })} +
+ + {/* Current position label */} +
+ + Current: {formatUsdValue(data.currentMcap)} / {formatUsdValue(goldMcap)} + +
+
+ ); +} From 1656552944daae70e34196dd80cd8d22c81560c9 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Tue, 21 Apr 2026 14:33:18 +0900 Subject: [PATCH 2/3] [#886] Add pool value USD display to hero and milestone cards Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/airdrop/CampaignHero.tsx | 31 +++++++++++++++++++++++ src/components/airdrop/MilestoneTrack.tsx | 6 +++++ 2 files changed, 37 insertions(+) diff --git a/src/components/airdrop/CampaignHero.tsx b/src/components/airdrop/CampaignHero.tsx index accba0ce..545eceb4 100644 --- a/src/components/airdrop/CampaignHero.tsx +++ b/src/components/airdrop/CampaignHero.tsx @@ -99,6 +99,37 @@ export function CampaignHero() {
+ {/* Pool value at next milestone */} + {data.latestPriceUsd != null && data.latestPriceUsd > 0 && ( +
+ {nextMilestone ? ( + + Pool value if {nextMilestone.name}:{" "} + + {formatUsdValue( + data.poolAmount * + (data.milestones[ + nextMilestone.name.toLowerCase() as keyof typeof data.milestones + ].pct / 100) * + data.latestPriceUsd + )} + + + ) : ( + + Pool value at Gold:{" "} + + {formatUsdValue( + data.poolAmount * + (data.milestones.gold.pct / 100) * + data.latestPriceUsd + )} + + + )} +
+ )} + {/* Stats row */}
diff --git a/src/components/airdrop/MilestoneTrack.tsx b/src/components/airdrop/MilestoneTrack.tsx index 18af953b..d353230f 100644 --- a/src/components/airdrop/MilestoneTrack.tsx +++ b/src/components/airdrop/MilestoneTrack.tsx @@ -6,6 +6,7 @@ import { formatUsdValue } from "../../../lib/usd-price"; interface StatusData { poolAmount: number; currentMcap: number; + latestPriceUsd: number | null; milestones: { bronze: { mcap: number; pct: number; reached: boolean }; silver: { mcap: number; pct: number; reached: boolean }; @@ -100,6 +101,11 @@ export function MilestoneTrack() {
{milestone.pct}% · {poolValue.toLocaleString()} PLOT
+ {data.latestPriceUsd != null && data.latestPriceUsd > 0 && ( +
+ Pool: {formatUsdValue(poolValue * data.latestPriceUsd)} +
+ )}
); })} From 8a84746c8dd655a8160fbd7f0c934ec2d154ff46 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Tue, 21 Apr 2026 14:33:51 +0900 Subject: [PATCH 3/3] [#886] Add current mcap marker on milestone progress track Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/airdrop/MilestoneTrack.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/components/airdrop/MilestoneTrack.tsx b/src/components/airdrop/MilestoneTrack.tsx index d353230f..912e27e9 100644 --- a/src/components/airdrop/MilestoneTrack.tsx +++ b/src/components/airdrop/MilestoneTrack.tsx @@ -56,6 +56,17 @@ export function MilestoneTrack() { />
+ {/* Current mcap marker */} +
+
+
+ {formatUsdValue(data.currentMcap)} +
+
+ {/* Milestone markers */} {TIERS.map((tier) => { const milestone = data.milestones[tier.key];