diff --git a/package.json b/package.json index aa7cb2cf..e04e3e9a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "plotlink", - "version": "0.1.36", + "version": "0.1.37", "private": true, "workspaces": [ "packages/*" diff --git a/src/app/airdrop/page.tsx b/src/app/airdrop/page.tsx index 660d68ad..54df2487 100644 --- a/src/app/airdrop/page.tsx +++ b/src/app/airdrop/page.tsx @@ -4,7 +4,6 @@ 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 { MilestoneTrack } from "../../components/airdrop/MilestoneTrack"; import { AIRDROP_CONFIG } from "../../../lib/airdrop/config"; export const metadata: Metadata = { @@ -29,7 +28,6 @@ export default function AirdropPage() { {/* Right column: global sections */}
-
diff --git a/src/app/api/airdrop/daily-prices/route.ts b/src/app/api/airdrop/daily-prices/route.ts new file mode 100644 index 00000000..3e6644ed --- /dev/null +++ b/src/app/api/airdrop/daily-prices/route.ts @@ -0,0 +1,34 @@ +/** + * Daily FDV history for the campaign timeline chart (#936) + * GET /api/airdrop/daily-prices — no auth required + * + * Returns array of { date, fdv } ordered by date ascending. + */ + +import { NextResponse } from "next/server"; +import { createServerClient } from "../../../../../lib/supabase"; + +export async function GET() { + const supabase = createServerClient(); + if (!supabase) { + return NextResponse.json({ error: "Supabase not configured" }, { status: 500 }); + } + + const { data, error } = await supabase + .from("pl_daily_prices") + .select("recorded_at, mcap_usd") + .order("recorded_at", { ascending: true }); + + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } + + const points = (data ?? []).map((row) => ({ + date: row.recorded_at, + fdv: Number(row.mcap_usd), + })); + + return NextResponse.json(points, { + headers: { "Cache-Control": "public, s-maxage=300, stale-while-revalidate=60" }, + }); +} diff --git a/src/components/airdrop/CampaignHero.tsx b/src/components/airdrop/CampaignHero.tsx index 7f9fa321..3b5efbcb 100644 --- a/src/components/airdrop/CampaignHero.tsx +++ b/src/components/airdrop/CampaignHero.tsx @@ -1,8 +1,11 @@ "use client"; +import { useEffect, useMemo, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { formatUsdValue } from "../../../lib/usd-price"; +/* ─── Types ─── */ + interface StatusData { campaignStart: string; campaignEnd: string; @@ -22,6 +25,42 @@ 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" }, +]; + +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 }; + }); +} + +/* ─── SVG layout ─── */ + +const SVG_W = 700; +const SVG_H = 340; +const PAD = { top: 30, right: 70, bottom: 40, left: 70 }; +const CW = SVG_W - PAD.left - PAD.right; +const CH = SVG_H - PAD.top - PAD.bottom; + +/* ─── Helpers ─── */ + function useAirdropStatus() { return useQuery({ queryKey: ["airdrop-status"], @@ -35,31 +74,395 @@ function useAirdropStatus() { }); } -function CountdownDisplay({ days }: { days: number }) { - const weeks = Math.floor(days / 7); - const remainingDays = days % 7; +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 }); + + useEffect(() => { + const endMs = new Date(endDateStr + "T00:00:00Z").getTime(); + function update() { + const diff = Math.max(0, endMs - Date.now()); + const totalSec = Math.floor(diff / 1000); + setRemaining({ + d: Math.floor(totalSec / 86400), + h: Math.floor((totalSec % 86400) / 3600), + m: Math.floor((totalSec % 3600) / 60), + s: totalSec % 60, + }); + } + update(); + const id = setInterval(update, 1000); + return () => clearInterval(id); + }, [endDateStr]); + + 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 fdvToY(fdv: number, logMax: number): number { + if (fdv <= 0) return PAD.top + CH; + const t = (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, + tiers, +}: { + campaignStart: string; + campaignEnd: string; + currentFdv: number; + poolAmount: number; + tiers: Tier[]; +}) { + 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.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]); + + // Pool value step line from daily price data + const poolStepPath = useMemo(() => { + if (!dailyPrices?.length) return ""; + 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(" "); + }, [dailyPrices, startMs, endMs, totalMs, poolAmount, nowX, yLeftMax, tiers]); + + // Pool value area fill + const poolAreaPath = useMemo(() => { + if (!poolStepPath) return ""; + const baseline = poolToY(0, yLeftMax); + // 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, dailyPrices, startMs, totalMs, nowX, yLeftMax]); + + // Actual FDV line + const actualFdvPath = useMemo(() => { + if (!dailyPrices?.length) return ""; + 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(" "); + }, [dailyPrices, startMs, endMs, totalMs, currentFdv, nowX, fdvLogMax]); + + // Linear projection: from current position (nowX) → Diamond at campaign end + const projectionPath = useMemo(() => { + const toX = PAD.left + CW; + const fromY = fdvToY(currentFdv > 0 ? currentFdv : 100, fdvLogMax); + const toY = fdvToY(diamondFdv, fdvLogMax); + return `M ${nowX} ${fromY} L ${toX} ${toY}`; + }, [currentFdv, nowX, 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 return ( -
- {weeks > 0 && ( -
-
{weeks}
-
weeks
-
- )} - {weeks > 0 && ( -
:
- )} -
-
{weeks > 0 ? remainingDays : days}
-
days
+
+ + + + + + + + + {/* 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} + + + ))} + + {/* Axis labels */} + + Pool Value (USD) + + + 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 +
); } +/* ─── Main component ─── */ + export function CampaignHero() { const { data, isLoading } = useAirdropStatus(); + const countdown = useCountdown(data?.campaignEnd ?? "2027-01-01"); + + const tiers = useMemo( + () => (data ? buildTiers(data.milestones) : []), + [data], + ); if (isLoading || !data) { return ( @@ -69,33 +472,57 @@ export function CampaignHero() { ); } + const pad2 = (n: number) => String(n).padStart(2, "0"); + return ( -
- {/* Title + Tagline */} -
+
+ {/* Title + Explanation */} +

PLOT Big or Nothing Airdrop

-

- {data.poolAmount.toLocaleString()} PLOT locked. Earn or burn. +

+ {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.

+ + {/* Lock-up proof */} + {data.lockerId ? ( + + 🔒 View lock-up proof on Mint Club + + ) : ( + + 🔒 Lock-up proof: pending + + )}
- {/* Countdown */} + {/* Live Countdown */} {data.timeRemainingDays > 0 && ( -
- -
-
-
-
-
- {data.timeElapsedPercent}% elapsed +
+ {[ + { val: countdown.d, label: "days" }, + { val: countdown.h, label: "hrs" }, + { val: countdown.m, label: "min" }, + { val: countdown.s, label: "sec" }, + ].map((unit, i) => ( +
+ {i > 0 && :} +
+
+ {i === 0 ? unit.val : pad2(unit.val)} +
+
{unit.label}
+
-
+ ))}
)} @@ -117,19 +544,25 @@ export function CampaignHero() {
- {/* Lockup proof */} - {data.lockerId && ( -
- - View lockup proof on-chain - + {/* 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"} +
+
); }