From 37b2aa3eea1e3ad3091ae3337239fbe7d42fb6b5 Mon Sep 17 00:00:00 2001 From: Kevin Codex Date: Fri, 16 Oct 2026 14:59:23 +0800 Subject: [PATCH 1/3] feat(boost): invite boosts from the lane: Boost a token, open spots, entry line, booster credit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "Boost a token" in the lane header on every screen size; each open place is a button from 1024px, one "+ N open spots · Boost a token" row below that. All open the picker (BoostPicker, loaded on first use): the connected wallet's own Base launches first, a Base search, or trending on Base. Picking goes to /t/base/?boost=1#boost, where the dialog opens with every check it has; nothing is sent from the lane. - What it takes to get in, beside the title (its own line on a phone): "2 open spots · from 100K TWIG", or "Full · 5th place holds 1.2M TWIG". The lane read carries `entry` from the same ranked list the token page uses. - Each card credits the wallet that sent the most TWIG for it in the last 24 hours ("by @name ✓" via WhoName, the short address otherwise; ties go to the newer boost, then the address). The credit replaces the card's "Latest burn" link; the burns stay on the token page's Boost panel. A failed credit read never fails the lane. --- app/src/app/llms.txt/route.ts | 2 +- app/src/components/launchpad/BoostPicker.tsx | 151 ++++++++++++ app/src/components/launchpad/BoostedLane.tsx | 233 +++++++++++------- app/src/components/launchpad/boost-ui.test.ts | 57 ++++- app/src/components/ui.ts | 1 + app/src/lib/launchpad/boost.test.ts | 30 +++ app/src/lib/launchpad/boost.ts | 51 ++++ app/src/lib/launchpad/boostServer.ts | 43 +++- docs/boost.md | 8 + 9 files changed, 466 insertions(+), 110 deletions(-) create mode 100644 app/src/components/launchpad/BoostPicker.tsx diff --git a/app/src/app/llms.txt/route.ts b/app/src/app/llms.txt/route.ts index 1af2b0bc..8d8b4be7 100644 --- a/app/src/app/llms.txt/route.ts +++ b/app/src/app/llms.txt/route.ts @@ -15,7 +15,7 @@ To boost a launch: TWIG (${BOOST_TOKEN}).transfer(0x…dEaD, amount) with amount (whole = a count of whole TWIG, at least ${BOOST_FLOOR_TWIG}; token_id = the launch's position id, the list API's token_id field, greater than 0 and less than 10^11). The last 12 digits name the launch; a transfer outside these bounds is not a boost. Then POST ${SITE_URL}/api/launch/sync?chain=base&tx=0x… to show it at once. Burns are final. -- GET ${SITE_URL}/api/boost?chain=base (the lane: twig, boosts, last_at, expires_at, clicks, launch) +- GET ${SITE_URL}/api/boost?chain=base (the lane: twig, boosts, last_at, expires_at, clicks, top_booster, launch; entry) - GET ${SITE_URL}/api/boost?chain=base&token=0x… (one token: twig in the window, place, hidden) `; } diff --git a/app/src/components/launchpad/BoostPicker.tsx b/app/src/components/launchpad/BoostPicker.tsx new file mode 100644 index 00000000..5dc33ffe --- /dev/null +++ b/app/src/components/launchpad/BoostPicker.tsx @@ -0,0 +1,151 @@ +"use client"; + +import { useEffect, useId, useState } from "react"; +import { useRouter } from "next/navigation"; +import { ChevronRight, Search } from "lucide-react"; +import Sheet from "@/components/Sheet"; +import TokenAvatar from "./TokenAvatar"; +import { useHydratedAccount } from "@/lib/useHydratedAccount"; +import { isSearchableQuery, paletteTokenFrom, type PaletteToken } from "@/lib/command-palette"; +import { marketUsd } from "@/lib/launchpad/market-format"; +import { setPendingToken } from "@/lib/launchpad/token-transition"; +import { BOOST_CHAIN, BOOST_COPY } from "@/lib/launchpad/boost"; + +type LaunchesResponse = { launches?: Parameters[0][] }; + +/** How many of the wallet's own launches lead the list, and how many trending tokens fill it before anything is typed. */ +const MINE_MAX = 5; +const TRENDING_MAX = 6; + +/** + * "Boost a token" from the Boosted lane: pick a Base token, land on its page with the boost dialog open + * (`?boost=1#boost`, the same link /me's Boost uses). Nothing is sent from here: the token's page shows the amount, the + * lane and the burn before the wallet is asked, with every check the dialog has. The connected wallet's own Base + * launches come first (their creators are the likeliest boosters), then a search of Base tokens, or what is trending + * on Base before anything is typed. + */ +export default function BoostPicker({ onClose }: { onClose: () => void }) { + const router = useRouter(); + const { address } = useHydratedAccount(); + const inputId = useId(); + const [query, setQuery] = useState(""); + const [mine, setMine] = useState<{ wallet: string; tokens: PaletteToken[] } | null>(null); + const [trending, setTrending] = useState(null); + const [results, setResults] = useState<{ q: string; tokens: PaletteToken[]; failed: boolean } | null>(null); + + // the wallet's own Base launches, newest first (what /me lists) + const wallet = address?.toLowerCase() ?? null; + useEffect(() => { + if (!wallet) return; + const ac = new AbortController(); + fetch(`/api/me?wallet=${wallet}`, { signal: ac.signal, cache: "no-store" }) + .then((r) => (r.ok ? (r.json() as Promise) : null)) + .then((j) => setMine({ wallet, tokens: (j?.launches ?? []).filter((l) => l.chain === BOOST_CHAIN).slice(0, MINE_MAX).map(paletteTokenFrom) })) + .catch(() => { + if (!ac.signal.aborted) setMine({ wallet, tokens: [] }); + }); + return () => ac.abort(); + }, [wallet]); + + useEffect(() => { + const ac = new AbortController(); + fetch(`/api/launch/list?chain=${BOOST_CHAIN}&sort=live&limit=${TRENDING_MAX}`, { signal: ac.signal, cache: "no-store" }) + .then((r) => (r.ok ? (r.json() as Promise) : null)) + .then((j) => setTrending((j?.launches ?? []).filter((l) => l.chain === BOOST_CHAIN).map(paletteTokenFrom))) + .catch(() => { + if (!ac.signal.aborted) setTrending([]); + }); + return () => ac.abort(); + }, []); + + // Base tokens only, debounced; a late answer for an older query is dropped + const q = query.trim(); + useEffect(() => { + if (!isSearchableQuery(q)) return; + const ac = new AbortController(); + const timer = setTimeout(() => { + fetch(`/api/launch/search?q=${encodeURIComponent(q)}&chain=${BOOST_CHAIN}&order=holders`, { signal: ac.signal, cache: "no-store" }) + .then((r) => (r.ok ? (r.json() as Promise) : Promise.reject(new Error(String(r.status))))) + .then((j) => setResults({ q, tokens: (j.launches ?? []).filter((l) => l.chain === BOOST_CHAIN).slice(0, 8).map(paletteTokenFrom), failed: false })) + .catch(() => { + if (!ac.signal.aborted) setResults({ q, tokens: [], failed: true }); + }); + }, 160); + return () => { + clearTimeout(timer); + ac.abort(); + }; + }, [q]); + + // the boost happens on the token's page: this only navigates there, with the dialog asked to open + const pick = (t: PaletteToken) => { + setPendingToken({ chain: t.chain, token: t.token, name: t.name, symbol: t.symbol, image: t.image }); + onClose(); + router.push(`/t/${BOOST_CHAIN}/${t.token}?boost=1#boost`); + }; + + const searching = isSearchableQuery(q); + const current = results && results.q === q ? results : null; + const own = mine && mine.wallet === wallet ? mine.tokens : []; + const status = searching ? (current ? (current.failed ? BOOST_COPY.pickerFailed : current.tokens.length === 0 ? BOOST_COPY.pickerNone : "") : "Searching…") : ""; + + return ( + +
+

{BOOST_COPY.pickerNote}

+
+
+

{status}

+ {searching ? ( + current && current.tokens.length > 0 ? : null + ) : ( + <> + {own.length > 0 ? : null} + {trending === null ?

Loading…

: trending.length > 0 ? : null} + + )} +
+
+ ); +} + +function TokenList({ title, tokens, onPick }: { title?: string; tokens: readonly PaletteToken[]; onPick: (t: PaletteToken) => void }) { + const titleId = useId(); + return ( +
+ {title ?

{title}

: null} +
    + {tokens.map((t) => ( +
  • + +
  • + ))} +
+
+ ); +} diff --git a/app/src/components/launchpad/BoostedLane.tsx b/app/src/components/launchpad/BoostedLane.tsx index 8654ab60..629357e9 100644 --- a/app/src/components/launchpad/BoostedLane.tsx +++ b/app/src/components/launchpad/BoostedLane.tsx @@ -1,19 +1,25 @@ "use client"; import Link from "next/link"; +import dynamic from "next/dynamic"; import { useEffect, useRef, useState, type CSSProperties } from "react"; import { motion, useReducedMotion } from "motion/react"; -import { ArrowUpRight, ChevronDown, MousePointerClick } from "lucide-react"; +import { ArrowUpRight, ChevronDown, Flame, MousePointerClick, Plus } from "lucide-react"; import TokenAvatar from "./TokenAvatar"; import { TwigMark } from "./TwigBadge"; import { useLive } from "./LiveProvider"; import LiveNumber from "@/components/LiveNumber"; +import { btn } from "@/components/ui"; +import { WhoName } from "@/components/profile/Who"; import type { BoostLane } from "@/lib/launchpad/boostServer"; -import { BOOST_COPY, BOOST_LANE_SIZE, boostFuse, boostIgnitions, boostTimeLeft, boostWholeOf, type BoostSource } from "@/lib/launchpad/boost"; +import { BOOST_CHAIN, BOOST_COPY, BOOST_FLOOR_TWIG, BOOST_LANE_SIZE, boostEntryLine, boostFuse, boostIgnitions, boostTimeLeft, boostWholeOf, compactWhole, type BoostSource } from "@/lib/launchpad/boost"; import { capDisplay } from "@/lib/launchpad/market-cap"; -import { explorerTx } from "@/lib/chainPublic"; +import { explorerAddress, shortAddr } from "@/lib/chainPublic"; import { setPendingToken } from "@/lib/launchpad/token-transition"; +// the picker (its searches) loads on first use, not with the page +const BoostPicker = dynamic(() => import("./BoostPicker"), { ssr: false }); + /** The rolling TWIG figure: "1.2M", "300K", like fmtBoostTwig (Intl rounds 999,999 up to "1M" by itself). */ const TWIG_FORMAT = { notation: "compact", maximumFractionDigits: 2 } as const; /** A card's clicks: "312", "1.2K". */ @@ -29,6 +35,8 @@ const EMBERS: readonly { dx: number; dy: number; delay: number; size: number; in ]; /** An ignition is drawn for this long; older entries are dropped when the next one is recorded. */ const IGNITE_KEEP_MS = 10_000; +/** "from 100K TWIG": what an open spot asks. */ +const FROM_FLOOR = `from ${compactWhole(Number(BOOST_FLOOR_TWIG))} TWIG`; /** * The lane's beacon to /api/boost/seen: the lane came on screen, or a card was clicked. sendBeacon outlives the @@ -59,8 +67,13 @@ function sendSeen(body: { kind: "view" } | { kind: "click"; token: string }, sou * a click only while the token is still in the lane, once per visitor and 30 minutes. Each card shows its clicks of * the last 24 hours (the window its TWIG is counted in) once there is one, rolling up when a poll brings more. * - * Under 640px a card is one compact row (the name left; TWIG, clicks and time left on the right) so all five places - * fit on a phone screen; "Latest burn" is on the token page there. From 640px it is a card, as before. + * Under 640px a card is one compact row (the name left; TWIG, clicks and time left on the right, the credit under + * it) so all five places fit on a phone screen. From 640px it is a card. Each card credits the wallet that sent the + * most TWIG for it in the window ("by @name"); the burn itself is one step away, on the token's page. + * + * The lane invites a boost: "Boost a token" in its header, every open place a button (one row for all of them below + * 1024px), and a line saying what it takes to get in. Each opens the picker, which only navigates to the token's page + * with the boost dialog open: nothing is sent from the lane. */ export default function BoostedLane({ initial, serverNow, source }: { initial: BoostLane; serverNow: number; source: BoostSource }) { const { subscribe, wantBoost } = useLive(); @@ -70,6 +83,7 @@ export default function BoostedLane({ initial, serverNow, source }: { initial: B // what each card held at the last snapshot: a token with more TWIG than this, freshly boosted, lights up const prev = useRef | null>(initial.enabled ? new Map(initial.items.map((i) => [i.token, i.twig])) : null); const [ignited, setIgnited] = useState>({}); + const [picking, setPicking] = useState(false); const section = useRef(null); const viewed = useRef(false); @@ -123,14 +137,25 @@ export default function BoostedLane({ initial, serverNow, source }: { initial: B if (!lane.enabled) return null; const items = shown; + const open = Math.max(0, BOOST_LANE_SIZE - items.length); + const entryLine = boostEntryLine(open, lane.entry); + const pick = () => setPicking(true); return (
-
-

- - {BOOST_COPY.laneTitle} -

-

{BOOST_COPY.laneNote}

+
+
+

+ + {BOOST_COPY.laneTitle} +

+ {/* what it takes to get in: beside the title from 640px, a line of its own under it on a phone */} + {entryLine ?

{entryLine}

: null} + +
+

{BOOST_COPY.laneNote}

{/* the whole rule, in place: no page to keep in step with the switch */}
@@ -141,94 +166,114 @@ export default function BoostedLane({ initial, serverNow, source }: { initial: B

{BOOST_COPY.final}

- {items.length === 0 ? ( -

{BOOST_COPY.laneEmpty}

- ) : ( -
    - {items.map((b, i) => { - const l = b.launch; - const cap = capDisplay(l.fdv_quote, l.quote_usd, { key: l.quote_key, symbol: l.quote_symbol, decimals: l.quote_decimals }).compact; - const left = boostTimeLeft(b.last_at, now); - const fuse = boostFuse(b.last_at, now); - const lit = ignited[l.token]; - // absent from a machine on the older build while a deploy rolls: no count shown, never "undefined" - const clicks = b.clicks ?? 0; - // two columns from 640px: an odd last card takes the whole row instead of leaving a hole - const lastOdd = i === items.length - 1 && items.length % 2 === 1; - // the whole card opens the token (the link's ::after covers it); only "Latest burn" sits above as its own link - return ( - {BOOST_COPY.laneEmpty}

    : null} +
      + {items.map((b, i) => { + const l = b.launch; + const cap = capDisplay(l.fdv_quote, l.quote_usd, { key: l.quote_key, symbol: l.quote_symbol, decimals: l.quote_decimals }).compact; + const left = boostTimeLeft(b.last_at, now); + const fuse = boostFuse(b.last_at, now); + const lit = ignited[l.token]; + // absent from a machine on the older build while a deploy rolls: no count shown, never "undefined" + const clicks = b.clicks ?? 0; + // two columns from 640px: an odd last card takes the whole row instead of leaving a hole + const lastOdd = i === items.length - 1 && items.length % 2 === 1; + // the whole card opens the token (the link's ::after covers it); only the credit's name sits above as its own link + return ( + + {/* one warm ring per ignition: a new key replays it */} + {lit ? - ); - })} - {/* five columns from 1024px: the free places read as open, never as a grey block */} - {Array.from({ length: Math.max(0, BOOST_LANE_SIZE - items.length) }, (_, i) => ( - - ))} -
    - )} + +
    + ); + })} + {/* five columns from 1024px: each free place is a button into the picker */} + {Array.from({ length: open }, (_, i) => ( +
  1. + +
  2. + ))} + {/* under 1024px: one row for all the free places, so the cards stay first */} + {open > 0 ? ( +
  3. + +
  4. + ) : null} +
+ {picking ? setPicking(false)} /> : null}
); } diff --git a/app/src/components/launchpad/boost-ui.test.ts b/app/src/components/launchpad/boost-ui.test.ts index 5cdfa742..e3b95689 100644 --- a/app/src/components/launchpad/boost-ui.test.ts +++ b/app/src/components/launchpad/boost-ui.test.ts @@ -197,10 +197,12 @@ test("CodeRabbit round 1: the done screen, admin errors and the agent formula", assert.match(llms, /greater than 0 and less than 10\^11/); }); -test("the whole card opens the token; only the burn link sits above it as its own target", () => { +test("the whole card opens the token; only the credit's name sits above it as its own target", () => { // the token link's ::after covers the card (LaunchCard's pattern), and keyboard focus rings the whole card assert.match(lane, /className="flex min-w-0 items-center gap-2\.5 outline-none \[grid-area:link\] after:absolute after:inset-0 after:z-\[1\] focus-visible:after:ring-2 focus-visible:after:ring-inset focus-visible:after:ring-brand sm:pr-5"/); - assert.match(lane, / now\(\) - make_interval\(hours => \$\{BOOST_WINDOW_H\}\)/); assert.match(body, /\} catch \(err\) \{\n\s*console\.warn\("\[boost\] lane clicks read failed:"[^\n]*\n\s*return new Map\(\);/); - assert.match(server, /const items = shown\.map\(\(t\) => \(\{ \.\.\.t, clicks: clicks\.get\(t\.token\) \?\? 0 \}\)\);/); + assert.match(server, /const items = shown\.map\(\(t\) => \(\{ \.\.\.t, clicks: clicks\.get\(t\.token\) \?\? 0, top_booster: boosters\.get\(t\.token\) \}\)\);/); // a click count is no ignition: only more TWIG lights a card assert.match(read("../../lib/launchpad/boost.ts"), /const grew = before === undefined \|\| BigInt\(it\.twig\) > BigInt\(before\);/); }); test("a phone gets a compact row with all five places; from 640px the stacked card", () => { - assert.match(lane, /\[grid-template-areas:'link_twig_twig'_'link_clicks_left'\]/); - assert.match(lane, /sm:\[grid-template-areas:'link_link'_'twig_left'_'burn_clicks'\]/); - for (const area of ["link", "twig", "clicks", "left", "burn"]) assert.match(lane, new RegExp(`\\[grid-area:${area}\\]`), area); - // "Latest burn" from 640px only (the token page has it on a phone); every place stays a full-width tap target - assert.match(lane, /className="relative z-\[2\] hidden w-fit items-center gap-0\.5 text-\[10px\] text-muted \[grid-area:burn\] hover:text-ink sm:inline-flex"/); + assert.match(lane, /\[grid-template-areas:'link_twig_twig'_'link_clicks_left'_'by_by_by'\]/); + assert.match(lane, /sm:\[grid-template-areas:'link_link'_'twig_left'_'by_clicks'\]/); + for (const area of ["link", "twig", "clicks", "left", "by"]) assert.match(lane, new RegExp(`\\[grid-area:${area}\\]`), area); + // the credit lines up under the name on a phone (avatar 32 + gap 10) + assert.match(lane, /\[grid-area:by\] max-sm:pl-\[42px\]/); + // the burn proof left the card for the credit; the token page keeps it + assert.doesNotMatch(lane, /Latest burn|explorerTx/); assert.match(panel, /explorerTx\(BOOST_CHAIN, state\.last_tx\)/); }); @@ -318,3 +322,40 @@ test("the lane never counts a view of its empty line, and the hover arrow never assert.match(lane, /const items = shown;/); assert.match(lane, /
@@ -149,7 +150,7 @@ export default function BoostedLane({ initial, serverNow, source }: { initial: B {BOOST_COPY.laneTitle} {/* what it takes to get in: beside the title from 640px, a line of its own under it on a phone */} - {entryLine ?

{entryLine}

: null} +

{entryLine}

- - ))} - {/* under 1024px: one row for all the free places, so the cards stay first */} - {open > 0 ? ( -
  • - -
  • - ) : null} - + + ); + })} + {/* five columns from 1024px: each free place is a button into the picker */} + {Array.from({ length: open }, (_, i) => ( +
  • + +
  • + ))} + {/* under 1024px: one row for all the free places, so the cards stay first */} + {open > 0 ? ( +
  • + +
  • + ) : null} + + {picking ? setPicking(false)} /> : null}
    ); diff --git a/app/src/components/launchpad/boost-ui.test.ts b/app/src/components/launchpad/boost-ui.test.ts index e3b95689..fed23b69 100644 --- a/app/src/components/launchpad/boost-ui.test.ts +++ b/app/src/components/launchpad/boost-ui.test.ts @@ -324,12 +324,14 @@ test("the lane never counts a view of its empty line, and the hover arrow never }); const picker = read("./BoostPicker.tsx"); +const list = read("../../app/api/launch/list/route.ts"); test("the lane invites a boost: a header button, every open place a button, and what it takes to get in", () => { - assert.match(lane, /const open = Math\.max\(0, BOOST_LANE_SIZE - items\.length\);\n\s*const entryLine = boostEntryLine\(open, lane\.entry\);/); + assert.match(lane, /const open = Math\.max\(0, BOOST_LANE_SIZE - items\.length\);\n\s*\/\/ read off the cards shown, so it can never contradict them\n\s*const entryLine = boostEntryLine\(items\.map\(\(b\) => b\.twig\)\);/); + assert.doesNotMatch(server.slice(server.indexOf("export async function getBoostLane("), server.indexOf("export async function boostLaneOrNull(")), /laneEntry/, "the lane's line is never the server's ranked cutoff"); assert.match(lane, /