diff --git a/app/db/schema.sql b/app/db/schema.sql index cb4814bb..3e14e218 100644 --- a/app/db/schema.sql +++ b/app/db/schema.sql @@ -448,3 +448,29 @@ 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 ── +-- 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 ( + 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); +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, + 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..460476c9 --- /dev/null +++ b/app/src/app/api/boost/seen/route.ts @@ -0,0 +1,36 @@ +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 { boostBeaconSameOrigin, 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. Only the site's own pages may send it: the counts are public on + * the cards, and another site could otherwise send them from its visitors' browsers. 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 }); + if (!boostBeaconSameOrigin(req.headers.get("sec-fetch-site"), req.headers.get("origin"), req.headers.get("host"))) { + return NextResponse.json({ error: "cross-site" }, { status: 403 }); + } + 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..18ff34ab --- /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, ...(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..1fa58d2f 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,13 @@ 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"); + // another site's beacons are refused before they use a bucket or reach the DB + assert.match(src, /if \(!boostBeaconSameOrigin\(req\.headers\.get\("sec-fetch-site"\), req\.headers\.get\("origin"\), req\.headers\.get\("host"\)\)\) \{\n\s*return NextResponse\.json\(\{ error: "cross-site" \}, \{ status: 403 \}\);/); + assert.ok(src.indexOf("boostBeaconSameOrigin(req") < src.indexOf("rateLimited(`boost-seen:ip")); + assert.ok(src.indexOf("rateLimited(`boost-seen:ip") < src.indexOf("recordBoostSeen(")); + assert.match(src, /parseBoostSeen\(await readJson\(req, 512\)\)/); +}); 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/BoostAdmin.tsx b/app/src/components/launchpad/BoostAdmin.tsx index 6bf2732b..db4078bd 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, BoostStats } 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,110 @@ 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); + // both keyed by period: a period still loading shows "Loading…", never the last period's figures or error + const [got, setGot] = useState<{ key: string; stats: BoostStats } | null>(null); + const [failed, setFailed] = useState<{ key: string; text: string } | null>(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 Partial & { error?: string }; + if (!res.ok || !d.rows || !d.total) throw new Error(d.error ?? `request failed (HTTP ${res.status})`); + setGot({ key, stats: { rows: d.rows, total: d.total } }); + }) + .catch((e) => { + if (!ac.signal.aborted) setFailed({ key, text: friendlyError(e) }); + }); + return () => ac.abort(); + }, [wallet, days, key]); + + const stats = got?.key === key ? got.stats : null; + const err = !stats && failed?.key === key ? failed.text : null; + const rows = stats?.rows ?? null; + const total = stats?.total; + 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, per token. The top {num(100)} tokens are listed; the totals cover all.

+ {err ?

{err}

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

Loading…

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

No views, clicks or boosts in this period.

+ ) : ( +
+ + + + + + + + + + + + + + {rows.map((r) => ( + + + + + + + + + + ))} + + {total ? ( + + + + + + + {/* a visitor is counted per token: one person on five cards cannot be told from five people */} + + + + + + ) : 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)}
{total.tokens > rows.length ? `All ${num(total.tokens)}` : "All"}{num(total.views)}{num(total.clicks)}{boostClickRate(total.clicks, total.views)}–{BigInt(total.twig) > 0n ? fmtBoostTwig(total.twig) : "–"}{per(total.clicks, total.twig)}
+
+ )} +
+ ); +} diff --git a/app/src/components/launchpad/BoostedLane.tsx b/app/src/components/launchpad/BoostedLane.tsx index dde12f7d..df0da3fb 100644 --- a/app/src/components/launchpad/BoostedLane.tsx +++ b/app/src/components/launchpad/BoostedLane.tsx @@ -3,19 +3,21 @@ 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"; 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"; /** 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 }[] = [ @@ -28,6 +30,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 +52,17 @@ 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); 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 }: { 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 +70,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 +101,30 @@ export default function BoostedLane({ initial, serverNow }: { initial: BoostLane }; }, [subscribe]); - 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 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; + 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; + const items = shown; return ( -
+

@@ -108,50 +153,69 @@ 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..908e91c8 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,113 @@ 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 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 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/); + 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 and polls read (same cache), and a boost that ran out inside it is dropped there, once + assert.match(server, /const lane = await boostLaneOrNull\(ethUsdNow\);\n\s*return lane\?\.enabled \? lane\.items\.map\(\(b\) => b\.token\) : \[\];/); + const orNull = server.slice(server.indexOf("export async function boostLaneOrNull("), server.indexOf("/** One token's boost state")); + assert.match(orNull, /return \{ \.\.\.lane, items: lane\.items\.filter\(\(b\) => boostTimeLeft\(b\.last_at, now\) !== null\) \};/); + assert.equal((server.match(/boostTimeLeft\(/g) ?? []).length, 1, "one expiry filter on the server"); + // 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 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*