From 113289a7f1bd34e9820f2efda2dc8ba8e46d60f2 Mon Sep 17 00:00:00 2001 From: Kevin Codex Date: Fri, 16 Oct 2026 12:50:01 +0800 Subject: [PATCH 1/3] feat(boost): whole-card lane links, hover, and views/clicks while boosted - The whole Boosted card opens the token (stretched link, LaunchCard's pattern); "Latest burn" stays its own link above it. Keyboard focus rings the whole card. - Hover: warm tint, a still warm edge, an arrow, a slightly larger avatar, a brighter fuse. Only under a pointer that hovers; reduced motion keeps the colours. Nothing loops. - POST /api/boost/seen (sendBeacon): one view per page when the lane is on screen, and each card click. A click counts only while the token is in the lane, at the place the server's lane gives it, once per visitor, token and 30 minutes. Bots skipped, 60/min per IP, 512-byte body. The visitor is the daily presence hash mixed with the token: no IP, user agent or wallet stored. - bb_boost_views (hourly counts) and bb_boost_clicks; /admin gets a "Views and clicks" table (24h/7d/30d) via GET /api/boost/stats for admin wallets. The ranking never reads either table (guard extended). --- app/db/schema.sql | 25 ++++ app/src/app/(home)/page.tsx | 2 +- app/src/app/api/boost/seen/route.ts | 32 ++++++ app/src/app/api/boost/stats/route.ts | 28 +++++ app/src/app/api/write-limits.test.ts | 8 ++ app/src/components/launchpad/BoostAdmin.tsx | 108 +++++++++++++++++- app/src/components/launchpad/BoostedLane.tsx | 78 ++++++++++--- app/src/components/launchpad/ChainLanding.tsx | 2 +- app/src/components/launchpad/boost-ui.test.ts | 65 ++++++++++- app/src/lib/launchpad/boost.test.ts | 60 ++++++++++ app/src/lib/launchpad/boost.ts | 56 +++++++++ app/src/lib/launchpad/boostServer.ts | 81 +++++++++++++ docs/boost.md | 25 ++++ 13 files changed, 548 insertions(+), 22 deletions(-) create mode 100644 app/src/app/api/boost/seen/route.ts create mode 100644 app/src/app/api/boost/stats/route.ts diff --git a/app/db/schema.sql b/app/db/schema.sql index cb4814bb..e0925646 100644 --- a/app/db/schema.sql +++ b/app/db/schema.sql @@ -448,3 +448,28 @@ CREATE TABLE IF NOT EXISTS bb_boosts ( CREATE INDEX IF NOT EXISTS bb_boosts_time_idx ON bb_boosts (chain_id, block_time DESC); CREATE INDEX IF NOT EXISTS bb_boosts_tag_idx ON bb_boosts (chain_id, tag); ALTER TABLE bb_token_settings ADD COLUMN IF NOT EXISTS boost_hidden boolean NOT NULL DEFAULT false; -- admin: kept out of the Boosted lane + +-- ── Boosted lane views + clicks (2026-10-16): counted only while the token is in the lane ── +-- Read by /admin only, never by the ranking (boost-ui.test.ts). visitor = sha256(presence visitor hash | token), cut +-- short: the presence hash rotates every UTC day and cannot be reversed, and mixing in the token means one person's +-- clicks on two tokens cannot be linked. One row per visitor, token and 30-minute slot, so a reload counts once. +CREATE TABLE IF NOT EXISTS bb_boost_clicks ( + chain_id integer NOT NULL, + token text NOT NULL, + visitor text NOT NULL, + slot timestamptz NOT NULL, -- the 30-minute slot the click fell in + place integer NOT NULL CHECK (place >= 1), -- the token's place in the lane then, from the server's lane + source text NOT NULL CHECK (source IN ('home', 'base')), + clicked_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (chain_id, token, visitor, slot) +); +CREATE INDEX IF NOT EXISTS bb_boost_clicks_time_idx ON bb_boost_clicks (chain_id, clicked_at DESC); +-- One count per token and hour: each page load that brings the lane on screen adds 1 to every token in it. +CREATE TABLE IF NOT EXISTS bb_boost_views ( + chain_id integer NOT NULL, + token text NOT NULL, + hour_at timestamptz NOT NULL, + views integer NOT NULL DEFAULT 0, + PRIMARY KEY (chain_id, token, hour_at) +); +CREATE INDEX IF NOT EXISTS bb_boost_views_time_idx ON bb_boost_views (chain_id, hour_at DESC); diff --git a/app/src/app/(home)/page.tsx b/app/src/app/(home)/page.tsx index cfb3a476..5aa0061d 100644 --- a/app/src/app/(home)/page.tsx +++ b/app/src/app/(home)/page.tsx @@ -56,7 +56,7 @@ export default async function Home({ searchParams }: { searchParams: Promise<{ s {/* what is hot first (names and numbers to act on), then the last half hour as it happens, then every launch */} item.kind === "swap").map(feedKey)} /> {/* paid and labelled, after the facts-only board and never inside it (boost.ts); nothing while BOOST is off */} - + inRiverWindow(item, now))} recent={feed} serverNow={now} coveredSince={riverCoverage(feed, RIVER_SEED, now)} lastActivityAt={feed[0] ? new Date(feed[0].at).getTime() : 0} />
diff --git a/app/src/app/api/boost/seen/route.ts b/app/src/app/api/boost/seen/route.ts new file mode 100644 index 00000000..b1933020 --- /dev/null +++ b/app/src/app/api/boost/seen/route.ts @@ -0,0 +1,32 @@ +import { NextResponse } from "next/server"; +import { rateLimited } from "@/lib/launchpad/editServer"; +import { clientIp, readJson } from "@/lib/profiles/http"; +import { looksLikeBot, visitorHash } from "@/lib/launchpad/presence"; +import { ethUsd } from "@/lib/launchpad/ethPrice"; +import { parseBoostSeen } from "@/lib/launchpad/boost"; +import { recordBoostSeen } from "@/lib/launchpad/boostServer"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +/** + * POST {kind: "view" | "click", source: "home" | "base", token?}: the Boosted lane's beacon (sendBeacon, so the body + * comes as text/plain). A view adds 1 to every token in the lane now; a click counts once per visitor, token and 30 + * minutes, and only while the token is in the lane. Nothing is stored that names a person: see presence.ts and + * boostServer.ts. 204 whether it counted or not; the page never waits on it. + */ +export async function POST(req: Request) { + const ua = req.headers.get("user-agent") ?? ""; + if (looksLikeBot(ua)) return new NextResponse(null, { status: 204 }); + const ip = clientIp(req); + // a person sends one view per page and a click per card opened; 60 a minute leaves room for a shared address + if (rateLimited(`boost-seen:ip:${ip}`, 60)) return NextResponse.json({ error: "slow down" }, { status: 429 }); + const seen = parseBoostSeen(await readJson(req, 512)); + if (!seen) return NextResponse.json({ error: "bad beacon" }, { status: 400 }); + try { + await recordBoostSeen(seen, visitorHash(ip, ua), await ethUsd()); + } catch (err) { + console.warn("[boost] beacon failed:", err instanceof Error ? err.message : err); + } + return new NextResponse(null, { status: 204 }); +} diff --git a/app/src/app/api/boost/stats/route.ts b/app/src/app/api/boost/stats/route.ts new file mode 100644 index 00000000..0740907d --- /dev/null +++ b/app/src/app/api/boost/stats/route.ts @@ -0,0 +1,28 @@ +import { NextResponse } from "next/server"; +import { rateLimited } from "@/lib/launchpad/editServer"; +import { clientIp } from "@/lib/profiles/http"; +import { adminWallets } from "@/lib/launchpad/postsServer"; +import { isBoostStatsDays } from "@/lib/launchpad/boost"; +import { getBoostStats } from "@/lib/launchpad/boostServer"; + +export const dynamic = "force-dynamic"; + +/** + * GET /api/boost/stats?wallet=0x…&days=1|7|30 → the Boosted lane's views and clicks per token, with the TWIG burned for + * each in the same period. Counts, nothing that names a person; listed only for admin wallets to keep it out of + * casual view, like the reported posts queue. + */ +export async function GET(req: Request) { + if (rateLimited(`boost-stats:ip:${clientIp(req)}`, 30)) return NextResponse.json({ error: "slow down" }, { status: 429 }); + const u = new URL(req.url); + const wallet = (u.searchParams.get("wallet") ?? "").toLowerCase(); + if (!adminWallets().has(wallet)) return NextResponse.json({ error: "admin only" }, { status: 403 }); + const days = Number(u.searchParams.get("days") ?? "7"); + if (!isBoostStatsDays(days)) return NextResponse.json({ error: "bad days" }, { status: 400 }); + try { + return NextResponse.json({ days, rows: await getBoostStats(days) }, { headers: { "cache-control": "no-store" } }); + } catch (err) { + console.error("[boost] stats read failed:", err instanceof Error ? err.message : err); + return NextResponse.json({ error: "could not load stats" }, { status: 502 }); + } +} diff --git a/app/src/app/api/write-limits.test.ts b/app/src/app/api/write-limits.test.ts index 0f387c45..d04d0470 100644 --- a/app/src/app/api/write-limits.test.ts +++ b/app/src/app/api/write-limits.test.ts @@ -19,6 +19,7 @@ for ( { file: "./launch/sync/route.ts", bucket: "sync:ip", mustPrecede: "pollAll()" }, { file: "./launch/meta/route.ts", bucket: "meta:ip", mustPrecede: "req.json()" }, { file: "./presence/route.ts", bucket: "presence:ip", mustPrecede: "recordBeacon(" }, + { file: "./boost/seen/route.ts", bucket: "boost-seen:ip", mustPrecede: "readJson(" }, ] ) { test(`${file} rate-limits by IP before doing work`, () => { @@ -43,3 +44,10 @@ test("sync still validates tx/chain shapes with 400s", () => { assert.match(src, /\{\s*error:\s*"bad tx"\s*\},\s*\{\s*status:\s*400\s*\}/); assert.match(src, /\{\s*error:\s*"bad chain"\s*\},\s*\{\s*status:\s*400\s*\}/); }); + +test("the Boosted lane beacon skips bots before the limiter, and caps its body", () => { + const src = source("./boost/seen/route.ts"); + assert.ok(src.indexOf("looksLikeBot(ua)") < src.indexOf("rateLimited(`boost-seen:ip"), "bot beacons never touch the limiter or the DB"); + assert.ok(src.indexOf("rateLimited(`boost-seen:ip") < src.indexOf("recordBoostSeen(")); + assert.match(src, /parseBoostSeen\(await readJson\(req, 512\)\)/); +}); diff --git a/app/src/components/launchpad/BoostAdmin.tsx b/app/src/components/launchpad/BoostAdmin.tsx index 6bf2732b..0c2be63a 100644 --- a/app/src/components/launchpad/BoostAdmin.tsx +++ b/app/src/components/launchpad/BoostAdmin.tsx @@ -7,8 +7,8 @@ import { getWalletClient } from "wagmi/actions"; import { isAddress } from "viem"; import { useHydratedAccount } from "@/lib/useHydratedAccount"; import { btn, card } from "@/components/ui"; -import type { BoostLane } from "@/lib/launchpad/boostServer"; -import { BOOST_CHAIN, buildBoostModMessage, fmtBoostTwig, type BoostModAction } from "@/lib/launchpad/boost"; +import type { BoostLane, BoostStatsRow } from "@/lib/launchpad/boostServer"; +import { BOOST_CHAIN, BOOST_STATS_DAYS, boostClickRate, boostClicksPerFloor, buildBoostModMessage, fmtBoostTwig, type BoostModAction, type BoostStatsDays } from "@/lib/launchpad/boost"; import { CHAINS } from "@/lib/chainPublic"; import { nowMs } from "@/lib/launchpad/time"; import { friendlyError } from "@/lib/errors"; @@ -17,7 +17,7 @@ import { nonce } from "@/lib/nonce"; /** * The Boosted lane for moderators: what is in it now, Hide on each card, and Hide / Unhide by address (a hidden token is * no longer listed anywhere, so showing it again takes its address). Each action is an admin-wallet signature. Hiding - * never refunds: the TWIG was burned on-chain. + * never refunds: the TWIG was burned on-chain. Below them, the lane's views and clicks per token. */ export default function BoostAdmin() { const { address } = useHydratedAccount(); @@ -85,6 +85,108 @@ export default function BoostAdmin() {
{msg ?

{msg.text}

: null} + {address ? : null} ); } + +const num = (n: number) => n.toLocaleString("en-US"); + +/** + * Views and clicks per token over a period, with the TWIG burned for each: whether a place in the lane brings visitors. + * A view is a page load that brought the lane on screen; a click counts once per visitor and 30 minutes, and only + * while the token was in the lane. Visitors: one per person per day. + */ +function LaneStats({ wallet }: { wallet: string }) { + const [days, setDays] = useState(7); + const [got, setGot] = useState<{ key: string; rows: BoostStatsRow[] } | null>(null); + const [err, setErr] = useState(null); + const key = `${wallet.toLowerCase()}:${days}`; + useEffect(() => { + const ac = new AbortController(); + fetch(`/api/boost/stats?wallet=${wallet}&days=${days}`, { cache: "no-store", signal: ac.signal }) + .then(async (res) => { + const d = (await res.json().catch(() => ({}))) as { rows?: BoostStatsRow[]; error?: string }; + if (!res.ok) throw new Error(d.error ?? `request failed (HTTP ${res.status})`); + setGot({ key, rows: d.rows ?? [] }); + setErr(null); + }) + .catch((e) => { + if (!ac.signal.aborted) setErr(friendlyError(e)); + }); + return () => ac.abort(); + }, [wallet, days, key]); + + // a period still loading shows nothing rather than the last period's figures under the new heading + const rows = got?.key === key ? got.rows : null; + const total = rows?.reduce((t, r) => ({ views: t.views + r.views, clicks: t.clicks + r.clicks, visitors: t.visitors + r.visitors, twig: t.twig + BigInt(r.twig) }), { views: 0, clicks: 0, visitors: 0, twig: 0n }); + const per = (clicks: number, twig: string) => { + const v = boostClicksPerFloor(clicks, twig); + return v === null ? "–" : v.toFixed(1); + }; + return ( +
+
+

Views and clicks

+
+ {BOOST_STATS_DAYS.map((d) => ( + + ))} +
+
+

A view is a page that brought the lane on screen. A click counts once per visitor every 30 minutes, only while the token is in the lane. Visitors: one per person per day.

+ {err ?

{err}

: null} + {rows === null ? ( + err ? null :

Loading…

+ ) : rows.length === 0 ? ( +

No views, clicks or boosts in this period.

+ ) : ( +
+ + + + + + + + + + + + + + {rows.map((r) => ( + + + + + + + + + + ))} + + {total ? ( + + + + + + + + + + + + ) : null} +
TokenViewsClicksRateVisitorsTWIG burnedPer 100K
+ {r.symbol ?? r.token.slice(0, 10)} + {num(r.views)}{num(r.clicks)}{boostClickRate(r.clicks, r.views)}{num(r.visitors)}{BigInt(r.twig) > 0n ? fmtBoostTwig(r.twig) : "–"}{per(r.clicks, r.twig)}
All{num(total.views)}{num(total.clicks)}{boostClickRate(total.clicks, total.views)}{num(total.visitors)}{total.twig > 0n ? fmtBoostTwig(total.twig) : "–"}{per(total.clicks, total.twig.toString())}
+
+ )} +
+ ); +} diff --git a/app/src/components/launchpad/BoostedLane.tsx b/app/src/components/launchpad/BoostedLane.tsx index dde12f7d..e897a086 100644 --- a/app/src/components/launchpad/BoostedLane.tsx +++ b/app/src/components/launchpad/BoostedLane.tsx @@ -9,7 +9,7 @@ import { TwigMark } from "./TwigBadge"; import { useLive } from "./LiveProvider"; import LiveNumber from "@/components/LiveNumber"; import type { BoostLane } from "@/lib/launchpad/boostServer"; -import { BOOST_COPY, BOOST_LANE_SIZE, boostFuse, boostIgnitions, boostTimeLeft, boostWholeOf } from "@/lib/launchpad/boost"; +import { BOOST_COPY, BOOST_LANE_SIZE, boostFuse, boostIgnitions, boostTimeLeft, boostWholeOf, type BoostSource } from "@/lib/launchpad/boost"; import { capDisplay } from "@/lib/launchpad/market-cap"; import { explorerTx } from "@/lib/chainPublic"; import { setPendingToken } from "@/lib/launchpad/token-transition"; @@ -28,6 +28,20 @@ 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; +/** + * The lane's beacon to /api/boost/seen: the lane came on screen, or a card was clicked. sendBeacon outlives the + * navigation a click starts and never holds it up; fetch with keepalive where it is missing or refuses. Never throws. + */ +function sendSeen(body: { kind: "view" } | { kind: "click"; token: string }, source: BoostSource) { + const data = JSON.stringify({ ...body, source }); + try { + if (navigator.sendBeacon?.("/api/boost/seen", data)) return; + } catch { + // fall through to fetch + } + void fetch("/api/boost/seen", { method: "POST", body: data, keepalive: true }).catch(() => {}); +} + /** * The Boosted lane: tokens someone sent TWIG to 0x…dEaD for, most TWIG in the last 24 hours first (boost.ts). A paid, * labelled surface of its own, never mixed into the Live sort or the Trending board. Renders nothing while the BOOST @@ -36,9 +50,13 @@ const IGNITE_KEEP_MS = 10_000; * * Motion follows the board above it: nothing moves on its own. Each card carries a fuse (the share of its 24 hours * left, stepped with the card's clock), and a card lights up once when a fresh boost lands on it: a warm ring, the - * amount rolling up, a few embers. Places change with a short slide. + * amount rolling up, a few embers. Places change with a short slide. A hover is the visitor's own doing: the card warms, + * its edge and an arrow show, the fuse brightens. + * + * The whole card opens the token. The lane counts one view per page and each card click (sendSeen, /admin only); the + * server counts a click only while the token is still in the lane. */ -export default function BoostedLane({ initial, serverNow }: { initial: BoostLane; serverNow: number }) { +export default function BoostedLane({ initial, serverNow, source }: { initial: BoostLane; serverNow: number; source: BoostSource }) { const { subscribe, wantBoost } = useLive(); const reduced = useReducedMotion(); const [lane, setLane] = useState(initial); @@ -46,6 +64,8 @@ export default function BoostedLane({ initial, serverNow }: { initial: BoostLane // 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 section = useRef(null); + const viewed = useRef(false); useEffect(() => wantBoost(), [wantBoost]); useEffect(() => { @@ -75,11 +95,29 @@ export default function BoostedLane({ initial, serverNow }: { initial: BoostLane }; }, [subscribe]); + // one view per page: the first time the lane, with cards in it, is a third on screen + const hasCards = lane.enabled && lane.items.length > 0; + useEffect(() => { + const el = section.current; + if (!hasCards || viewed.current || !el || typeof IntersectionObserver === "undefined") return; + const io = new IntersectionObserver( + (entries) => { + if (viewed.current || !entries.some((e) => e.isIntersecting)) return; + viewed.current = true; + io.disconnect(); + sendSeen({ kind: "view" }, source); + }, + { threshold: 0.3 }, + ); + io.observe(el); + return () => io.disconnect(); + }, [hasCards, source]); + if (!lane.enabled) return null; // a boost that ran out between polls leaves at once rather than showing "0m left" const items = lane.items.filter((b) => boostTimeLeft(b.last_at, now) !== null); return ( -
+

@@ -108,28 +146,40 @@ export default function BoostedLane({ initial, serverNow }: { initial: BoostLane const lit = ignited[l.token]; // 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 ( {/* one warm ring per ignition: a new key replays it */} {lit ? diff --git a/app/src/components/launchpad/ChainLanding.tsx b/app/src/components/launchpad/ChainLanding.tsx index 563c0af5..d0937961 100644 --- a/app/src/components/launchpad/ChainLanding.tsx +++ b/app/src/components/launchpad/ChainLanding.tsx @@ -71,7 +71,7 @@ export default async function ChainLanding({ chain }: { chain: ChainKey }) {

{/* always mounted on Base, so a switch-on reaches an open tab; the margin goes with the lane (empty:hidden) */} - {chain === BOOST_CHAIN ?
: null} + {chain === BOOST_CHAIN ?
: null}

Tokens on {CHAIN_LABELS[chain]}

diff --git a/app/src/components/launchpad/boost-ui.test.ts b/app/src/components/launchpad/boost-ui.test.ts index ce10f6d4..2b7bbbfa 100644 --- a/app/src/components/launchpad/boost-ui.test.ts +++ b/app/src/components/launchpad/boost-ui.test.ts @@ -26,10 +26,13 @@ const boostRoute = read("../../app/api/boost/route.ts"); const llms = read("../../app/llms.txt/route.ts"); const home = read("../../app/(home)/page.tsx"); const tokenPage = read("../../app/t/[chain]/[token]/page.tsx"); +const seenRoute = read("../../app/api/boost/seen/route.ts"); +const statsRoute = read("../../app/api/boost/stats/route.ts"); test("the ranking never reads boosts: no paid row can enter the Live sort or the Trending board", () => { // boost code, not the English word (the board's own copy says fresh tokens "get a boost" in score) - const BOOST_CODE = /from ["'][^"']*[bB]oost[^"']*["']|bb_boosts|BOOST_|[bB]oostLane|useBoostOn|getTokenBoost|live\.boost|snap\.boost/; + // bb_boost also covers the lane's views and clicks (bb_boost_views, bb_boost_clicks): attention never ranks either + const BOOST_CODE = /from ["'][^"']*[bB]oost[^"']*["']|bb_boost|BOOST_|[bB]oostLane|useBoostOn|getTokenBoost|live\.boost|snap\.boost/; for (const f of ["../../lib/launchpad/ranking.ts", "../../lib/launchpad/trending-live.ts", "../../lib/launchpad/trending-board.ts", "./TrendingStrip.tsx", "./LaunchList.tsx"]) { assert.doesNotMatch(read(f), BOOST_CODE, f); } @@ -42,6 +45,7 @@ test("the ranking never reads boosts: no paid row can enter the Live sort or the for (const imp of IMPORTS) assert.equal(queries.split(imp).length, 2, imp); const outside = IMPORTS.reduce((acc, imp) => acc.replace(imp, ""), queries.slice(0, start) + queries.slice(end)); assert.doesNotMatch(outside, BOOST_CODE); + assert.doesNotMatch(queries.slice(start, end), /bb_boost_(clicks|views)/); // the totals count only boosts that name a launch, and only while the switch is on assert.match(queries.slice(start, end), /boostEnabled\(\)\n\s*\? db<\{ burned: string \}\[\]>`\n\s*SELECT COALESCE\(sum\(b\.amount\), 0\)::text AS burned FROM bb_boosts b\n\s*JOIN bb_launches l ON l\.chain_id = b\.chain_id AND l\.token_id = b\.tag/); }); @@ -103,7 +107,7 @@ test("everything stays dark while BOOST is off", () => { test("a failed lane read never breaks a page or blanks open tabs", () => { assert.match(server, /\.catch\(\(err\) => \{\n\s*console\.warn\("\[boost\] lane read failed:"/); assert.match(liveRoute, /boost: boost \?\? undefined/); - assert.match(home, //); + assert.match(home, //); // a tick without the lane is no news: the cards stay as they are assert.match(lane, /const next = snap\.boost;\n\s*if \(!next\) return;/); // the lane sits after the facts-only board, never inside it @@ -150,7 +154,7 @@ test("only a lane on screen downloads the lane; every page gets the switch as on assert.match(layout, /boost_on: boostEnabled\(\)/); assert.match(read("./useBoostOn.ts"), /useState\(live\.boost_on === true\)/); // /base keeps the lane mounted, so a switch-on reaches an open tab - assert.match(landing, /chain === BOOST_CHAIN \?
/); }); test("a write that changes the lane makes the next read fresh", () => { @@ -192,3 +196,58 @@ test("CodeRabbit round 1: the done screen, admin errors and the agent formula", assert.match(llms, /whole = a count of whole TWIG, at least \$\{BOOST_FLOOR_TWIG\}/); 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", () => { + // 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 pr-5 outline-none after:absolute after:inset-0 after:z-\[1\] focus-visible:after:ring-2 focus-visible:after:ring-inset focus-visible:after:ring-brand"/); + assert.match(lane, / { + assert.match(lane, /className=\{`group\/card relative flex min-w-0 flex-col gap-2 bg-card px-4 pb-3\.5 pt-3 transition-colors duration-150 hover:bg-\[/); + // movement (the avatar's scale, the arrow's slide) only with motion allowed; colour and the edge stay for everyone + assert.match(lane, /motion-safe:group-hover\/card:scale-105/); + assert.match(lane, /motion-safe:-translate-x-0\.5 motion-safe:translate-y-0\.5 motion-safe:group-hover\/card:translate-x-0/); + assert.doesNotMatch(lane, /(? { + assert.match(lane, /navigator\.sendBeacon\?\.\("\/api\/boost\/seen", data\)/); + assert.match(lane, /void fetch\("\/api\/boost\/seen", \{ method: "POST", body: data, keepalive: true \}\)\.catch\(\(\) => \{\}\);/); + // a click (and a middle click, which fires no click event) reports the card's token + assert.match(lane, /setPendingToken\([^\n]*\);\n\s*sendSeen\(\{ kind: "click", token: l\.token \}, source\);/); + assert.match(lane, /if \(e\.button === 1\) sendSeen\(\{ kind: "click", token: l\.token \}, source\);/); + // the view: once per mount, when the lane with cards in it is on screen + assert.match(lane, /if \(viewed\.current \|\| !entries\.some\(\(e\) => e\.isIntersecting\)\) return;\n\s*viewed\.current = true;\n\s*io\.disconnect\(\);\n\s*sendSeen\(\{ kind: "view" \}, source\);/); + assert.equal((lane.match(/sendSeen\(/g) ?? []).length, 4, "the helper, the view, the click, the middle click"); +}); + +test("a click counts only while the token is in the lane, at the server's place, once per visitor and slot", () => { + const body = server.slice(server.indexOf("export async function recordBoostSeen("), server.indexOf("export type BoostStatsRow")); + assert.match(body, /^export async function recordBoostSeen\(seen: BoostSeen, visitorHash: string, ethUsdNow: number \| null\): Promise \{\n\s*if \(!boostEnabled\(\)\) return false;/); + assert.match(body, /const place = boostPlace\(lane, seen\.token\);\n\s*if \(place === null\) return false;/); + assert.match(body, /ON CONFLICT DO NOTHING RETURNING 1/); + assert.doesNotMatch(body, /seen\.place|seen\.source === /, "nothing but the token and the source come from the request"); + // the lane is the one pages show (same cache), minus a boost that ran out since + assert.match(server, /const lane = await boostLaneOrNull\(ethUsdNow\);\n\s*if \(!lane\?\.enabled\) return \[\];/); + assert.match(server, /lane\.items\.filter\(\(b\) => boostTimeLeft\(b\.last_at, now\) !== null\)\.map\(\(b\) => b\.token\)/); + // no address, wallet or raw presence hash is stored: the visitor is the daily hash mixed with the token + assert.match(server, /createHash\("sha256"\)\.update\(`\$\{visitorHash\}\|\$\{token\}`\)\.digest\("hex"\)\.slice\(0, 32\)/); + assert.match(seenRoute, /await recordBoostSeen\(seen, visitorHash\(ip, ua\), await ethUsd\(\)\);/); + assert.match(seenRoute, /return new NextResponse\(null, \{ status: 204 \}\);\n\}/); +}); + +test("views and clicks are read by the admin figures alone, behind an admin wallet", () => { + const users = walk(path.resolve(import.meta.dirname, "../..")).filter((f) => !f.endsWith(".test.ts") && /bb_boost_(clicks|views)/.test(readFileSync(f, "utf8"))); + assert.deepEqual(users.map((f) => path.basename(f)), ["boostServer.ts"]); + assert.ok(statsRoute.indexOf('if (!adminWallets().has(wallet)) return NextResponse.json({ error: "admin only" }, { status: 403 });') < statsRoute.indexOf("getBoostStats(")); + assert.match(statsRoute, /if \(!isBoostStatsDays\(days\)\) return NextResponse\.json\(\{ error: "bad days" \}, \{ status: 400 \}\);/); + assert.match(admin, /\{address \? : null\}/); +}); diff --git a/app/src/lib/launchpad/boost.test.ts b/app/src/lib/launchpad/boost.test.ts index 77c66118..0c52ffd1 100644 --- a/app/src/lib/launchpad/boost.test.ts +++ b/app/src/lib/launchpad/boost.test.ts @@ -31,6 +31,15 @@ import { maxBoostWhole, parseBoostWhole, rankLane, + BOOST_CLICK_SLOT_MS, + BOOST_SOURCES, + BOOST_STATS_DAYS, + boostClickRate, + boostClickSlot, + boostClicksPerFloor, + boostPlace, + isBoostStatsDays, + parseBoostSeen, type BoostTransfer, } from "./boost.ts"; import { TWIG_ADDRESS, TWIG_ADDRESSES } from "./twig.ts"; @@ -227,3 +236,54 @@ test("the dialog knows whether a boost shows now", () => { assert.equal(boostShowsNow(full, 900_000n * E18, 100_000n * E18, false), true, "it adds to what the token already has"); assert.equal(boostShowsNow(full, 0n, 1n, true), true, "already in the lane"); }); + +test("the lane's beacon is a view or a click on a token, from the home page or /base", () => { + const TOKEN = "0xAbCdEf0123456789aBcDeF0123456789AbCdEf01"; + assert.deepEqual([...BOOST_SOURCES], ["home", "base"]); + assert.deepEqual(parseBoostSeen({ kind: "view", source: "home" }), { kind: "view", source: "home" }); + assert.deepEqual(parseBoostSeen({ kind: "view", source: "base", token: TOKEN }), { kind: "view", source: "base" }, "a view carries no token"); + assert.deepEqual(parseBoostSeen({ kind: "click", source: "base", token: TOKEN, place: 1 }), { kind: "click", source: "base", token: TOKEN.toLowerCase() }, "lowercased; a place in the body is ignored"); + for (const bad of [ + null, + "view", + [{ kind: "view", source: "home" }], + {}, + { kind: "view" }, + { kind: "view", source: "token" }, + { kind: "hover", source: "home" }, + { kind: "click", source: "home" }, + { kind: "click", source: "home", token: "0x123" }, + { kind: "click", source: "home", token: `${TOKEN}00` }, + { kind: "click", source: "home", token: 42 }, + ]) { + assert.equal(parseBoostSeen(bad), null, JSON.stringify(bad)); + } +}); + +test("a click counts only for a token in the lane now, at the place the lane gives it", () => { + const lane = ["0xaaa", "0xbbb", "0xccc"]; + assert.equal(boostPlace(lane, "0xaaa"), 1); + assert.equal(boostPlace(lane, "0xCCC"), 3, "any case"); + assert.equal(boostPlace(lane, "0xddd"), null, "out of the lane (expired, hidden, never in it)"); + assert.equal(boostPlace([], "0xaaa"), null, "an empty lane, or the switch off"); +}); + +test("clicks fall in 30-minute slots, so a reload counts once", () => { + assert.equal(BOOST_CLICK_SLOT_MS, 1_800_000); + const t = Date.parse("2026-10-16T09:00:00.000Z"); + assert.equal(boostClickSlot(t), "2026-10-16T09:00:00.000Z"); + assert.equal(boostClickSlot(t + 1_799_999), "2026-10-16T09:00:00.000Z"); + assert.equal(boostClickSlot(t + 1_800_000), "2026-10-16T09:30:00.000Z"); + assert.equal(boostClickSlot(t - 1), "2026-10-16T08:30:00.000Z"); +}); + +test("the admin figures: periods, click rate and clicks per 100K TWIG", () => { + assert.deepEqual([...BOOST_STATS_DAYS], [1, 7, 30]); + for (const d of BOOST_STATS_DAYS) assert.equal(isBoostStatsDays(d), true); + for (const d of [0, 2, 365, "7", null, Number.NaN]) assert.equal(isBoostStatsDays(d), false, String(d)); + assert.equal(boostClickRate(21, 500), "4.2%"); + assert.equal(boostClickRate(0, 10), "0.0%"); + assert.equal(boostClickRate(3, 0), "–", "no view yet: no rate, never a confident 0"); + assert.equal(boostClicksPerFloor(30, (300_000n * E18 + ID).toString()), 10, "30 clicks for 300K TWIG = 10 per 100K (the tag is ignored)"); + assert.equal(boostClicksPerFloor(5, "0"), null, "nothing burned in the period"); +}); diff --git a/app/src/lib/launchpad/boost.ts b/app/src/lib/launchpad/boost.ts index cca46bcf..66f6840a 100644 --- a/app/src/lib/launchpad/boost.ts +++ b/app/src/lib/launchpad/boost.ts @@ -194,6 +194,62 @@ export function boostShowsNow(entry: LaneEntry, current: bigint, raw: bigint, in return current + raw >= BigInt(entry.cutoff); } +/* ─────────────────────────────── views and clicks ─────────────────────────────── */ + +/** + * The lane counts how often it is seen and its cards are clicked, for /admin only: never read by the ranking + * (boost-ui.test.ts). A click counts only while its token is in the lane, at the place the server's lane gives it. + */ +export const BOOST_SOURCES = ["home", "base"] as const; +export type BoostSource = (typeof BOOST_SOURCES)[number]; +/** One click per visitor, token and slot: a reload or a double click counts once. */ +export const BOOST_CLICK_SLOT_MS = 30 * 60_000; +/** The periods /admin can read. */ +export const BOOST_STATS_DAYS = [1, 7, 30] as const; +export type BoostStatsDays = (typeof BOOST_STATS_DAYS)[number]; + +/** What the lane's beacon reports: the lane came on screen, or a card was clicked. */ +export type BoostSeen = { kind: "view"; source: BoostSource } | { kind: "click"; source: BoostSource; token: string }; + +/** A beacon body → what it reports (the token lowercased), or null when it is not one. Other fields are ignored. */ +export function parseBoostSeen(body: unknown): BoostSeen | null { + if (!body || typeof body !== "object" || Array.isArray(body)) return null; + const b = body as Record; + if (typeof b.source !== "string" || !(BOOST_SOURCES as readonly string[]).includes(b.source)) return null; + const source = b.source as BoostSource; + if (b.kind === "view") return { kind: "view", source }; + if (b.kind === "click" && typeof b.token === "string" && /^0x[0-9a-fA-F]{40}$/.test(b.token)) return { kind: "click", source, token: b.token.toLowerCase() }; + return null; +} + +/** A token's place in the lane as it stands now (1-based), or null when it is not in it: then a click counts nothing. */ +export function boostPlace(lane: readonly string[], token: string): number | null { + const i = lane.indexOf(token.toLowerCase()); + return i >= 0 ? i + 1 : null; +} + +/** The start of the 30-minute slot a moment falls in (UTC). */ +export function boostClickSlot(nowMs: number): string { + return new Date(Math.floor(nowMs / BOOST_CLICK_SLOT_MS) * BOOST_CLICK_SLOT_MS).toISOString(); +} + +export function isBoostStatsDays(v: unknown): v is BoostStatsDays { + return typeof v === "number" && (BOOST_STATS_DAYS as readonly number[]).includes(v); +} + +/** "4.2%": clicks per lane view; "–" before any view. */ +export function boostClickRate(clicks: number, views: number): string { + if (!(views > 0)) return "–"; + return `${((clicks / views) * 100).toFixed(1)}%`; +} + +/** Clicks per 100K TWIG (the floor) burned for the token in the same period; null when nothing was burned in it. */ +export function boostClicksPerFloor(clicks: number, twigRaw: string): number | null { + const whole = Number(boostWholeOf(BigInt(twigRaw))); + if (!(whole > 0)) return null; + return (clicks * Number(BOOST_FLOOR_TWIG)) / whole; +} + /* ─────────────────────────────── moderation ─────────────────────────────── */ export const BOOST_MOD_ACTIONS = ["hide", "unhide"] as const; diff --git a/app/src/lib/launchpad/boostServer.ts b/app/src/lib/launchpad/boostServer.ts index 0c9de140..b1a0e601 100644 --- a/app/src/lib/launchpad/boostServer.ts +++ b/app/src/lib/launchpad/boostServer.ts @@ -1,4 +1,5 @@ import "server-only"; +import { createHash } from "node:crypto"; import { isAddress } from "viem"; import { maybeDb } from "@/lib/db"; import { getLaunchesByRefs, type LaunchRow } from "./queries"; @@ -12,10 +13,15 @@ import { BOOST_FLOOR_TWIG, BOOST_LANE_SIZE, BOOST_WINDOW_H, + boostClickSlot, boostExpiresAt, + boostPlace, + boostTimeLeft, buildBoostModMessage, isBoostModAction, rankLane, + type BoostSeen, + type BoostStatsDays, type LaneEntry, type LaneTotals, } from "./boost"; @@ -146,6 +152,81 @@ export async function getTokenBoost(token: string): Promise { }; } +/* ─────────────────────────────── views and clicks (/admin only) ─────────────────────────────── */ + +/** The lane's tokens as pages show them now, in order: the same few-second cache, minus a boost that ran out since. */ +async function laneTokensNow(ethUsdNow: number | null): Promise { + const lane = await boostLaneOrNull(ethUsdNow); + if (!lane?.enabled) return []; + const now = Date.now(); + return lane.items.filter((b) => boostTimeLeft(b.last_at, now) !== null).map((b) => b.token); +} + +/** A click's visitor: the presence hash (daily salt) mixed with the token, cut short, so two tokens' clicks never link. */ +function clickVisitor(visitorHash: string, token: string): string { + return createHash("sha256").update(`${visitorHash}|${token}`).digest("hex").slice(0, 32); +} + +/** + * Record a lane beacon; true when it counted. A view adds 1 to this hour of every token in the lane now. A click counts + * once per visitor, token and 30-minute slot, and only while the token is in the lane: its place is read from the + * server's lane, never taken from the request, and a tab left open after a boost ran out counts nothing. + */ +export async function recordBoostSeen(seen: BoostSeen, visitorHash: string, ethUsdNow: number | null): Promise { + if (!boostEnabled()) return false; + const db = maybeDb(); + if (!db) return false; + const lane = await laneTokensNow(ethUsdNow); + if (seen.kind === "view") { + if (lane.length === 0) return false; + await db`INSERT INTO bb_boost_views (chain_id, token, hour_at, views) + SELECT ${BOOST_CHAIN_ID}, t, date_trunc('hour', now()), 1 FROM unnest(${lane}::text[]) AS t + ON CONFLICT (chain_id, token, hour_at) DO UPDATE SET views = bb_boost_views.views + 1`; + return true; + } + const place = boostPlace(lane, seen.token); + if (place === null) return false; + const rows = await db`INSERT INTO bb_boost_clicks (chain_id, token, visitor, slot, place, source) + VALUES (${BOOST_CHAIN_ID}, ${seen.token}, ${clickVisitor(visitorHash, seen.token)}, ${boostClickSlot(Date.now())}, ${place}, ${seen.source}) + ON CONFLICT DO NOTHING RETURNING 1`; + return rows.length > 0; +} + +/** One token's lane figures over a period: views, clicks, visitors (one per person per day) and the TWIG burned for it. */ +export type BoostStatsRow = { token: string; name: string | null; symbol: string | null; views: number; clicks: number; visitors: number; twig: string; boosts: number }; + +/** Every token seen, clicked or boosted in the last `days` days, most clicks first. */ +export async function getBoostStats(days: BoostStatsDays): Promise { + const db = maybeDb(); + if (!db) return []; + return db` + WITH v AS ( + SELECT token, sum(views)::int AS views FROM bb_boost_views + WHERE chain_id = ${BOOST_CHAIN_ID} AND hour_at > now() - make_interval(days => ${days}) + GROUP BY token + ), k AS ( + SELECT token, count(*)::int AS clicks, count(DISTINCT visitor)::int AS visitors FROM bb_boost_clicks + WHERE chain_id = ${BOOST_CHAIN_ID} AND clicked_at > now() - make_interval(days => ${days}) + GROUP BY token + ), b AS ( + SELECT l.token, sum(x.amount)::text AS twig, count(*)::int AS boosts + FROM bb_boosts x + JOIN bb_launches l ON l.chain_id = x.chain_id AND l.token_id = x.tag + WHERE x.chain_id = ${BOOST_CHAIN_ID} AND x.block_time > now() - make_interval(days => ${days}) + AND x.amount >= ${RAW_FLOOR}::numeric + GROUP BY l.token + ), t AS (SELECT token FROM v UNION SELECT token FROM k UNION SELECT token FROM b) + SELECT t.token, l.name, l.symbol, COALESCE(v.views, 0) AS views, COALESCE(k.clicks, 0) AS clicks, + COALESCE(k.visitors, 0) AS visitors, COALESCE(b.twig, '0') AS twig, COALESCE(b.boosts, 0) AS boosts + FROM t + LEFT JOIN v ON v.token = t.token + LEFT JOIN k ON k.token = t.token + LEFT JOIN b ON b.token = t.token + LEFT JOIN bb_launches l ON l.chain_id = ${BOOST_CHAIN_ID} AND l.token = t.token + ORDER BY clicks DESC, views DESC, t.token + LIMIT 100`; +} + type Fail = { ok: false; error: string; status: number }; const fail = (error: string, status: number): Fail => ({ ok: false, error, status }); diff --git a/docs/boost.md b/docs/boost.md index a72e60e6..fa0d2f3c 100644 --- a/docs/boost.md +++ b/docs/boost.md @@ -35,6 +35,21 @@ amount = whole TWIG × 10^18 + the launch's position id Nothing in the lane moves on its own, the same rule as the Trending board above it. Each card has a **fuse**: a thin warm line along its bottom edge whose length is the share of its 24 hours still left, stepped with the card's 30-second clock. When a poll shows a card with more TWIG than before and a boost under 3 minutes old (`boostIgnitions`), the card **lights up** once: a warm inset ring, the amount rolling up to its new total, and a few embers rising from it. A token that only moved up because another expired stays still. Places change with a short slide. With reduced motion only the colour ring plays: no embers, no roll, no slide. +The whole card opens the token (the name link's `::after` covers it; only "Latest burn" sits above as its own link). A hover is the visitor's own doing, so it may move: the card warms, a still warm edge and an arrow show, the avatar grows a little and the fuse brightens. It applies only under a pointer that hovers (no stuck hover on phones, where a tap shows a pressed shade), and with reduced motion only the colours change. + +## Views and clicks + +The lane counts how often it is seen and how often its cards are opened, to tell whether a place in it brings visitors. The figures are for /admin only (a "Views and clicks" table under the Boosted lane, 24 h / 7 d / 30 d), and nothing in the ranking reads them (`boost-ui.test.ts`). + +- **A view** is a page load that brought the lane, with cards in it, a third on screen: one beacon per page, and the server adds 1 to this hour of every token in the lane then (`bb_boost_views`). +- **A click** is a card opened by a click, a Cmd/Ctrl-click or a middle click. It counts **only while the token is in the lane**: the server checks the token against the same few-second lane the pages read, takes its place from there (never from the request), and a tab left open after the boost ran out counts nothing. One click per visitor, token and 30-minute slot, so a reload or a double click counts once (`bb_boost_clicks`). +- The beacon is `POST /api/boost/seen` (`navigator.sendBeacon`, so it never holds up the navigation). Bots are skipped, each IP has 60 a minute, the body is capped at 512 bytes, and the answer is a 204 either way. +- **Nothing names a person.** The visitor is the presence hash (sha256 of IP, user agent and a daily salt, see `presence.ts`) mixed with the token and cut short: it changes every UTC day and cannot link one person's clicks on two tokens. No IP, user agent or wallet is stored. "Visitors" in the table is one per person per day. +- **Set `PRESENCE_SALT`** (any long random string, `fly secrets set PRESENCE_SALT=$(openssl rand -hex 32)`) so every machine salts the same way. Without it each machine draws its own salt at start, so a repeat click that lands on the other machine counts again, and a restart starts new visitors (the site's visit counter has the same gap). +- `GET /api/boost/stats?wallet=&days=1|7|30` serves the table: per token, views, clicks, visitors, the click rate, and the TWIG burned for it in the period with the clicks per 100K TWIG. + +A script with rotating user agents behind many IPs can still add clicks; the figures are a guide for decisions, not a payout, and nothing is paid or ranked on them. + ## The dialog On a Base token page (or from **Boost** on a launch in /me, which opens it there): whole TWIG only, presets 100K / 300K / 1M / 5M, the balance, Max (tag included), an estimate in dollars at today's TWIG price, and the last digits the amount will carry. It states plainly that the TWIG goes to 0x…dEaD, cannot be undone or refunded, and counts toward the token's place in the lane for 24 hours without changing the ranking. When the lane is full and the amount would leave the token under the 5th place, the dialog says so before anything is sent and offers the amount that would show it now. The done screen names the token's place, or says it is not in the lane yet. @@ -63,3 +78,13 @@ On an anvil fork of Base, a wallet with TWIG can boost any launch the local data SELECT date_trunc('week', block_time) AS wk, count(*) AS boosts, count(DISTINCT booster) AS boosters, sum(amount) / 1e18 AS twig FROM bb_boosts WHERE chain_id = 8453 GROUP BY 1 ORDER BY 1 DESC; ``` + +Lane views and clicks per week: + +```sql +SELECT wk, sum(views) AS views, sum(clicks) AS clicks, round(100.0 * sum(clicks) / NULLIF(sum(views), 0), 1) AS click_pct FROM ( + SELECT date_trunc('week', hour_at) AS wk, views, 0 AS clicks FROM bb_boost_views WHERE chain_id = 8453 + UNION ALL + SELECT date_trunc('week', clicked_at), 0, 1 FROM bb_boost_clicks WHERE chain_id = 8453 +) x GROUP BY wk ORDER BY wk DESC; +``` From 69ae71c54e4959856d4d9d2a8c2e9ca071e0ff82 Mon Sep 17 00:00:00 2001 From: Kevin Codex Date: Fri, 16 Oct 2026 13:00:29 +0800 Subject: [PATCH 2/3] feat(boost): click count on each lane card; compact rows on phones - Each Boosted card shows its clicks from the lane in the last 24 hours (the window its TWIG is counted in) with a pointer icon: nothing at 0, rolls up when a poll brings more, a screen-reader label, and the count in the card's tooltip. A pointer, not an eye: one page load is a view for every card, so views would read the same on all five. - The lane read carries `clicks` (one indexed query per lane build; a failed read shows no counts, never no lane). - Under 640px a card is a compact two-line row (name left; TWIG, clicks, time left right): all five places fit on a phone screen, about 62px a card instead of 110px. "Latest burn" from 640px (the token page has it on a phone). One set of parts placed by grid areas, so the stacked card from 640px is unchanged. --- app/db/schema.sql | 3 +- app/src/app/llms.txt/route.ts | 2 +- app/src/components/launchpad/BoostedLane.tsx | 59 +++++++++++-------- app/src/components/launchpad/boost-ui.test.ts | 26 +++++++- app/src/lib/launchpad/boost.ts | 5 +- app/src/lib/launchpad/boostServer.ts | 27 ++++++++- docs/boost.md | 7 ++- 7 files changed, 94 insertions(+), 35 deletions(-) diff --git a/app/db/schema.sql b/app/db/schema.sql index e0925646..3e14e218 100644 --- a/app/db/schema.sql +++ b/app/db/schema.sql @@ -450,7 +450,7 @@ CREATE INDEX IF NOT EXISTS bb_boosts_tag_idx ON bb_boosts (chain_id, tag); ALTER TABLE bb_token_settings ADD COLUMN IF NOT EXISTS boost_hidden boolean NOT NULL DEFAULT false; -- admin: kept out of the Boosted lane -- ── Boosted lane views + clicks (2026-10-16): counted only while the token is in the lane ── --- Read by /admin only, never by the ranking (boost-ui.test.ts). visitor = sha256(presence visitor hash | token), cut +-- Shown as each lane card's click count and on /admin; never read by the ranking (boost-ui.test.ts). visitor = sha256(presence visitor hash | token), cut -- short: the presence hash rotates every UTC day and cannot be reversed, and mixing in the token means one person's -- clicks on two tokens cannot be linked. One row per visitor, token and 30-minute slot, so a reload counts once. CREATE TABLE IF NOT EXISTS bb_boost_clicks ( @@ -464,6 +464,7 @@ CREATE TABLE IF NOT EXISTS bb_boost_clicks ( PRIMARY KEY (chain_id, token, visitor, slot) ); CREATE INDEX IF NOT EXISTS bb_boost_clicks_time_idx ON bb_boost_clicks (chain_id, clicked_at DESC); +CREATE INDEX IF NOT EXISTS bb_boost_clicks_token_time_idx ON bb_boost_clicks (chain_id, token, clicked_at DESC); -- each card's 24 h count -- One count per token and hour: each page load that brings the lane on screen adds 1 to every token in it. CREATE TABLE IF NOT EXISTS bb_boost_views ( chain_id integer NOT NULL, diff --git a/app/src/app/llms.txt/route.ts b/app/src/app/llms.txt/route.ts index f0aacdb8..1af2b0bc 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, launch) +- GET ${SITE_URL}/api/boost?chain=base (the lane: twig, boosts, last_at, expires_at, clicks, launch) - GET ${SITE_URL}/api/boost?chain=base&token=0x… (one token: twig in the window, place, hidden) `; } diff --git a/app/src/components/launchpad/BoostedLane.tsx b/app/src/components/launchpad/BoostedLane.tsx index e897a086..08975710 100644 --- a/app/src/components/launchpad/BoostedLane.tsx +++ b/app/src/components/launchpad/BoostedLane.tsx @@ -3,7 +3,7 @@ import Link from "next/link"; import { useEffect, useRef, useState, type CSSProperties } from "react"; import { motion, useReducedMotion } from "motion/react"; -import { ArrowUpRight, ChevronDown } from "lucide-react"; +import { ArrowUpRight, ChevronDown, MousePointerClick } from "lucide-react"; import TokenAvatar from "./TokenAvatar"; import { TwigMark } from "./TwigBadge"; import { useLive } from "./LiveProvider"; @@ -16,6 +16,8 @@ import { setPendingToken } from "@/lib/launchpad/token-transition"; /** 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". */ +const CLICKS_FORMAT = { notation: "compact", maximumFractionDigits: 1 } as const; /** Sparks that rise from the amount when a boost lands: offsets, delays and size. Decorative, a fixed set. */ const EMBERS: readonly { dx: number; dy: number; delay: number; size: number; ink?: boolean }[] = [ @@ -53,8 +55,12 @@ function sendSeen(body: { kind: "view" } | { kind: "click"; token: string }, sou * amount rolling up, a few embers. Places change with a short slide. A hover is the visitor's own doing: the card warms, * its edge and an arrow show, the fuse brightens. * - * The whole card opens the token. The lane counts one view per page and each card click (sendSeen, /admin only); the - * server counts a click only while the token is still in the lane. + * The whole card opens the token. The lane counts one view per page and each card click (sendSeen); the server counts + * 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. */ export default function BoostedLane({ initial, serverNow, source }: { initial: BoostLane; serverNow: number; source: BoostSource }) { const { subscribe, wantBoost } = useLive(); @@ -152,7 +158,8 @@ export default function BoostedLane({ initial, serverNow, source }: { initial: B key={l.token} layout={reduced ? false : "position"} transition={{ type: "spring", stiffness: 380, damping: 34 }} - className={`group/card relative flex min-w-0 flex-col gap-2 bg-card px-4 pb-3.5 pt-3 transition-colors duration-150 hover:bg-[color-mix(in_oklab,var(--color-warm-soft)_70%,var(--color-card))] active:bg-warm-soft motion-reduce:transition-none ${lastOdd ? "sm:col-span-2 lg:col-span-1" : ""}`} + // one set of parts, placed by grid areas: a two-line row on a phone, the stacked card from 640px + className={`group/card relative grid min-w-0 grid-cols-[minmax(0,1fr)_auto_auto] items-center gap-x-3 gap-y-0.5 bg-card px-4 py-3 [grid-template-areas:'link_twig_twig'_'link_clicks_left'] sm:grid-cols-[minmax(0,1fr)_auto] sm:items-baseline sm:gap-y-2 sm:pb-3.5 sm:[grid-template-areas:'link_link'_'twig_left'_'burn_clicks'] transition-colors duration-150 hover:bg-[color-mix(in_oklab,var(--color-warm-soft)_70%,var(--color-card))] active:bg-warm-soft motion-reduce:transition-none ${lastOdd ? "sm:col-span-2 lg:col-span-1" : ""}`} > {/* one warm ring per ignition: a new key replays it */} {lit ? + ) : null} + {left} + Latest burn {/* the fuse: what is left of the 24 hours, burning down from the right; steps with the card's clock */} diff --git a/app/src/components/launchpad/boost-ui.test.ts b/app/src/components/launchpad/boost-ui.test.ts index 2b7bbbfa..5dfcccbd 100644 --- a/app/src/components/launchpad/boost-ui.test.ts +++ b/app/src/components/launchpad/boost-ui.test.ts @@ -199,7 +199,7 @@ test("CodeRabbit round 1: the done screen, admin errors and the agent formula", test("the whole card opens the token; only the burn link 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 pr-5 outline-none after:absolute after:inset-0 after:z-\[1\] focus-visible:after:ring-2 focus-visible:after:ring-inset focus-visible:after:ring-brand"/); + 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, / { - assert.match(lane, /className=\{`group\/card relative flex min-w-0 flex-col gap-2 bg-card px-4 pb-3\.5 pt-3 transition-colors duration-150 hover:bg-\[/); + assert.match(lane, /className=\{`group\/card relative grid min-w-0 [^`]* transition-colors duration-150 hover:bg-\[color-mix\(in_oklab,var\(--color-warm-soft\)_70%,var\(--color-card\)\)\] active:bg-warm-soft motion-reduce:transition-none/); // movement (the avatar's scale, the arrow's slide) only with motion allowed; colour and the edge stay for everyone assert.match(lane, /motion-safe:group-hover\/card:scale-105/); assert.match(lane, /motion-safe:-translate-x-0\.5 motion-safe:translate-y-0\.5 motion-safe:group-hover\/card:translate-x-0/); @@ -244,10 +244,30 @@ test("a click counts only while the token is in the lane, at the server's place, assert.match(seenRoute, /return new NextResponse\(null, \{ status: 204 \}\);\n\}/); }); -test("views and clicks are read by the admin figures alone, behind an admin wallet", () => { +test("views and clicks are read in boostServer.ts alone; the full table only for an admin wallet", () => { const users = walk(path.resolve(import.meta.dirname, "../..")).filter((f) => !f.endsWith(".test.ts") && /bb_boost_(clicks|views)/.test(readFileSync(f, "utf8"))); assert.deepEqual(users.map((f) => path.basename(f)), ["boostServer.ts"]); assert.ok(statsRoute.indexOf('if (!adminWallets().has(wallet)) return NextResponse.json({ error: "admin only" }, { status: 403 });') < statsRoute.indexOf("getBoostStats(")); assert.match(statsRoute, /if \(!isBoostStatsDays\(days\)\) return NextResponse\.json\(\{ error: "bad days" \}, \{ status: 400 \}\);/); assert.match(admin, /\{address \? : null\}/); }); + +test("each card shows its clicks of the last 24 hours, never a 0, and a failed read never fails the lane", () => { + assert.match(lane, /\{b\.clicks > 0 \? \(\n\s*\n\s*
-

A view is a page that brought the lane on screen. A click counts once per visitor every 30 minutes, only while the token is in the lane. Visitors: one per person per day.

+

A view is a page that brought the lane on screen. A click counts once per visitor every 30 minutes, only while the token is in the lane. Visitors: one per person per day, per token. The top {num(100)} tokens are listed; the totals cover all.

{err ?

{err}

: null} {rows === null ? ( err ? null :

Loading…

@@ -174,13 +175,14 @@ function LaneStats({ wallet }: { wallet: string }) { {total ? ( - All + {total.tokens > rows.length ? `All ${num(total.tokens)}` : "All"} {num(total.views)} {num(total.clicks)} {boostClickRate(total.clicks, total.views)} - {num(total.visitors)} - {total.twig > 0n ? fmtBoostTwig(total.twig) : "–"} - {per(total.clicks, total.twig.toString())} + {/* a visitor is counted per token: one person on five cards cannot be told from five people */} + – + {BigInt(total.twig) > 0n ? fmtBoostTwig(total.twig) : "–"} + {per(total.clicks, total.twig)} ) : null} diff --git a/app/src/components/launchpad/BoostedLane.tsx b/app/src/components/launchpad/BoostedLane.tsx index 08975710..df0da3fb 100644 --- a/app/src/components/launchpad/BoostedLane.tsx +++ b/app/src/components/launchpad/BoostedLane.tsx @@ -101,8 +101,10 @@ export default function BoostedLane({ initial, serverNow, source }: { initial: B }; }, [subscribe]); - // one view per page: the first time the lane, with cards in it, is a third on screen - const hasCards = lane.enabled && lane.items.length > 0; + // a boost that ran out between polls leaves at once rather than showing "0m left" + const shown = lane.enabled ? lane.items.filter((b) => boostTimeLeft(b.last_at, now) !== null) : []; + // one view per page: the first time the lane, with cards on screen (never its empty line), is a third in view + const hasCards = shown.length > 0; useEffect(() => { const el = section.current; if (!hasCards || viewed.current || !el || typeof IntersectionObserver === "undefined") return; @@ -120,8 +122,7 @@ export default function BoostedLane({ initial, serverNow, source }: { initial: B }, [hasCards, source]); if (!lane.enabled) return null; - // a boost that ran out between polls leaves at once rather than showing "0m left" - const items = lane.items.filter((b) => boostTimeLeft(b.last_at, now) !== null); + const items = shown; return (
@@ -165,7 +166,7 @@ export default function BoostedLane({ initial, serverNow, source }: { initial: B {lit ?