Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions app/src/app/api/boost/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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 });
Expand Down
11 changes: 9 additions & 2 deletions app/src/app/api/launch/list/route.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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");
Expand All @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion app/src/app/llms.txt/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
`;
}
Expand Down
191 changes: 191 additions & 0 deletions app/src/components/launchpad/BoostPicker.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof paletteTokenFrom>[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<PaletteToken[] | null>(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<string | null>(null);
const [hidden, setHidden] = useState<ReadonlySet<string>>(new Set());
// the check a pick is waiting on; closing the picker aborts it, and an aborted check never navigates
const pickCheck = useRef<AbortController | null>(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<LaunchesResponse>) : 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<LaunchesResponse>) : 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<LaunchesResponse>) : 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`);
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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 (
<Sheet title={BOOST_COPY.cta} onClose={onClose}>
<div className="space-y-4">
<p className="text-pretty text-xs leading-relaxed text-body">{BOOST_COPY.pickerNote}</p>
<div className="flex items-center gap-2.5 rounded-xl border border-line-strong bg-paper px-3 focus-within:border-transparent focus-within:ring-2 focus-within:ring-brand/60">
<Search size={15} aria-hidden="true" className="shrink-0 text-muted" />
<label htmlFor={inputId} className="sr-only">{BOOST_COPY.pickerSearch}</label>
{/* 16px on a phone: iOS zooms the page into any smaller input it focuses */}
<input
id={inputId}
type="search"
autoComplete="off"
spellCheck={false}
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Name, symbol or address"
className="min-h-11 min-w-0 flex-1 bg-transparent text-base text-ink outline-none placeholder:text-faint focus-visible:outline-none! sm:text-sm"
/>
</div>
<p role="status" aria-live="polite" className="text-xs text-muted empty:hidden">{status}</p>
{searching ? (
current && current.tokens.length > 0 ? <TokenList tokens={current.tokens} {...row} /> : null
) : (
<>
{own.length > 0 ? <TokenList title={BOOST_COPY.pickerMine} tokens={own} {...row} /> : null}
{trending === null ? <p className="text-xs text-muted">Loading…</p> : trending.length > 0 ? <TokenList title={BOOST_COPY.pickerTrending} tokens={trending} {...row} /> : null}
</>
)}
</div>
</Sheet>
);
}

function TokenList({ title, tokens, onPick, checking, hidden }: { title?: string; tokens: readonly PaletteToken[]; onPick: (t: PaletteToken) => void; checking: string | null; hidden: ReadonlySet<string> }) {
const titleId = useId();
return (
<section aria-labelledby={title ? titleId : undefined}>
{title ? <h3 id={titleId} className="mb-1.5 text-[11px] font-medium text-muted">{title}</h3> : null}
<ul className="-mx-2 space-y-0.5">
{tokens.map((t) => (
<li key={t.token}>
<button
type="button"
onClick={() => onPick(t)}
disabled={hidden.has(t.token) || (checking !== null && checking !== t.token)}
aria-busy={checking === t.token}
className="group flex w-full min-w-0 items-center gap-3 rounded-xl px-2 py-2 text-left transition-colors hover:bg-[color-mix(in_oklab,var(--color-warm-soft)_70%,var(--color-card))] focus-visible:bg-[color-mix(in_oklab,var(--color-warm-soft)_70%,var(--color-card))] active:bg-warm-soft disabled:cursor-default disabled:hover:bg-transparent motion-reduce:transition-none"
>
<TokenAvatar chain={t.chain} token={t.token} symbol={t.symbol} image={t.image} size={32} className="shrink-0 rounded-xl" />
<span className="min-w-0 flex-1">
<span className="block truncate text-[13px] font-semibold text-ink">{t.name}</span>
<span className="block truncate text-[11px] text-muted"><span className="font-mono text-body">{t.symbol}</span>{t.capUsd !== null ? <><span aria-hidden="true"> · </span><span className="sr-only">, market cap </span><span className="font-mono tnum">{marketUsd(t.capUsd)}</span></> : null}</span>
</span>
{hidden.has(t.token) ? (
<span className="shrink-0 text-right text-[11px] text-muted">{BOOST_COPY.pickerHidden}</span>
) : (
<span className="inline-flex shrink-0 items-center gap-0.5 text-[11px] font-medium text-warm-ink">{checking === t.token ? "Opening…" : "Boost"}<ChevronRight size={13} aria-hidden="true" className="transition-transform duration-150 motion-safe:group-hover:translate-x-0.5" /></span>
)}
</button>
</li>
))}
</ul>
</section>
);
}
Loading
Loading