diff --git a/app/src/app/api/boost/route.ts b/app/src/app/api/boost/route.ts index 235c2f60..d2b035ef 100644 --- a/app/src/app/api/boost/route.ts +++ b/app/src/app/api/boost/route.ts @@ -4,7 +4,7 @@ import { rateLimited } from "@/lib/launchpad/editServer"; import { clientIp } from "@/lib/profiles/http"; import { ethUsd } from "@/lib/launchpad/ethPrice"; import { BOOST_CHAIN } from "@/lib/launchpad/boost"; -import { boostEnabled, getBoostLane, getTokenBoost } from "@/lib/launchpad/boostServer"; +import { boostEnabled, boostLaneOrNull, getTokenBoost } from "@/lib/launchpad/boostServer"; export const dynamic = "force-dynamic"; @@ -27,7 +27,10 @@ export async function GET(req: Request) { if (!isAddress(token)) return NextResponse.json({ error: "bad token" }, { status: 400 }); return NextResponse.json(await getTokenBoost(token), { headers: NO_STORE }); } - return NextResponse.json(await getBoostLane(await ethUsd()), { headers: NO_STORE }); + // the same few-second cache pages and polls read: a caller polling this cannot add a database pass per request + const lane = await boostLaneOrNull(await ethUsd()); + if (!lane) return NextResponse.json({ error: "could not load boosts" }, { status: 502 }); + return NextResponse.json(lane, { headers: NO_STORE }); } catch (err) { console.error("[boost] read failed:", err instanceof Error ? err.message : err); return NextResponse.json({ error: "could not load boosts" }, { status: 502 }); diff --git a/app/src/app/api/launch/list/route.ts b/app/src/app/api/launch/list/route.ts index 12b96081..d72259d4 100644 --- a/app/src/app/api/launch/list/route.ts +++ b/app/src/app/api/launch/list/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from "next/server"; +import { isAddress } from "viem"; import { visibleChainOr } from "@/lib/launchpad/config"; import { isFilter } from "@/lib/launchpad/search"; import { clampLimit, clampOffset } from "@/lib/launchpad/paging"; @@ -7,7 +8,11 @@ import { ethUsd } from "@/lib/launchpad/ethPrice"; export const dynamic = "force-dynamic"; -/** GET /api/launch/list?chain=base|robinhood|arc&sort=live|new|mcap|volume|gainers|holders&window=1h|24h|all&limit=50 — agent-friendly JSON ("trending" still accepted as an alias of live). */ +/** + * GET /api/launch/list?chain=base|robinhood|arc&sort=live|new|mcap|volume|gainers|holders&window=1h|24h|all&limit=50 + * [&launcher=0x…] — agent-friendly JSON ("trending" still accepted as an alias of live). `launcher` keeps one wallet's + * launches (the Boost picker's "Your launches"); anything that is not an address is ignored. + */ export async function GET(req: Request) { const u = new URL(req.url); const sort = parseSort(u.searchParams.get("sort"), "new"); @@ -18,9 +23,11 @@ export async function GET(req: Request) { const filter = isFilter(f) ? f : null; const limit = clampLimit(u.searchParams.get("limit"), 50); const offset = clampOffset(u.searchParams.get("offset")); + const l = u.searchParams.get("launcher"); + const launcher = l && isAddress(l) ? l.toLowerCase() : undefined; try { const usd = await ethUsd(); - const page = await listLaunchesPage({ sort, window, chain, filter, limit, offset, ethUsd: usd }); + const page = await listLaunchesPage({ sort, window, chain, filter, limit, offset, launcher, ethUsd: usd }); return NextResponse.json({ sort, window, chain, filter, limit, offset, has_more: page.hasMore, ethUsd: usd, launches: page.items }, { headers: { "cache-control": "no-store" } }); } catch (err) { console.error("[launch] list failed:", err instanceof Error ? err.message : err); 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..43b00d72 --- /dev/null +++ b/app/src/components/launchpad/BoostPicker.tsx @@ -0,0 +1,191 @@ +"use client"; + +import { useEffect, useId, useRef, 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; +/** Trending is kept this long across opens of the picker, so reopening it asks for nothing. */ +const TRENDING_TTL_MS = 60_000; +let trendingCache: { at: number; tokens: PaletteToken[] } | null = null; +const freshTrending = () => (trendingCache && Date.now() - trendingCache.at < TRENDING_TTL_MS ? trendingCache.tokens : null); + +/** + * "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. A token an admin keeps out of the lane says so here, instead of sending the + * visitor to a page with no Boost button. + */ +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(freshTrending); + const [results, setResults] = useState<{ q: string; tokens: PaletteToken[]; failed: boolean } | null>(null); + // the token being checked before leaving, and the ones an admin keeps out of the lane + const [checking, setChecking] = useState(null); + const [hidden, setHidden] = useState>(new Set()); + // the check a pick is waiting on; closing the picker aborts it, and an aborted check never navigates + const pickCheck = useRef(null); + useEffect(() => () => pickCheck.current?.abort(), []); + + // the wallet's own Base launches, newest first: only the few shown are asked for + const wallet = address?.toLowerCase() ?? null; + useEffect(() => { + if (!wallet) return; + const ac = new AbortController(); + fetch(`/api/launch/list?chain=${BOOST_CHAIN}&launcher=${wallet}&sort=new&limit=${MINE_MAX}`, { 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(() => { + if (freshTrending()) return; + 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) => { + const tokens = (j?.launches ?? []).filter((l) => l.chain === BOOST_CHAIN).map(paletteTokenFrom); + if (j) trendingCache = { at: Date.now(), tokens }; + setTrending(tokens); + }) + .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. A token an admin + // keeps out of the lane has no Boost button there, so it is checked first; a failed check just goes (the page says). + const pick = async (t: PaletteToken) => { + if (checking) return; + setChecking(t.token); + const ac = new AbortController(); + pickCheck.current = ac; + const kept = await fetch(`/api/boost?chain=${BOOST_CHAIN}&token=${t.token}`, { cache: "no-store", signal: ac.signal }) + .then((r) => (r.ok ? (r.json() as Promise<{ enabled?: boolean; hidden?: boolean }>) : null)) + .then((d) => d?.enabled === true && d.hidden === true) + .catch(() => false); + // the visitor closed the picker while it checked: they dismissed it, so nothing more happens + if (ac.signal.aborted) return; + pickCheck.current = null; + setChecking(null); + if (kept) { + setHidden((h) => new Set(h).add(t.token)); + return; + } + 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 row = { onPick: (t: PaletteToken) => void pick(t), checking, hidden }; + + 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, checking, hidden }: { title?: string; tokens: readonly PaletteToken[]; onPick: (t: PaletteToken) => void; checking: string | null; hidden: ReadonlySet }) { + 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..57d2fe3b 100644 --- a/app/src/components/launchpad/BoostedLane.tsx +++ b/app/src/components/launchpad/BoostedLane.tsx @@ -1,19 +1,26 @@ "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 NamesProvider from "@/components/profile/NamesProvider"; 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_FROM_FLOOR, BOOST_LANE_SIZE, boostEntryLine, boostFuse, boostIgnitions, boostTimeLeft, boostWholeOf, 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 +36,7 @@ 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; +const NO_NAMES = {}; /** * 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,26 @@ 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); + // read off the cards shown, so it can never contradict them + const entryLine = boostEntryLine(items.map((b) => b.twig)); + 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}

+ +
+

{BOOST_COPY.laneNote}

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

{BOOST_COPY.final}

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

{BOOST_COPY.laneEmpty}

- ) : ( -
    + {items.length === 0 ?

    {BOOST_COPY.laneEmpty}

    : null} + {/* the credited wallets' names come with the lane, so the first paint shows them (no address-to-name swap) */} + + {/* the list is the lane's places: the boosted cards, then the open ones (buttons into the picker) */} +
      {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; @@ -155,14 +182,14 @@ export default function BoostedLane({ initial, serverNow, source }: { initial: B 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 + // 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 ? ) : null} {left} - - Latest burn + {/* credit: the wallet that sent the most TWIG for it in the window; only the name is a link (above the card's) */} + {b.top_booster ? ( + + by + {shortAddr(b.top_booster)}} + /> + + ) : null} {/* the fuse: what is left of the 24 hours, burning down from the right; steps with the card's clock */} ); })} - {/* 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..fe5e00c4 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,63 @@ 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, /