diff --git a/package-lock.json b/package-lock.json index f4eb8b5..ed88443 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "plotlink", - "version": "1.40.5", + "version": "1.41.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "plotlink", - "version": "1.40.5", + "version": "1.41.0", "workspaces": [ "packages/*" ], diff --git a/package.json b/package.json index 0bc61fb..eebae34 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "plotlink", - "version": "1.40.5", + "version": "1.41.0", "private": true, "workspaces": [ "packages/*" diff --git a/src/app/airdrop/AirdropStateMachine.tsx b/src/app/airdrop/AirdropStateMachine.tsx index aeac4cd..5607f80 100644 --- a/src/app/airdrop/AirdropStateMachine.tsx +++ b/src/app/airdrop/AirdropStateMachine.tsx @@ -1,8 +1,8 @@ "use client"; -import { useEffect, useState, useCallback } from "react"; +import { useEffect, useState, useCallback, useRef } from "react"; import { useAccount } from "wagmi"; -import Link from "next/link"; +import { useConnectModal } from "@rainbow-me/rainbowkit"; import { CampaignHero } from "../../components/airdrop/CampaignHero"; import { ActivationFlow } from "../../components/airdrop/ActivationFlow"; import { ContributionPanel } from "../../components/airdrop/ContributionPanel"; @@ -23,10 +23,7 @@ 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 { +function deriveState(isConnected: boolean, activatedAt: string | null): AirdropState { if (IS_PAUSED) return "paused"; if (MERKLE_CLAIM_ADDRESS) return "settlement-normal"; if (FINAL_BURN_TX) return "settlement-final-burn"; @@ -36,14 +33,50 @@ function deriveState( const needsFetch = !IS_PAUSED && !MERKLE_CLAIM_ADDRESS && !FINAL_BURN_TX; +function HeroBlock({ isConnected, onActivate }: { isConnected: boolean; onActivate: () => void }) { + const { openConnectModal } = useConnectModal(); + + return ( +
+

+ The airdrop pool
+ grows with us.
+ Or it burns. +

+

Buy storyline tokens · Bring friends · Push PLOT together

+ {!isConnected ? ( + + ) : ( + + )} +

One signature + a few clicks. ~2 min.

+
+ ); +} + export function AirdropStateMachine() { const { address, isConnected } = useAccount(); const [fetchResult, setFetchResult] = useState<{ activatedAt: string | null; done: boolean }>({ activatedAt: null, done: !needsFetch }); + const activationRef = useRef(null); const onActivated = useCallback(() => { setFetchResult({ activatedAt: new Date().toISOString(), done: true }); }, []); + const scrollToActivation = useCallback(() => { + activationRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }); + }, []); + useEffect(() => { if (!needsFetch || !isConnected || !address) return; @@ -57,14 +90,13 @@ export function AirdropStateMachine() { const activatedAt = (needsFetch && isConnected) ? fetchResult.activatedAt : null; const loading = needsFetch && isConnected && !fetchResult.done; - const state = deriveState(isConnected, activatedAt); if (state === "paused") { return ( -
-

Campaign Paused

-

Campaign temporarily paused. Will resume shortly.

+
+

Campaign Paused

+

Campaign temporarily paused. Will resume shortly.

); } @@ -73,9 +105,7 @@ export function AirdropStateMachine() { return ( <> -
- -
+
); } @@ -84,9 +114,7 @@ export function AirdropStateMachine() { return ( <> -
- -
+
); } @@ -95,9 +123,7 @@ export function AirdropStateMachine() { return ( <> -
-

Loading...

-
+

Loading...

); } @@ -106,32 +132,27 @@ export function AirdropStateMachine() { return ( <> -
- {!isConnected ? ( -
-

Connect your wallet to get started.

- - Browse storylines → - -
- ) : ( + + {isConnected && ( +
- )} +
+ )} +
); } + // mining return ( <>
-
- - -
+ +
); diff --git a/src/app/globals.css b/src/app/globals.css index 791516e..d97f20a 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -14,6 +14,8 @@ --success: oklch(45% 0.14 145); --burn: oklch(50% 0.20 25); --dist: oklch(45% 0.16 145); + --gold-1: #FFE07A; + --gold-2: #D9A847; --font-display: 'Newsreader', 'Iowan Old Style', Georgia, serif; --font-body: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; diff --git a/src/components/airdrop/MilestoneClimb.tsx b/src/components/airdrop/MilestoneClimb.tsx index 82f2f05..2657826 100644 --- a/src/components/airdrop/MilestoneClimb.tsx +++ b/src/components/airdrop/MilestoneClimb.tsx @@ -1,10 +1,11 @@ "use client"; import { useEffect, useState } from "react"; -import { useAccount } from "wagmi"; interface StatusData { currentFdv: number; + latestPriceUsd: number | null; + poolAmount: number; milestones: { bronze: { mcap: number; pct: number; reached: boolean }; silver: { mcap: number; pct: number; reached: boolean }; @@ -13,136 +14,155 @@ interface StatusData { }; } -interface ProjectionData { - projected_share: { bronze: number; silver: number; gold: number; diamond: number }; -} - interface MilestoneClimbProps { dimmed?: boolean; } -const TIERS = ["bronze", "silver", "gold", "diamond"] as const; -const TIER_LABELS: Record = { - bronze: "Bronze", - silver: "Silver", - gold: "Gold", - diamond: "Diamond", -}; +const TIERS = [ + { key: "bronze", emoji: "\u{1F949}", label: "Bronze", cx: 140, cy: 164 }, + { key: "silver", emoji: "\u{1F948}", label: "Silver", cx: 260, cy: 125 }, + { key: "gold", emoji: "\u{1F947}", label: "Gold", cx: 400, cy: 55 }, + { key: "diamond", emoji: "\u{1F48E}", label: "Diamond", cx: 530, cy: 18 }, +] as const; function formatMcap(n: number): string { - if (n >= 1_000_000) return `$${(n / 1_000_000).toFixed(0)}M`; + if (n >= 1_000_000) return `$${(n / 1_000_000).toFixed(1)}M`; if (n >= 1_000) return `$${(n / 1_000).toFixed(0)}K`; return `$${n}`; } +function formatPool(n: number): string { + if (n >= 1_000_000) return `$${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `$${(n / 1_000).toFixed(0)}K`; + if (n >= 1) return `$${Math.round(n)}`; + return "$0"; +} + +function interpolateFdvX(fdv: number, milestones: StatusData["milestones"]): number { + const mcaps = [milestones.bronze.mcap, milestones.silver.mcap, milestones.gold.mcap, milestones.diamond.mcap]; + const xs = [140, 260, 400, 530]; + + if (fdv <= 0) return 30; + if (fdv >= mcaps[3]) return 530; + + const logFdv = Math.log10(fdv); + const logMin = Math.log10(mcaps[0] * 0.1); + + for (let i = 0; i < mcaps.length; i++) { + if (fdv <= mcaps[i]) { + const prevX = i === 0 ? 30 : xs[i - 1]; + const prevLog = i === 0 ? logMin : Math.log10(mcaps[i - 1]); + const curLog = Math.log10(mcaps[i]); + const t = (logFdv - prevLog) / (curLog - prevLog); + return prevX + t * (xs[i] - prevX); + } + } + return 530; +} + +function interpolateFdvY(x: number): number { + const points = [[30, 175], [140, 164], [260, 125], [400, 55], [530, 18]]; + for (let i = 1; i < points.length; i++) { + if (x <= points[i][0]) { + const t = (x - points[i - 1][0]) / (points[i][0] - points[i - 1][0]); + return points[i - 1][1] + t * (points[i][1] - points[i - 1][1]); + } + } + return 18; +} + export function MilestoneClimb({ dimmed }: MilestoneClimbProps) { - const { address, isConnected } = useAccount(); const [status, setStatus] = useState(null); - const [projection, setProjection] = useState(null); useEffect(() => { fetch("/api/airdrop/status") .then(r => r.ok ? r.json() : null) - .then(d => setStatus(d)) + .then(d => { if (d) setStatus(d); }) .catch(() => {}); }, []); - useEffect(() => { - if (!isConnected || !address || dimmed) return; - fetch(`/api/airdrop/projection?address=${address.toLowerCase()}`) - .then(r => r.ok ? r.json() : null) - .then(d => setProjection(d)) - .catch(() => {}); - }, [isConnected, address, dimmed]); - if (!status) { return ( -
-

Milestone Climb

-

Loading...

+
+

Loading milestone chart...

); } - const { milestones, currentFdv } = status; - const tierData = TIERS.map(tier => ({ - key: tier, - label: TIER_LABELS[tier], - mcap: milestones[tier].mcap, - pct: milestones[tier].pct, - reached: milestones[tier].reached, - share: projection?.projected_share[tier] ?? 0, - })); - - const minMcap = tierData[0].mcap; - const maxMcap = tierData[tierData.length - 1].mcap; - const logMin = Math.log10(minMcap * 0.5); - const logMax = Math.log10(maxMcap * 1.5); - const logRange = logMax - logMin; - - const svgW = 300; - const svgH = 160; - const padX = 40; - const padY = 20; - const chartW = svgW - padX * 2; - const chartH = svgH - padY * 2; - - function xPos(mcap: number) { - const logVal = Math.log10(Math.max(mcap, 1)); - const normalized = Math.max(0, Math.min(1, (logVal - logMin) / logRange)); - return padX + normalized * chartW; - } - - const fdvX = xPos(currentFdv); - const lineY = padY + chartH * 0.5; + const { milestones, currentFdv, latestPriceUsd, poolAmount } = status; + const plotPrice = latestPriceUsd ?? 0; - return ( -
-

Milestone Climb

+ const tierData = TIERS.map(t => { + const m = milestones[t.key as keyof typeof milestones]; + return { + ...t, + mcap: m.mcap, + pct: m.pct, + reached: m.reached, + poolUsd: (poolAmount * m.pct / 100) * plotPrice, + }; + }); - - {/* Baseline */} - + const fdvX = interpolateFdvX(currentFdv, milestones); + const fdvY = interpolateFdvY(fdvX); - {/* Tier nodes */} - {tierData.map(t => { - const cx = xPos(t.mcap); - return ( + return ( +
+
+ + + + + + + + + + + + + + + + {tierData.map(t => ( - - - - {t.label} - - - {formatMcap(t.mcap)} - - {!dimmed && t.share > 0 && ( - - {Math.round(t.share).toLocaleString()} PLOT - - )} + + + {t.emoji} - ); - })} - - {/* Current FDV marker */} - - - - {formatMcap(currentFdv)} - - + ))} + + + + + + + +
+ TODAY {formatMcap(currentFdv)} +
+
+ +
+ {tierData.map(t => ( +
+
{t.emoji} {t.label}
+
{formatMcap(t.mcap)}
+
→ ~{formatPool(t.poolUsd)} pool
+
+ ))} +
); }