From 33814c68f6217802dfbaa185366e680483300f7d Mon Sep 17 00:00:00 2001 From: dev-2 Date: Tue, 28 Apr 2026 12:31:27 +0000 Subject: [PATCH] Redesign airdrop milestones for clarity (#1005) Replace the dense SVG TimelineChart and 3-column StatsRow with a 4-segment progress bar plus 4 milestone cards, so users can read the "reach milestones or it burns" stakes at a glance. - CampaignHero: drop TimelineChart (hard-to-parse log/linear/projection overlay) and StatsRow (info absorbed into the new layout). - New SegmentedProgressBar: 4 equal segments, log-scale fill within each, so reaching Bronze visibly fills 25% instead of looking like 1% on a Diamond-anchored bar. Indicator on the active segment. - New MilestoneCard: tier emoji + label, FDV target, CMC rank (prod-only), unlock %, PLOT amount, pool USD, burn %. Reached cards get an accent border + check; future cards dim. - All values come from the API milestones (config-driven, test/prod). - Responsive: 2x2 mobile, 4-column desktop. - Delete MilestoneTrack.tsx (unused after CampaignHero owns the layout). Bumps to 1.3.0 (feature). Co-Authored-By: Claude Opus 4.7 (1M context) --- package-lock.json | 4 +- package.json | 2 +- src/components/airdrop/CampaignHero.tsx | 664 +++++----------------- src/components/airdrop/MilestoneTrack.tsx | 420 -------------- 4 files changed, 157 insertions(+), 933 deletions(-) delete mode 100644 src/components/airdrop/MilestoneTrack.tsx diff --git a/package-lock.json b/package-lock.json index f131c00..84ae342 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "plotlink", - "version": "1.2.2", + "version": "1.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "plotlink", - "version": "1.2.2", + "version": "1.3.0", "workspaces": [ "packages/*" ], diff --git a/package.json b/package.json index 0a8bbd8..eb265f3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "plotlink", - "version": "1.2.2", + "version": "1.3.0", "private": true, "workspaces": [ "packages/*" diff --git a/src/components/airdrop/CampaignHero.tsx b/src/components/airdrop/CampaignHero.tsx index 7f9dbd4..7354c69 100644 --- a/src/components/airdrop/CampaignHero.tsx +++ b/src/components/airdrop/CampaignHero.tsx @@ -25,40 +25,37 @@ interface StatusData { lockerTx: 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" }, + { key: "bronze" as const, emoji: "🥉", label: "Bronze", cmcRank: 1900 }, + { key: "silver" as const, emoji: "🥈", label: "Silver", cmcRank: 950 }, + { key: "gold" as const, emoji: "🥇", label: "Gold", cmcRank: 400 }, + { key: "diamond" as const, emoji: "💎", label: "Diamond", cmcRank: 250 }, ]; -type Tier = { key: string; emoji: string; label: string; fdv: number; pct: number }; +type Tier = { + key: string; + emoji: string; + label: string; + cmcRank: number; + fdv: number; + pct: number; + reached: boolean; +}; + +const IS_PROD_MODE = process.env.NEXT_PUBLIC_AIRDROP_MODE !== "test"; /** 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 }; + return { ...m, fdv: ms.mcap, pct: ms.pct, reached: ms.reached }; }); } -/* ─── 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; - /* ─── Helpers ─── */ function useAirdropStatus() { @@ -74,39 +71,14 @@ 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; +/** Pool USD at a given milestone: poolAmount * (pct/100) * (fdv / maxSupply). */ +function poolUsdAtTier(tier: Tier, poolAmount: number): number { + return poolAmount * (tier.pct / 100) * (tier.fdv / MAX_SUPPLY); } -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)}`; +/** PLOT unlocked at a given milestone. */ +function plotAtTier(tier: Tier, poolAmount: number): number { + return poolAmount * (tier.pct / 100); } /* ─── Countdown hook ─── */ @@ -134,465 +106,146 @@ 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; -} +/* ─── Segmented progress bar ─── */ -function poolToY(usd: number, yLeftMax: number): number { - return PAD.top + CH * (1 - usd / yLeftMax); -} - -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); -} - -/* ─── Chart sub-component ─── */ - -function TimelineChart({ - campaignStart, - campaignEnd, - currentFdv, - poolAmount, +/** + * 4-segment progress bar. Each segment represents one milestone tier and + * fills based on log-scale progress between adjacent milestones, so reaching + * Bronze visibly fills the first segment instead of looking like 1% of the bar. + */ +function SegmentedProgressBar({ tiers, + currentFdv, }: { - campaignStart: string; - campaignEnd: string; - currentFdv: number; - poolAmount: number; tiers: Tier[]; + currentFdv: 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)}`); + const segments = useMemo(() => { + return tiers.map((t, i) => { + const lowerFdv = i === 0 ? t.fdv / 10 : tiers[i - 1].fdv; + let fillPct = 0; + if (currentFdv >= t.fdv) { + fillPct = 100; + } else if (currentFdv > lowerFdv) { + const logCur = Math.log10(currentFdv); + const logLow = Math.log10(lowerFdv); + const logHi = Math.log10(t.fdv); + fillPct = ((logCur - logLow) / (logHi - logLow)) * 100; } - 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]); - - return ( -
- {/* Desktop: full SVG chart */} -
- - - - - - - - - {/* Grid lines (horizontal at each milestone FDV) */} - {milestoneLines.map((m) => ( - - - {/* Right-side label */} - - {m.emoji} {formatCompact(m.fdv)} - - - ))} + return { ...t, fillPct }; + }); + }, [tiers, currentFdv]); - {/* Y-left axis ticks (pool value) */} - - {yLeftTicks.map((val) => ( - - {formatCompact(val)} - - ))} - - - {/* X-axis month labels */} - {months.map((m) => ( - - - - {m.label} - - - ))} + const indicatorIdx = segments.findIndex((s) => s.fillPct < 100 && s.fillPct > 0); + const indicatorSegment = + indicatorIdx === -1 + ? segments.findIndex((s) => s.fillPct === 0) + : indicatorIdx; - {/* Axis labels */} - - Pool Value (USD) - - +
+ {segments.map((s) => ( +
- FDV (USD) - - - {/* 1. Pool value area fill */} - {poolAreaPath && ( - - )} - - {/* 2. Pool value step line */} - {poolStepPath && ( - - )} - - {/* 3. Linear FDV projection (dashed) */} - - - {/* 4. Actual FDV line (solid) */} - {actualFdvPath && ( - - )} - - {/* Heartbeat dot on current FDV position */} - {currentFdv > 0 && ( - - {/* Pulse ring */} - - - - - {/* Solid dot */} - - - - - - )} - - {/* Chart border */} - - - - {/* 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}%` }} + style={{ width: `${s.fillPct}%` }} />
-
+ ))} +
- {/* Milestone list */} -
- {tiers.map((t) => { - const reached = currentFdv >= t.fdv; - const tierPct = t.pct; - return ( -
- - {t.emoji} {t.label} - - - {formatCompact(t.fdv)} - - {tierPct}% - - +
+ {segments.map((s, i) => ( +
+
+ {s.emoji} {formatUsdValue(s.fdv)} +
+ {indicatorSegment === i && currentFdv > 0 && ( +
+ Current: {formatUsdValue(currentFdv)}
- ); - })} -
- - {/* Linear target comparison */} -
-
Linear target today
-
{formatCompact(linearTargetToday)}
-
+ )} +
+ ))}
); } -/* ─── Stats row sub-component ─── */ +/* ─── Milestone card ─── */ -function StatsRow({ - participants, - currentFdv, - tiers, +function MilestoneCard({ + tier, + poolAmount, + isCurrentTarget, }: { - participants: number; - currentFdv: number; - tiers: Tier[]; + tier: Tier; + poolAmount: number; + isCurrentTarget: boolean; }) { - // 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}`; + const plot = plotAtTier(tier, poolAmount); + const poolUsd = poolUsdAtTier(tier, poolAmount); + const burnPct = 100 - tier.pct; + + const visualState = tier.reached + ? "border-accent text-foreground" + : isCurrentTarget + ? "border-border text-foreground" + : "border-border opacity-50"; return ( -
-
-
{participants}
-
Participants
-
-
-
- {currentFdv > 0 ? formatUsdValue(currentFdv) : "\u2014"} +
+
+
+ {tier.emoji} {tier.label}
-
FDV
+ {tier.reached && ( + + )}
-
-
{progressPct}%
-
{progressLabel}
-
-
+ +
+
+ FDV + {formatUsdValue(tier.fdv)} +
+ + {IS_PROD_MODE && ( +
+ CMC + ~#{tier.cmcRank.toLocaleString()} +
+ )} + +
+ Unlock + {tier.pct}% +
+
+ PLOT + {plot.toLocaleString()} +
+ +
+ Pool + ~{formatUsdValue(poolUsd)} +
+
+ Burn + {burnPct}%
@@ -620,6 +273,9 @@ export function CampaignHero() { const pad2 = (n: number) => String(n).padStart(2, "0"); + // Current target = first unreached tier; null if all reached + const currentTargetIdx = tiers.findIndex((t) => !t.reached); + return (
{/* Title + Explanation */} @@ -628,9 +284,9 @@ export function CampaignHero() { PLOT Big or Nothing Airdrop

- {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. + {data.poolAmount.toLocaleString()} PLOT locked in a time-locked contract. + Reach milestone FDV targets and the pool is distributed to point holders. + Miss them and the unreached portion is burned forever.

{/* Lock-up proof */} @@ -672,31 +328,19 @@ export function CampaignHero() {
)} - {/* Stats row */} - - - {/* 6-Month Timeline Chart */} - - - {/* Current position summary */} -
-
- Current FDV: {data.currentFdv > 0 ? formatUsdValue(data.currentFdv) : "\u2014"} - · Zone: {currentZoneLabel(data.currentFdv, tiers)} -
-
- Pool value: {data.currentFdv > 0 ? formatUsdValue(poolValueAtFdv(data.currentFdv, data.poolAmount, tiers)) : "$0"} -
+ {/* Segmented progress bar */} + + + {/* Milestone cards: 2x2 mobile, 4-col desktop */} +
+ {tiers.map((t, i) => ( + + ))}
); diff --git a/src/components/airdrop/MilestoneTrack.tsx b/src/components/airdrop/MilestoneTrack.tsx deleted file mode 100644 index 05e5d1c..0000000 --- a/src/components/airdrop/MilestoneTrack.tsx +++ /dev/null @@ -1,420 +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; - -/** Visual presentation per tier; FDV and poolPct come from API/config. */ -const TIER_PRESENTATION = [ - { key: "bronze" as const, label: "Bronze", emoji: "\uD83E\uDD49" }, - { key: "silver" as const, label: "Silver", emoji: "\uD83E\uDD48" }, - { key: "gold" as const, label: "Gold", emoji: "\uD83E\uDD47" }, - { key: "diamond" as const, label: "Diamond", emoji: "\uD83D\uDC8E" }, -]; - -/** 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 bounds derived from the active milestones so -// test mode (e.g. $7K bronze) and prod mode (e.g. $1M bronze) both render. -function computeFdvBounds(milestones: ChartMilestone[]): { logMin: number; logMax: number; floor: number } { - const minFdv = Math.max(milestones[0].fdv / 100, 1); - const maxFdv = milestones[milestones.length - 1].fdv * 2; - return { logMin: Math.log10(minFdv), logMax: Math.log10(maxFdv), floor: minFdv }; -} - -function fdvToX(fdv: number, logMin: number, logMax: number, floor: number): number { - if (fdv <= 0) return PAD.left; - const logVal = Math.log10(Math.max(fdv, floor)); - const t = (logVal - logMin) / (logMax - logMin); - 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, logMin: number, logMax: number, floor: number): string { - const baseline = usdToY(0, yMax); - let path = `M ${fdvToX(floor, logMin, logMax, floor)} ${baseline}`; - let prevY = baseline; - for (const m of milestones) { - const x = fdvToX(m.fdv, logMin, logMax, floor); - 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, logMin: number, logMax: number, floor: 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, logMin, logMax, floor); - const y = usdToY(m.poolUsd, yMax); - if (i === 0) { - path = `M ${fdvToX(floor, logMin, logMax, floor)} ${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( - () => - TIER_PRESENTATION.map((t) => { - const ms = data?.milestones?.[t.key]; - return { - label: t.label, - emoji: t.emoji, - fdv: ms?.mcap ?? 0, - poolPct: ms?.pct ?? 0, - poolUsd: poolUsdAt(ms?.mcap ?? 0, ms?.pct ?? 0, data?.poolAmount ?? 0), - }; - }), - [data?.milestones, data?.poolAmount], - ); - - if (isLoading || !data) { - return ( -
-
Loading milestones...
-
- ); - } - - const { logMin, logMax, floor } = computeFdvBounds(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, floor), logMin, logMax, floor); - const dotY = usdToY(currentPoolUsd, yMax); - - const areaPath = buildAreaPath(milestones, yMax, logMin, logMax, floor); - const linePath = buildLinePath(milestones, yMax, logMin, logMax, floor); - - 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" : formatUsdValue(val)} - - - ))} - - {/* Filled area */} - - - {/* Step line */} - - - {/* Vertical zone dividers + annotations */} - {milestones.map((m) => { - const x = fdvToX(m.fdv, logMin, logMax, floor); - const y = usdToY(m.poolUsd, yMax); - return ( - - - - {m.emoji} {m.label} - - - {m.poolPct}% ({formatUsdValue(m.poolUsd)}) - - - - ); - })} - - {/* X-axis labels */} - {milestones.map((m) => ( - - {formatUsdValue(m.fdv)} - - ))} - - {/* 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 -
- )} -
-
- ); -}