Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
183 changes: 183 additions & 0 deletions app/src/components/launchpad/BoostPicker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
"use client";

import { useEffect, useId, useState } from "react";
import { useRouter } from "next/navigation";
import { ChevronRight, Search } from "lucide-react";
import Sheet from "@/components/Sheet";
import TokenAvatar from "./TokenAvatar";
import { useHydratedAccount } from "@/lib/useHydratedAccount";
import { isSearchableQuery, paletteTokenFrom, type PaletteToken } from "@/lib/command-palette";
import { marketUsd } from "@/lib/launchpad/market-format";
import { setPendingToken } from "@/lib/launchpad/token-transition";
import { BOOST_CHAIN, BOOST_COPY } from "@/lib/launchpad/boost";

type LaunchesResponse = { launches?: Parameters<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 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 kept = await fetch(`/api/boost?chain=${BOOST_CHAIN}&token=${t.token}`, { cache: "no-store" })
.then((r) => (r.ok ? (r.json() as Promise<{ enabled?: boolean; hidden?: boolean }>) : null))
.then((d) => d?.enabled === true && d.hidden === true)
.catch(() => false);
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