From 765f4aef1b7fa88ae438dbba319271f4a5ab4be5 Mon Sep 17 00:00:00 2001 From: project7 Date: Thu, 30 Apr 2026 11:19:27 +0900 Subject: [PATCH 1/2] Redesign airdrop hero with Growing Pool concept and burn visualization (#1011) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the complex SVG chart/StatsRow/tier-name approach with a bold "5% OF ALL PLOT — LOCKED" headline, a live burn bar showing the current burn/distribute split, and flat milestone rows showing FDV targets with unlock percentages and pool values. Delete unused MilestoneTrack component. Co-Authored-By: Claude Opus 4.6 --- src/components/airdrop/CampaignHero.tsx | 733 ++++++---------------- src/components/airdrop/MilestoneTrack.tsx | 414 ------------ 2 files changed, 188 insertions(+), 959 deletions(-) delete mode 100644 src/components/airdrop/MilestoneTrack.tsx diff --git a/src/components/airdrop/CampaignHero.tsx b/src/components/airdrop/CampaignHero.tsx index 315427b5..8e36f74c 100644 --- a/src/components/airdrop/CampaignHero.tsx +++ b/src/components/airdrop/CampaignHero.tsx @@ -25,39 +25,28 @@ interface StatusData { lockerId: string | null; } -interface DailyPrice { - date: string; - fdv: number; -} - /* ─── Constants ─── */ const MAX_SUPPLY = 1_000_000; -const TIER_META = [ - { key: "bronze" as const, emoji: "\uD83E\uDD49", label: "Bronze" }, - { key: "silver" as const, emoji: "\uD83E\uDD48", label: "Silver" }, - { key: "gold" as const, emoji: "\uD83E\uDD47", label: "Gold" }, - { key: "diamond" as const, emoji: "\uD83D\uDC8E", label: "Diamond" }, -]; +const TIER_KEYS = ["bronze", "silver", "gold", "diamond"] as const; -type Tier = { key: string; emoji: string; label: string; fdv: number; pct: number }; - -/** Build tier array from API milestones so test/prod config is respected */ -function buildTiers(milestones: StatusData["milestones"]): Tier[] { - return TIER_META.map((m) => { - const ms = milestones[m.key]; - return { ...m, fdv: ms.mcap, pct: ms.pct }; - }); +interface MilestoneRow { + fdv: number; + pct: number; + unlockPlot: number; + poolUsd: number; + burnPct: number; + cmcRank: string | null; + isFull: boolean; } -/* ─── SVG layout ─── */ - -const SVG_W = 700; -const SVG_H = 340; -const PAD = { top: 30, right: 80, bottom: 40, left: 70 }; -const CW = SVG_W - PAD.left - PAD.right; -const CH = SVG_H - PAD.top - PAD.bottom; +const CMC_RANKS: Record = { + 1_000_000: "≈ CMC #1900", + 10_000_000: "≈ CMC #950", + 50_000_000: "≈ CMC #400", + 100_000_000: "≈ CMC #250", +}; /* ─── Helpers ─── */ @@ -74,43 +63,12 @@ function useAirdropStatus() { }); } -function useDailyPrices() { - return useQuery({ - queryKey: ["airdrop-daily-prices"], - queryFn: async () => { - const res = await fetch("/api/airdrop/daily-prices"); - if (!res.ok) throw new Error("Failed to fetch daily prices"); - return res.json(); - }, - staleTime: 300_000, - }); -} - -/** Pool value at current FDV: highest reached tier pct * pool * price */ -function poolValueAtFdv(fdv: number, poolAmount: number, tiers: Tier[]): number { - const price = fdv / MAX_SUPPLY; - // Walk tiers in reverse to find highest reached - for (let i = tiers.length - 1; i >= 0; i--) { - if (fdv >= tiers[i].fdv) return poolAmount * (tiers[i].pct / 100) * price; - } - return 0; -} - -function currentZoneLabel(fdv: number, tiers: Tier[]): string { - for (let i = tiers.length - 1; i >= 0; i--) { - if (fdv >= tiers[i].fdv) return tiers[i].label; - } - return "Pre-" + tiers[0].label; -} - function formatCompact(val: number): string { if (val >= 1_000_000) return `$${(val / 1_000_000).toFixed(1)}M`; if (val >= 1_000) return `$${(val / 1_000).toFixed(0)}K`; return `$${val.toFixed(0)}`; } -/* ─── Countdown hook ─── */ - function useCountdown(endDateStr: string) { const [remaining, setRemaining] = useState({ d: 0, h: 0, m: 0, s: 0 }); @@ -134,466 +92,107 @@ function useCountdown(endDateStr: string) { return remaining; } -/* ─── Pure chart helpers (outside component to avoid unstable refs) ─── */ - -const FDV_LOG_MIN = Math.log10(100); - -function timeToX(ms: number, startMs: number, totalMs: number): number { - return PAD.left + ((ms - startMs) / totalMs) * CW; -} - -function poolToY(usd: number, yLeftMax: number): number { - return PAD.top + CH * (1 - usd / yLeftMax); +function buildMilestoneRows( + milestones: StatusData["milestones"], + poolAmount: number, +): MilestoneRow[] { + return TIER_KEYS.map((key) => { + const ms = milestones[key]; + const price = ms.mcap / MAX_SUPPLY; + const unlockPlot = poolAmount * (ms.pct / 100); + return { + fdv: ms.mcap, + pct: ms.pct, + unlockPlot, + poolUsd: unlockPlot * price, + burnPct: 100 - ms.pct, + cmcRank: CMC_RANKS[ms.mcap] ?? null, + isFull: ms.pct === 100, + }; + }); } -function fdvToY(fdv: number, logMax: number): number { - if (fdv <= 0) return PAD.top + CH; - const t = Math.max(0, Math.min(1, (Math.log10(Math.max(fdv, 100)) - FDV_LOG_MIN) / (logMax - FDV_LOG_MIN))); - return PAD.top + CH * (1 - t); +function getCurrentBurnState( + currentFdv: number, + milestones: StatusData["milestones"], + poolAmount: number, +): { burnPct: number; distributePct: number; poolUsd: number } { + const entries = TIER_KEYS.map((k) => milestones[k]); + let highestPct = 0; + for (let i = entries.length - 1; i >= 0; i--) { + if (currentFdv >= entries[i].mcap) { + highestPct = entries[i].pct; + break; + } + } + const price = currentFdv / MAX_SUPPLY; + const unlockPlot = poolAmount * (highestPct / 100); + return { + burnPct: 100 - highestPct, + distributePct: highestPct, + poolUsd: unlockPlot * price, + }; } -/* ─── Chart sub-component ─── */ +/* ─── Burn Bar ─── */ -function TimelineChart({ - campaignStart, - campaignEnd, +function BurnBar({ + burnPct, + distributePct, currentFdv, - poolAmount, - tiers, + poolUsd, }: { - campaignStart: string; - campaignEnd: string; + burnPct: number; + distributePct: number; currentFdv: number; - poolAmount: number; - tiers: Tier[]; + poolUsd: number; }) { - const { data: dailyPrices } = useDailyPrices(); - const [nowMs, setNowMs] = useState(() => Date.now()); - - // Refresh current time once per minute (chart doesn't need per-second updates) - useEffect(() => { - const id = setInterval(() => setNowMs(Date.now()), 60_000); - return () => clearInterval(id); - }, []); - - const startMs = new Date(campaignStart + "T00:00:00Z").getTime(); - const endMs = new Date(campaignEnd + "T00:00:00Z").getTime(); - const totalMs = endMs - startMs; - - const nowX = timeToX(Math.max(startMs, Math.min(nowMs, endMs)), startMs, totalMs); - - const diamondFdv = tiers[tiers.length - 1].fdv; - const fdvLogMax = Math.log10(diamondFdv * 2); // 2x headroom above diamond - const diamondPoolUsd = poolAmount * (diamondFdv / MAX_SUPPLY); - const yLeftMax = diamondPoolUsd * 1.1; - - // Month labels for x-axis - const months = useMemo(() => { - const result: { label: string; ms: number }[] = []; - const d = new Date(campaignStart + "T00:00:00Z"); - for (let i = 0; i < 7; i++) { - const ms = d.getTime(); - if (ms <= endMs) { - result.push({ label: `M${i + 1}`, ms }); - } - d.setUTCMonth(d.getUTCMonth() + 1); - } - return result; - }, [campaignStart, endMs]); - - // Effective data points: use daily prices if available, else synthesize from current FDV - const hasHistory = !!(dailyPrices?.length && dailyPrices.some((dp) => { - const dpMs = new Date(dp.date + "T00:00:00Z").getTime(); - return dpMs >= startMs && dpMs <= endMs; - })); - - // Pool value step line from daily price data (or $0 flat line if no data) - const poolStepPath = useMemo(() => { - const baseline = poolToY(0, yLeftMax); - if (!hasHistory) { - // No data: flat $0 line from campaign start to now - const currentPv = poolValueAtFdv(currentFdv, poolAmount, tiers); - const pvY = currentPv > 0 ? poolToY(currentPv, yLeftMax) : baseline; - return `M ${PAD.left.toFixed(1)} ${baseline.toFixed(1)} L ${nowX.toFixed(1)} ${pvY.toFixed(1)}`; - } - const parts: string[] = []; - let lastPoolVal = 0; - for (const dp of dailyPrices!) { - const dpMs = new Date(dp.date + "T00:00:00Z").getTime(); - if (dpMs < startMs || dpMs > endMs) continue; - const x = timeToX(dpMs, startMs, totalMs); - const pv = poolValueAtFdv(dp.fdv, poolAmount, tiers); - if (pv !== lastPoolVal && parts.length > 0) { - parts.push(`L ${x.toFixed(1)} ${poolToY(lastPoolVal, yLeftMax).toFixed(1)}`); - } - parts.push(`${parts.length === 0 ? "M" : "L"} ${x.toFixed(1)} ${poolToY(pv, yLeftMax).toFixed(1)}`); - lastPoolVal = pv; - } - if (parts.length > 0) { - parts.push(`L ${nowX.toFixed(1)} ${poolToY(lastPoolVal, yLeftMax).toFixed(1)}`); - } - return parts.join(" "); - }, [hasHistory, dailyPrices, startMs, endMs, totalMs, poolAmount, nowX, yLeftMax, tiers, currentFdv]); - - // Pool value area fill - const poolAreaPath = useMemo(() => { - if (!poolStepPath) return ""; - const baseline = poolToY(0, yLeftMax); - if (!hasHistory) { - // Area from campaign start baseline → pool step → back to baseline - return `M ${PAD.left.toFixed(1)} ${baseline.toFixed(1)} ${poolStepPath.replace(/^M/, "L")} L ${nowX.toFixed(1)} ${baseline.toFixed(1)} Z`; - } - // Clamp area fill start to campaign start (daily prices may predate campaign) - const firstX = dailyPrices?.length - ? timeToX(Math.max(new Date(dailyPrices[0].date + "T00:00:00Z").getTime(), startMs), startMs, totalMs) - : PAD.left; - return `M ${firstX.toFixed(1)} ${baseline.toFixed(1)} ${poolStepPath.replace(/^M/, "L")} L ${nowX.toFixed(1)} ${baseline.toFixed(1)} Z`; - }, [poolStepPath, hasHistory, dailyPrices, startMs, totalMs, nowX, yLeftMax]); - - // Actual FDV line (or single point if no history) - const actualFdvPath = useMemo(() => { - if (!hasHistory) { - // No history: just draw a point at current position (will be rendered as dot) - if (currentFdv <= 0) return ""; - const y = fdvToY(currentFdv, fdvLogMax); - return `M ${nowX.toFixed(1)} ${y.toFixed(1)} L ${nowX.toFixed(1)} ${y.toFixed(1)}`; - } - const parts: string[] = []; - for (const dp of dailyPrices!) { - const dpMs = new Date(dp.date + "T00:00:00Z").getTime(); - if (dpMs < startMs || dpMs > endMs) continue; - const x = timeToX(dpMs, startMs, totalMs); - const y = fdvToY(dp.fdv, fdvLogMax); - parts.push(`${parts.length === 0 ? "M" : "L"} ${x.toFixed(1)} ${y.toFixed(1)}`); - } - if (parts.length > 0 && currentFdv > 0) { - parts.push(`L ${nowX.toFixed(1)} ${fdvToY(currentFdv, fdvLogMax).toFixed(1)}`); - } - return parts.join(" "); - }, [hasHistory, dailyPrices, startMs, endMs, totalMs, currentFdv, nowX, fdvLogMax]); - - // Linear projection: from campaign start → Diamond at campaign end - // Represents "constant growth needed from day 1 to hit Diamond" - const startFdv = useMemo(() => { - if (hasHistory && dailyPrices?.length) { - // Use first daily price within campaign period - for (const dp of dailyPrices) { - const dpMs = new Date(dp.date + "T00:00:00Z").getTime(); - if (dpMs >= startMs && dpMs <= endMs) return dp.fdv; - } - } - return currentFdv > 0 ? currentFdv : 100; - }, [hasHistory, dailyPrices, startMs, endMs, currentFdv]); - - const projectionPath = useMemo(() => { - const fromX = PAD.left; - const toX = PAD.left + CW; - const fromY = fdvToY(startFdv, fdvLogMax); - const toY = fdvToY(diamondFdv, fdvLogMax); - return `M ${fromX} ${fromY} L ${toX} ${toY}`; - }, [startFdv, diamondFdv, fdvLogMax]); - - const dotY = fdvToY(currentFdv > 0 ? currentFdv : 100, fdvLogMax); - - const milestoneLines = tiers.map((t) => ({ - ...t, - y: fdvToY(t.fdv, fdvLogMax), - })); - - const yLeftTicks = [0, diamondPoolUsd * 0.25, diamondPoolUsd * 0.5, diamondPoolUsd]; - // Right-axis ticks omitted — milestone emoji labels already show FDV values - - // Linear target today: where FDV should be if growing linearly from start to Diamond - const linearTargetToday = useMemo(() => { - const elapsed = Math.max(0, nowMs - startMs); - const progress = Math.min(1, elapsed / totalMs); - return startFdv + (diamondFdv - startFdv) * progress; - }, [nowMs, startMs, totalMs, startFdv, diamondFdv]); + const isFull = distributePct >= 100; + const isAllBurned = burnPct >= 100; return ( -
- {/* Desktop: full SVG chart */} -
- - - - - - - - - {/* Grid lines (horizontal at each milestone FDV) */} - {milestoneLines.map((m) => ( - - - {/* Right-side label */} - - {m.emoji} {formatCompact(m.fdv)} - - - ))} - - {/* Y-left axis ticks (pool value) */} - - {yLeftTicks.map((val) => ( - - {formatCompact(val)} - - ))} - - - {/* X-axis month labels */} - {months.map((m) => ( - - - - {m.label} - - - ))} +
+
+ If the campaign ended right now... +
- {/* Axis labels */} - - Pool Value (USD) - - - FDV (USD) - - - {/* 1. Pool value area fill */} - {poolAreaPath && ( - - )} - - {/* 2. Pool value step line */} - {poolStepPath && ( - - )} - - {/* 3. Linear FDV projection (dashed) */} - + {burnPct > 0 && ( +
- - {/* 4. Actual FDV line (solid) */} - {actualFdvPath && ( - - )} - - {/* Heartbeat dot on current FDV position */} - {currentFdv > 0 && ( - - {/* Pulse ring */} - - - - - {/* Solid dot */} - - - - - - )} - - {/* Chart border */} - 0 && ( +
- - - {/* Legend */} -
- - Actual FDV - - - Linear projection - - - Pool value - -
+ )}
- {/* Mobile: simplified milestone progress view */} -
-
FDV Progress
- - {/* Current FDV + overall progress bar */} -
-
- - Current: {currentFdv > 0 ? formatCompact(currentFdv) : "\u2014"} - - - {diamondFdv > 0 ? Math.min(100, Math.round((currentFdv / diamondFdv) * 100)) : 0}% - -
-
-
0 ? Math.min(100, (currentFdv / diamondFdv) * 100) : 0}%` }} - /> -
-
- - {/* Milestone list */} -
- {tiers.map((t) => { - const reached = currentFdv >= t.fdv; - const tierPct = t.pct; - return ( -
- - {t.emoji} {t.label} - - - {formatCompact(t.fdv)} - - {tierPct}% - - -
- ); - })} -
- - {/* Linear target comparison */} -
-
Linear target today
-
{formatCompact(linearTargetToday)}
-
+
+ + ← {burnPct}% BURNED + + + {isFull ? "FULL DISTRIBUTION" : `${distributePct}% distributed →`} +
-
- ); -} - -/* ─── Stats row sub-component ─── */ -function StatsRow({ - participants, - currentFdv, - tiers, -}: { - participants: number; - currentFdv: number; - tiers: Tier[]; -}) { - // Find next milestone - const nextTierIdx = tiers.findIndex((t) => currentFdv < t.fdv); - const allReached = nextTierIdx === -1; - const nextTier = allReached ? null : tiers[nextTierIdx]; - const progressPct = allReached - ? 100 - : nextTier - ? Math.min(100, Math.round((currentFdv / nextTier.fdv) * 100)) - : 0; - - const progressLabel = allReached - ? `${tiers[tiers.length - 1].emoji} ${tiers[tiers.length - 1].label} Achieved!` - : `Progress to ${nextTier!.emoji} ${nextTier!.label}`; - - return ( -
-
-
{participants}
-
Participants
-
-
-
- {currentFdv > 0 ? formatUsdValue(currentFdv) : "\u2014"} -
-
FDV
-
-
-
{progressPct}%
-
{progressLabel}
-
-
-
+
+ + Current FDV: {currentFdv > 0 ? formatUsdValue(currentFdv) : "—"} + + + Pool value right now: {poolUsd > 0 ? formatUsdValue(poolUsd) : "$0"} +
); @@ -605,8 +204,16 @@ export function CampaignHero() { const { data, isLoading } = useAirdropStatus(); const countdown = useCountdown(data?.campaignEnd ?? "2027-01-01"); - const tiers = useMemo( - () => (data ? buildTiers(data.milestones) : []), + const milestoneRows = useMemo( + () => (data ? buildMilestoneRows(data.milestones, data.poolAmount) : []), + [data], + ); + + const burnState = useMemo( + () => + data + ? getCurrentBurnState(data.currentFdv, data.milestones, data.poolAmount) + : { burnPct: 100, distributePct: 0, poolUsd: 0 }, [data], ); @@ -621,36 +228,18 @@ export function CampaignHero() { const pad2 = (n: number) => String(n).padStart(2, "0"); return ( -
- {/* Title + Explanation */} +
+ {/* ── Bold headline ── */}
-

- PLOT Big or Nothing Airdrop +

+ 5% OF ALL PLOT — LOCKED

-

- {data.poolAmount.toLocaleString()} PLOT (5% of max supply) locked in a time-locked contract. - If PLOT FDV reaches milestone targets within 6 months, the pool is distributed to point holders. - If not, it's burned forever. +

+ Grow the market. Or watch it burn.

- - {/* Lock-up proof */} - {data.lockerId ? ( - - 🔒 View lock-up proof on Mint Club - - ) : ( - - 🔒 Lock-up proof: pending - - )}
- {/* Live Countdown */} + {/* ── Countdown ── */} {data.timeRemainingDays > 0 && (
{[ @@ -672,32 +261,86 @@ export function CampaignHero() {
)} - {/* Stats row */} - + {/* ── Lock-up proof ── */} +
+ {data.lockerId ? ( + + 🔒 Verified on Mint Club + + ) : ( + + 🔒 Lock-up proof: pending + + )} +
- {/* 6-Month Timeline Chart */} - - {/* Current position summary */} -
-
- Current FDV: {data.currentFdv > 0 ? formatUsdValue(data.currentFdv) : "\u2014"} - · Zone: {currentZoneLabel(data.currentFdv, tiers)} + {/* ── What happens as PLOT grows ── */} +
+
+ What happens as PLOT grows
-
- Pool value: {data.currentFdv > 0 ? formatUsdValue(poolValueAtFdv(data.currentFdv, data.poolAmount, tiers)) : "$0"} + +
+ {milestoneRows.map((row, i) => ( +
+ {i > 0 &&
} +
+
+ + FDV {formatCompact(row.fdv)} + + {row.cmcRank && ( + {row.cmcRank} + )} +
+
+ + {row.pct}% unlocked + + + {row.burnPct}% burned + +
+
+ {row.unlockPlot.toLocaleString()} PLOT +
+
+ Pool ~{formatCompact(row.poolUsd)} + {row.isFull && ( + + Full distribution + + )} +
+
+
+ ))}
+ + {/* ── Participant count ── */} +
+ {data.totalParticipants > 0 + ? `${data.totalParticipants} participants earning` + : "Be the first to participate"} +
); } diff --git a/src/components/airdrop/MilestoneTrack.tsx b/src/components/airdrop/MilestoneTrack.tsx deleted file mode 100644 index 95be3670..00000000 --- a/src/components/airdrop/MilestoneTrack.tsx +++ /dev/null @@ -1,414 +0,0 @@ -"use client"; - -import { useMemo } from "react"; -import { useQuery } from "@tanstack/react-query"; -import { formatUsdValue } from "../../../lib/usd-price"; - -interface StatusData { - poolAmount: number; - currentFdv: 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 }; - diamond: { mcap: number; pct: number; reached: boolean }; - }; -} - -/* ─── Chart tier definitions (presentation layer) ─── */ - -const MAX_SUPPLY = 1_000_000; -const CHART_TIERS = [ - { label: "Bronze", emoji: "\uD83E\uDD49", fdv: 1_000_000, poolPct: 10 }, - { label: "Silver", emoji: "\uD83E\uDD48", fdv: 10_000_000, poolPct: 30 }, - { label: "Gold", emoji: "\uD83E\uDD47", fdv: 50_000_000, poolPct: 50 }, - { label: "Diamond", emoji: "\uD83D\uDC8E", fdv: 100_000_000, poolPct: 100 }, -] as const; - -/** Pool USD value at a given FDV milestone: poolAmount * (pct/100) * (fdv / maxSupply) */ -function poolUsdAt(fdv: number, poolPct: number, poolAmount: number): number { - return poolAmount * (poolPct / 100) * (fdv / MAX_SUPPLY); -} - -type ChartMilestone = { - fdv: number; - poolUsd: number; - label: string; - emoji: string; - poolPct: number; -}; - -/* ─── SVG layout constants ─── */ - -const SVG_W = 600; -const SVG_H = 300; -const PAD = { top: 40, right: 20, bottom: 50, left: 65 }; -const CHART_W = SVG_W - PAD.left - PAD.right; -const CHART_H = SVG_H - PAD.top - PAD.bottom; - -// Log scale helpers — FDV axis from 10k to 200M -const LOG_MIN = Math.log10(10_000); -const LOG_MAX = Math.log10(200_000_000); - -function fdvToX(fdv: number): number { - if (fdv <= 0) return PAD.left; - const logVal = Math.log10(Math.max(fdv, 10_000)); - const t = (logVal - LOG_MIN) / (LOG_MAX - LOG_MIN); - return PAD.left + t * CHART_W; -} - -function usdToY(usd: number, yMax: number): number { - const t = usd / yMax; - return PAD.top + CHART_H * (1 - t); -} - -/* ─── Path builders ─── */ - -function buildAreaPath(milestones: ChartMilestone[], yMax: number): string { - const baseline = usdToY(0, yMax); - let path = `M ${fdvToX(10_000)} ${baseline}`; - let prevY = baseline; - for (const m of milestones) { - const x = fdvToX(m.fdv); - const y = usdToY(m.poolUsd, yMax); - path += ` L ${x} ${prevY} L ${x} ${y}`; - prevY = y; - } - path += ` L ${PAD.left + CHART_W} ${prevY}`; - path += ` L ${PAD.left + CHART_W} ${baseline} Z`; - return path; -} - -function buildLinePath(milestones: ChartMilestone[], yMax: number): string { - let path = ""; - let prevY = usdToY(0, yMax); - for (let i = 0; i < milestones.length; i++) { - const m = milestones[i]; - const x = fdvToX(m.fdv); - const y = usdToY(m.poolUsd, yMax); - if (i === 0) { - path = `M ${fdvToX(10_000)} ${prevY} L ${x} ${prevY} L ${x} ${y}`; - } else { - path += ` L ${x} ${prevY} L ${x} ${y}`; - } - prevY = y; - } - path += ` L ${PAD.left + CHART_W} ${prevY}`; - return path; -} - -/* ─── Component ─── */ - -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, - }); - - const milestones: ChartMilestone[] = useMemo( - () => - CHART_TIERS.map((t) => ({ - ...t, - poolUsd: poolUsdAt(t.fdv, t.poolPct, data?.poolAmount ?? 50_000), - })), - [data?.poolAmount], - ); - - if (isLoading || !data) { - return ( -
-
Loading milestones...
-
- ); - } - - // Y-axis max with 10% headroom above Diamond - const yMax = milestones[milestones.length - 1].poolUsd * 1.1; - - // Y-axis ticks: evenly space 4 ticks from 0 to Diamond poolUsd - const diamondUsd = milestones[milestones.length - 1].poolUsd; - const yTicks = [0, diamondUsd * 0.2, diamondUsd * 0.5, diamondUsd]; - - // FDV = price * max supply - const currentFdv = - data.latestPriceUsd != null && data.latestPriceUsd > 0 - ? data.latestPriceUsd * MAX_SUPPLY - : 0; - - // Determine current zone - const currentZone = milestones.reduce( - (zone, m, i) => (currentFdv >= m.fdv ? i + 1 : zone), - 0, - ); - const currentZoneLabel = - currentZone === 0 ? "Pre-Bronze" : milestones[currentZone - 1].label; - - const currentPoolUsd = - currentZone > 0 ? milestones[currentZone - 1].poolUsd : 0; - - const dotX = fdvToX(Math.max(currentFdv, 10_000)); - const dotY = usdToY(currentPoolUsd, yMax); - - const areaPath = buildAreaPath(milestones, yMax); - const linePath = buildLinePath(milestones, yMax); - - return ( -
-

- FDV Milestone Chart -

-

- Pool unlock curve across FDV milestones · FDV = PLOT price - × {MAX_SUPPLY.toLocaleString()} max supply -

- -
- - - - - - - - - {/* Y-axis grid lines */} - {yTicks.map((val) => ( - - - - {val === 0 - ? "$0" - : val >= 1_000_000 - ? `$${(val / 1_000_000).toFixed(1)}M` - : `$${(val / 1_000).toFixed(0)}K`} - - - ))} - - {/* Filled area */} - - - {/* Step line */} - - - {/* Vertical zone dividers + annotations */} - {milestones.map((m) => { - const x = fdvToX(m.fdv); - const y = usdToY(m.poolUsd, yMax); - return ( - - - - {m.emoji} {m.label} - - - {m.poolPct}% ($ - {m.poolUsd >= 1_000_000 - ? `${(m.poolUsd / 1_000_000).toFixed(1)}M` - : `${(m.poolUsd / 1_000).toFixed(0)}K`} - ) - - - - ); - })} - - {/* X-axis labels */} - {milestones.map((m) => ( - - ${m.fdv >= 1_000_000 ? `${m.fdv / 1_000_000}M` : `${m.fdv / 1_000}K`} - - ))} - - {/* Axis labels */} - - FDV → - - - Pool Value - - - {/* Current FDV indicator — dot on x-axis, dashed line up to area */} - {currentFdv > 0 && ( - - {/* Vertical dashed line from x-axis up to the step level */} - - {/* Small marker at the step intersection */} - - {/* Pulse ring on x-axis */} - - - - - {/* Heartbeat dot on x-axis */} - - - - - {/* FDV label below axis */} - - {formatUsdValue(currentFdv)} - - - Current - - - )} - -
- - {/* Current position summary */} -
-
- Current FDV: {currentFdv > 0 ? formatUsdValue(currentFdv) : "\u2014"} - · Zone: {currentZoneLabel} -
- {currentFdv > 0 && currentZone < milestones.length && ( -
- Next: {milestones[currentZone].emoji}{" "} - {milestones[currentZone].label} at{" "} - {formatUsdValue(milestones[currentZone].fdv)} FDV -
- )} -
-
- ); -} From 6ba6f866b24467b36343ff9adf7a15c8afcded10 Mon Sep 17 00:00:00 2001 From: project7 Date: Thu, 30 Apr 2026 11:23:56 +0900 Subject: [PATCH 2/2] Make CMC ranks tier-index-based and fix headline casing CMC_RANKS keyed by tier index instead of exact FDV value so ranks render in both test and prod configs. Headline subtitle now uses uppercase tracking per wireframe spec. Co-Authored-By: Claude Opus 4.6 --- src/components/airdrop/CampaignHero.tsx | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/src/components/airdrop/CampaignHero.tsx b/src/components/airdrop/CampaignHero.tsx index 8e36f74c..13a49ad4 100644 --- a/src/components/airdrop/CampaignHero.tsx +++ b/src/components/airdrop/CampaignHero.tsx @@ -41,12 +41,7 @@ interface MilestoneRow { isFull: boolean; } -const CMC_RANKS: Record = { - 1_000_000: "≈ CMC #1900", - 10_000_000: "≈ CMC #950", - 50_000_000: "≈ CMC #400", - 100_000_000: "≈ CMC #250", -}; +const CMC_RANKS = ["≈ CMC #1900", "≈ CMC #950", "≈ CMC #400", "≈ CMC #250"]; /* ─── Helpers ─── */ @@ -96,7 +91,7 @@ function buildMilestoneRows( milestones: StatusData["milestones"], poolAmount: number, ): MilestoneRow[] { - return TIER_KEYS.map((key) => { + return TIER_KEYS.map((key, i) => { const ms = milestones[key]; const price = ms.mcap / MAX_SUPPLY; const unlockPlot = poolAmount * (ms.pct / 100); @@ -106,7 +101,7 @@ function buildMilestoneRows( unlockPlot, poolUsd: unlockPlot * price, burnPct: 100 - ms.pct, - cmcRank: CMC_RANKS[ms.mcap] ?? null, + cmcRank: CMC_RANKS[i] ?? null, isFull: ms.pct === 100, }; }); @@ -234,7 +229,7 @@ export function CampaignHero() {

5% OF ALL PLOT — LOCKED

-

+

Grow the market. Or watch it burn.