From 4f2dbd3ea5e547a71f65798f89aee6c39e9e6671 Mon Sep 17 00:00:00 2001 From: Vasanth T Date: Mon, 5 Oct 2026 07:04:58 +0530 Subject: [PATCH 1/2] ui: home as a live floor - New hero: "Launch a token. / We take nothing." The live totals read as one sentence with rolling digits; the full breakdown sits behind a toggle. - The river: the last 30 minutes of real launches and trades. Buys sit above the line, sells below, launches on it with a lock, sized by dollars. One CSS clock moves every mark (no per-frame script), reduced motion holds a still timeline, and a list view carries the same events as links. A cut-off seed says "Latest N" instead of a 30-minute total. - Trending becomes one row of chips. The "Live" tab is renamed "Active". - Watchlist carried over from #34: a star on every row (outside the row link), a Watchlist tab with a count, and ?view=watchlist so the tab survives a back navigation. The panel uses the list's chain toggle. - A Just launched rail replaces the Activity tape. Counts come from the market read model and are re-read only when the shared poll reports a new launch or a trade on a token shown (at most once every 10s). - The animated launch machine moves to How it works. - Header CTA handoff: the hero reports its own visibility, so the header copy stays quiet even when a light-theme load remounts the page. --- app/src/app/(home)/page.tsx | 74 ++-- app/src/app/api/launch/watchlist/route.ts | 27 ++ app/src/app/rules/page.tsx | 3 + app/src/components/HeaderNav.tsx | 23 +- app/src/components/launchpad/HeroCtaLink.tsx | 20 ++ app/src/components/launchpad/JustLaunched.tsx | 102 ++++++ app/src/components/launchpad/LaunchHero.tsx | 89 +++-- app/src/components/launchpad/LaunchList.tsx | 68 ++-- .../components/launchpad/LaunchMechanism.tsx | 27 +- app/src/components/launchpad/LaunchRow.tsx | 8 +- app/src/components/launchpad/LaunchTape.tsx | 100 ++---- .../components/launchpad/LiveRiver.module.css | 98 ++++++ app/src/components/launchpad/LiveRiver.tsx | 140 ++++++++ .../components/launchpad/TrendingStrip.tsx | 65 ++-- app/src/components/launchpad/WatchButton.tsx | 34 ++ .../components/launchpad/WatchlistPanel.tsx | 148 ++++++++ .../components/launchpad/home-floor.test.ts | 58 ++++ app/src/components/launchpad/useWatchlist.ts | 38 +++ .../components/sections/RulesGuide.module.css | 1 + app/src/lib/hero-cta.ts | 23 ++ .../lib/launchpad/feed-dust-wiring.test.ts | 6 +- app/src/lib/launchpad/holders.ts | 2 + app/src/lib/launchpad/paging.ts | 2 + app/src/lib/launchpad/queries.ts | 18 +- app/src/lib/launchpad/river.test.ts | 198 +++++++++++ app/src/lib/launchpad/river.ts | 232 +++++++++++++ app/src/lib/launchpad/watchlist.test.ts | 323 ++++++++++++++++++ app/src/lib/launchpad/watchlist.ts | 189 ++++++++++ app/src/lib/launchpad/watchlistData.test.ts | 256 ++++++++++++++ app/src/lib/launchpad/watchlistData.ts | 141 ++++++++ 30 files changed, 2264 insertions(+), 249 deletions(-) create mode 100644 app/src/app/api/launch/watchlist/route.ts create mode 100644 app/src/components/launchpad/HeroCtaLink.tsx create mode 100644 app/src/components/launchpad/JustLaunched.tsx create mode 100644 app/src/components/launchpad/LiveRiver.module.css create mode 100644 app/src/components/launchpad/LiveRiver.tsx create mode 100644 app/src/components/launchpad/WatchButton.tsx create mode 100644 app/src/components/launchpad/WatchlistPanel.tsx create mode 100644 app/src/components/launchpad/home-floor.test.ts create mode 100644 app/src/components/launchpad/useWatchlist.ts create mode 100644 app/src/lib/hero-cta.ts create mode 100644 app/src/lib/launchpad/river.test.ts create mode 100644 app/src/lib/launchpad/river.ts create mode 100644 app/src/lib/launchpad/watchlist.test.ts create mode 100644 app/src/lib/launchpad/watchlist.ts create mode 100644 app/src/lib/launchpad/watchlistData.test.ts create mode 100644 app/src/lib/launchpad/watchlistData.ts diff --git a/app/src/app/(home)/page.tsx b/app/src/app/(home)/page.tsx index 5dc61f31..eebad0c8 100644 --- a/app/src/app/(home)/page.tsx +++ b/app/src/app/(home)/page.tsx @@ -2,22 +2,28 @@ import type { Metadata } from "next"; import Link from "next/link"; import LaunchHero from "@/components/launchpad/LaunchHero"; import LaunchList from "@/components/launchpad/LaunchList"; -import LaunchTape from "@/components/launchpad/LaunchTape"; +import LiveRiver from "@/components/launchpad/LiveRiver"; +import JustLaunched from "@/components/launchpad/JustLaunched"; import { PostsFeed } from "@/components/launchpad/Posts"; import { listFeed } from "@/lib/launchpad/postsServer"; import { VOLUME_WINDOWS, getLaunchFeed, getTrending, isTrendingSource, listLaunchesPage, parseSort, trendingFrom, type VolumeWindow } from "@/lib/launchpad/queries"; -import { PAGE_SIZE } from "@/lib/launchpad/paging"; +import { JUST_LAUNCHED_SIZE, PAGE_SIZE } from "@/lib/launchpad/paging"; import { ethUsd } from "@/lib/launchpad/ethPrice"; import { LAUNCHPAD_CONFIGURED, visibleChainOr } from "@/lib/launchpad/config"; import { dbConfigured } from "@/lib/db"; import { isFilter } from "@/lib/launchpad/search"; +import { nowMs } from "@/lib/launchpad/time"; +import { inRiverWindow, riverCoverage } from "@/lib/launchpad/river"; import TrendingStrip from "@/components/launchpad/TrendingStrip"; export const dynamic = "force-dynamic"; -// Title and description come from the root layout; ?sort= / ?chain= / ?window= views are all this one page. +// Title and description come from the root layout; ?sort= / ?chain= / ?window= / ?view= are all this one page. export const metadata: Metadata = { alternates: { canonical: "/" } }; -export default async function Home({ searchParams }: { searchParams: Promise<{ sort?: string; window?: string; chain?: string; filter?: string }> }) { +/** The river starts from the newest events (getLaunchFeed's ceiling) and keeps the ones from the last 30 minutes. */ +const RIVER_SEED = 100; + +export default async function Home({ searchParams }: { searchParams: Promise<{ sort?: string; window?: string; chain?: string; filter?: string; view?: string }> }) { const sp = await searchParams; const sort = parseSort(sp.sort, "live"); const window: VolumeWindow = VOLUME_WINDOWS.includes(sp.window as VolumeWindow) ? (sp.window as VolumeWindow) : "all"; @@ -25,35 +31,41 @@ export default async function Home({ searchParams }: { searchParams: Promise<{ s const filter = isFilter(sp.filter) ? sp.filter : null; const usd = await ethUsd(); const listOpts = { sort, window, chain, filter, limit: PAGE_SIZE, ethUsd: usd }; - const [page, feed, posts, fetchedTrending] = await Promise.all([listLaunchesPage(listOpts), getLaunchFeed(24, usd), listFeed(30), isTrendingSource(listOpts) ? null : getTrending(usd)]); + const [page, feed, fresh, posts, fetchedTrending] = await Promise.all([ + listLaunchesPage(listOpts), + getLaunchFeed(RIVER_SEED, usd), + listLaunchesPage({ sort: "new", limit: JUST_LAUNCHED_SIZE, ethUsd: usd }), + listFeed(30), + isTrendingSource(listOpts) ? null : getTrending(usd), + ]); const trending = fetchedTrending ?? trendingFrom(page.items); // the default view's first page doubles as the strip's candidates + const now = nowMs(); return ( - <> -
- - -
- - -
-
- +
+ + inRiverWindow(item, now))} 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/launch/watchlist/route.ts b/app/src/app/api/launch/watchlist/route.ts new file mode 100644 index 00000000..5037e9e6 --- /dev/null +++ b/app/src/app/api/launch/watchlist/route.ts @@ -0,0 +1,27 @@ +import { NextResponse } from "next/server"; +import { dbConfigured } from "@/lib/db"; +import { rateLimited } from "@/lib/launchpad/editServer"; +import { ethUsd } from "@/lib/launchpad/ethPrice"; +import { WatchlistInputError, getWatchlistData, parseWatchlistRequest, readWatchlistBody } from "@/lib/launchpad/watchlistData"; + +export const dynamic = "force-dynamic"; + +/** Read-only POST keeps the user's device-local token list out of URLs. Nothing is persisted. */ +export async function POST(req: Request) { + const headers = { "cache-control": "no-store" }; + const ip = (req.headers.get("fly-client-ip") || req.headers.get("x-forwarded-for") || "").split(",")[0].trim() || "0.0.0.0"; + if (rateLimited(`watchlist:ip:${ip}`, 60)) { + return NextResponse.json({ error: "Too many refreshes. Try again shortly." }, { status: 429, headers: { ...headers, "retry-after": "60" } }); + } + try { + const body = await readWatchlistBody(req); + const at = Date.now(); + const items = parseWatchlistRequest(body, at); + if (!dbConfigured()) return NextResponse.json({ error: "Watchlist activity is unavailable.", indexed: false }, { status: 503, headers }); + const data = await getWatchlistData(items, at, items.length ? await ethUsd() : null); + return NextResponse.json(data, { status: data.indexed ? 200 : 503, headers }); + } catch (error) { + if (error instanceof WatchlistInputError) return NextResponse.json({ error: error.message }, { status: error.status, headers }); + return NextResponse.json({ error: "Watchlist activity could not be refreshed. Your saved tokens are unchanged.", indexed: false }, { status: 503, headers }); + } +} diff --git a/app/src/app/rules/page.tsx b/app/src/app/rules/page.tsx index 71514495..e83dba9d 100644 --- a/app/src/app/rules/page.tsx +++ b/app/src/app/rules/page.tsx @@ -8,6 +8,7 @@ import styles from "@/components/sections/RulesGuide.module.css"; import { launchpad } from "@/lib/launchpad/config"; import { CHAINS, CHAIN_KEYS, CHAIN_LABELS, explorerAddress } from "@/lib/chainPublic"; import { BRAND_GITHUB } from "@/lib/brand"; +import LaunchMachine from "@/components/launchpad/LaunchMachine"; export const metadata: Metadata = pageMetadata({ path: "/rules", title: "How it works", description: "What a launch does on-chain, what it costs (gas), and what can never happen to your liquidity." }); @@ -71,6 +72,8 @@ export default function RulesPage() {

01 / The launch

One signature.
Four things happen.

All in the same transaction.
No separate setup. No platform fee.

+ {/* the animated walk-through that used to open the home page: token, pool, lock */} +
    {STEPS.map(({ title, description, value, label }, i) => (
  1. diff --git a/app/src/components/HeaderNav.tsx b/app/src/components/HeaderNav.tsx index c577165e..051151d1 100644 --- a/app/src/components/HeaderNav.tsx +++ b/app/src/components/HeaderNav.tsx @@ -7,6 +7,7 @@ import { MotionConfig, motion, useReducedMotion } from "motion/react"; import { Menu, Search, X } from "lucide-react"; import { cn } from "@/lib/utils"; import { isTypingTarget } from "@/lib/command-palette"; +import { heroCtaOnScreen, subscribeHeroCta } from "@/lib/hero-cta"; import CommandPalette from "./CommandPalette"; import { Navbar, NavBody, MobileNav, MobileNavHeader, MobileNavMenu } from "./navigation-shell"; import Mark, { Wordmark } from "./launchpad/Mark"; @@ -94,24 +95,14 @@ function SearchTrigger({ onClick }: { onClick: () => void }) { /** * One filled blue per screen: while the home hero's own "Launch a token" is in - * view, the header's copy of it stays a hairline utility. Watches `#hero-cta`; - * on any other route (or before the hero mounts) the header CTA is the filled one. + * view, the header's copy of it stays a hairline utility. The hero reports its + * visibility itself (HeroCtaLink, `#hero-cta`); on any other route the header + * CTA is the filled one. */ function useHeroCtaOnScreen(pathname: string) { - // Assume it is on screen on the home page's first paint, so the header CTA - // does not flash filled before the observer's first callback. - const [onScreen, setOnScreen] = useState(() => pathname === "/"); - useEffect(() => { - const el = document.getElementById("hero-cta"); - if (!el) return; - // any visible sliver counts (occlusion by the floating pill is ignored on purpose): - // the header fills only once the hero CTA has fully left the viewport - const io = new IntersectionObserver(([entry]) => setOnScreen(entry.isIntersecting), { threshold: 0 }); - io.observe(el); - return () => io.disconnect(); - }, [pathname]); - // the observer only runs where the hero exists; elsewhere the stale value must not leak - return pathname === "/" && onScreen; + const onScreen = useSyncExternalStore(subscribeHeroCta, heroCtaOnScreen, () => null); + // before the hero's first report, assume it is on screen on the home page so the header CTA does not flash filled + return pathname === "/" && (onScreen ?? true); } // ── desktop ────────────────────────────────────────────────────────────────── diff --git a/app/src/components/launchpad/HeroCtaLink.tsx b/app/src/components/launchpad/HeroCtaLink.tsx new file mode 100644 index 00000000..c80c5091 --- /dev/null +++ b/app/src/components/launchpad/HeroCtaLink.tsx @@ -0,0 +1,20 @@ +"use client"; + +import Link from "next/link"; +import { useEffect, useRef } from "react"; +import { setHeroCtaOnScreen } from "@/lib/hero-cta"; + +/** The hero's filled CTA. It reports its own visibility, so the header's copy can stay quiet while it is on screen. */ +export default function HeroCtaLink({ id, href, className, children }: { id: string; href: string; className: string; children: React.ReactNode }) { + const ref = useRef(null); + useEffect(() => { + const el = ref.current; + if (!el) return; + // any visible sliver counts (occlusion by the floating pill is ignored on purpose): + // the header fills only once the hero CTA has fully left the viewport + const io = new IntersectionObserver(([entry]) => setHeroCtaOnScreen(entry.isIntersecting), { threshold: 0 }); + io.observe(el); + return () => { io.disconnect(); setHeroCtaOnScreen(null); }; + }, []); + return {children}; +} diff --git a/app/src/components/launchpad/JustLaunched.tsx b/app/src/components/launchpad/JustLaunched.tsx new file mode 100644 index 00000000..79861703 --- /dev/null +++ b/app/src/components/launchpad/JustLaunched.tsx @@ -0,0 +1,102 @@ +"use client"; + +import Link from "next/link"; +import { useEffect, useRef, useState } from "react"; +import TokenAvatar from "./TokenAvatar"; +import { useLive } from "./LiveProvider"; +import type { LaunchRow as L } from "@/lib/launchpad/queries"; +import { CHAIN_SHORT } from "@/lib/chainPublic"; +import { ago } from "@/lib/launchpad/time"; +import { launchKey } from "@/lib/launchpad/list-state"; +import { feedKey } from "@/lib/launchpad/river"; +import { setPendingToken } from "@/lib/launchpad/token-transition"; +import { JUST_LAUNCHED_SIZE } from "@/lib/launchpad/paging"; + +/** At most one refetch per this window; a burst of trades on a fresh token costs one request, not one per poll. */ +const MIN_GAP_MS = 10_000; + +/** + * The newest launches and whether each has had its first trade. Rows come from the market read model, so the + * counts are exact; the shared poll only says when they are worth re-reading (a new launch, or a trade on one shown). + */ +export default function JustLaunched({ initial, serverNow }: { initial: L[]; serverNow: number }) { + const { subscribe } = useLive(); + const [rows, setRows] = useState(initial); + const [now, setNow] = useState(serverNow); + const shown = useRef(initial); + + useEffect(() => { + const seen = new Set(); + let lastFetch = 0; + let trailing: ReturnType | null = null; + let controller: AbortController | null = null; + function load() { + const wait = lastFetch + MIN_GAP_MS - Date.now(); + if (wait > 0) { + trailing ??= setTimeout(() => { trailing = null; load(); }, wait); + return; + } + lastFetch = Date.now(); + controller?.abort(); + const c = new AbortController(); + controller = c; + fetch(`/api/launch/list?sort=new&limit=${JUST_LAUNCHED_SIZE}`, { cache: "no-store", signal: c.signal }) + .then(async (res) => { + if (!res.ok) return; + const data = await res.json() as { launches?: L[] }; + if (c.signal.aborted || !Array.isArray(data.launches)) return; + shown.current = data.launches; + setRows(data.launches); + }) + .catch(() => { /* keep the last rows; the next event retries */ }); + } + const unsubscribe = subscribe((snap) => { + const keys = new Set(shown.current.map(launchKey)); + const oldest = shown.current.length ? Math.min(...shown.current.map((r) => new Date(r.block_time).getTime())) : -Infinity; + let stale = false; + for (const item of snap.feed ?? []) { + const key = feedKey(item); + if (seen.has(key)) continue; + seen.add(key); + const token = launchKey(item); + if (item.kind === "launch" ? !keys.has(token) && new Date(item.at).getTime() >= oldest : keys.has(token)) stale = true; + } + if (stale) load(); + }); + const clock = setInterval(() => setNow(Date.now()), 15_000); + return () => { unsubscribe(); clearInterval(clock); controller?.abort(); if (trailing) clearTimeout(trailing); }; + }, [subscribe]); + + return ( +
    +
    +

    Just launched

    + Newest first +
    + {rows.length === 0 ? ( +

    No launches yet.

    New tokens show here the moment they launch.

    + ) : ( +
      + {rows.map((l) => { + const trades = l.buys + l.sells; + return ( +
    1. + setPendingToken({ chain: l.chain, token: l.token, name: l.name, symbol: l.symbol, image: l.image_url })} className="flex min-w-0 items-center gap-3 px-4 py-3 transition-colors hover:bg-card motion-reduce:transition-none"> + +
      +

      {l.name}

      +

      {l.symbol} on {CHAIN_SHORT[l.chain]}

      +
      +
      + + {trades ? <>{trades} {trades === 1 ? "trade" : "trades"} : "No trades yet"} +
      + +
    2. + ); + })} +
    + )} +
    + ); +} diff --git a/app/src/components/launchpad/LaunchHero.tsx b/app/src/components/launchpad/LaunchHero.tsx index 73e4f63b..2561e8fb 100644 --- a/app/src/components/launchpad/LaunchHero.tsx +++ b/app/src/components/launchpad/LaunchHero.tsx @@ -1,64 +1,51 @@ import Link from "next/link"; -import { ArrowRight } from "lucide-react"; +import { ArrowUpRight } from "lucide-react"; import { btn } from "@/components/ui"; import LaunchMechanism from "./LaunchMechanism"; -import LaunchMachine from "./LaunchMachine"; +import HeroCtaLink from "./HeroCtaLink"; import { BRAND_GITHUB } from "@/lib/brand"; /** * First viewport of the home page. Left: the promise and the one filled CTA. - * Right: a code-native isometric launch mechanism. Live totals remain outside - * the illustrative scene, with source links and an accessible breakdown. + * Right: the network's real totals as one plain sentence, with the breakdown a tap away. + * The live river sits directly below, so the proof is the market itself. */ -export default function LaunchHero({ configured }: { ethUsd?: number | null; configured: boolean }) { +export default function LaunchHero({ configured }: { configured: boolean }) { return ( -
    -
    -
    -

    - Launch a token. -
    - Free. On Base, Robinhood or Arc. -

    -

    - {/* the brand as a plain word, once, above the fold: the title and footer alone read as a domain */} - openlaunch is the free, open-source launchpad. One transaction: your token, a Uniswap v4 pool, and a liquidity position locked forever. 100% of the supply goes into the pool at launch. We take nothing. You only pay gas. -

    -
    - {/* the header watches this id: while it is on screen the header CTA stays quiet (one filled blue per screen) */} - - Launch a token - - - - How it works - -
    - {/* the proofs that used to be chips: quiet, linkable, one line */} -

    - MIT - · - - source ↗ - - · - - verified contracts - -

    - {!configured ? ( -

    - Launchpad contracts are not configured yet. Read-only until NEXT_PUBLIC_LAUNCH_FACTORY / _LOCKER are set. -

    - ) : null} -
    - -
    - -
    -
    - +
    +
    +

    + Launch a token. +
    + We take nothing. +

    +

    + {/* the brand as a plain word, once, above the fold: the title and footer alone read as a domain */} + openlaunch is the free, open-source launchpad on Base, Robinhood Chain and Arc. One transaction: your token, a Uniswap v4 pool, and a liquidity position locked forever. 100% of the supply goes into the pool at launch. You only pay gas. +

    +
    + {/* the header watches this id: while it is on screen the header CTA stays quiet (one filled blue per screen) */} + + Launch a token + + + See how it works +
    + {/* the proofs: quiet, linkable, one line */} + + {!configured ? ( +

    + Launchpad contracts are not configured yet. Read-only until NEXT_PUBLIC_LAUNCH_FACTORY / _LOCKER are set. +

    + ) : null} +
    +
    +
    ); diff --git a/app/src/components/launchpad/LaunchList.tsx b/app/src/components/launchpad/LaunchList.tsx index 7b8ae78a..98e15bc4 100644 --- a/app/src/components/launchpad/LaunchList.tsx +++ b/app/src/components/launchpad/LaunchList.tsx @@ -3,9 +3,12 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import { Fragment, useEffect, useRef, useState } from "react"; -import { ArrowDownWideNarrow, ArrowRight, Search, SlidersHorizontal, X } from "lucide-react"; +import { ArrowDownWideNarrow, ArrowRight, Search, SlidersHorizontal, Star, X } from "lucide-react"; import LaunchRow, { LaunchListHeader, type RowHighlight } from "./LaunchRow"; import { useLive } from "./LiveProvider"; +import WatchButton from "./WatchButton"; +import WatchlistPanel from "./WatchlistPanel"; +import { useWatchlist } from "./useWatchlist"; import { ToggleGroup, ToggleGroupItem } from "@/components/vendor/toggle-group"; import { btn } from "@/components/ui"; import type { LaunchRow as L, LaunchSort, VolumeWindow } from "@/lib/launchpad/queries"; @@ -19,7 +22,7 @@ import { Spinner } from "@/components/Skeleton"; import { startNav } from "@/components/RouteProgress"; const SORTS: { key: LaunchSort; label: string }[] = [ - { key: "live", label: "Live" }, + { key: "live", label: "Active" }, { key: "new", label: "New" }, { key: "mcap", label: "Market cap" }, { key: "volume", label: "Volume" }, @@ -37,13 +40,16 @@ const REORDER_QUIET_MS = 3_000; type Selection = { sort: LaunchSort; window: VolumeWindow; chain: ChainKey | null; filter: LaunchFilter | null }; type SearchResult = { query: string; chain: ChainKey | null; rows: L[]; error?: boolean }; +export type ListView = "market" | "watchlist"; /** Shared live data, stable pointer targets, URL-backed filters and scoped async results. */ -export default function LaunchList({ initial, initialHasMore = false, initialSort, initialWindow, initialChain, initialFilter = null, hasDb }: { initial: L[]; initialHasMore?: boolean; initialSort: LaunchSort; initialWindow: VolumeWindow; initialChain: ChainKey | null; initialFilter?: LaunchFilter | null; ethUsd?: number | null; hasDb: boolean }) { +export default function LaunchList({ initial, initialHasMore = false, initialSort, initialWindow, initialChain, initialFilter = null, initialView = "market", hasDb }: { initial: L[]; initialHasMore?: boolean; initialSort: LaunchSort; initialWindow: VolumeWindow; initialChain: ChainKey | null; initialFilter?: LaunchFilter | null; initialView?: ListView; ethUsd?: number | null; hasDb: boolean }) { const router = useRouter(); const { live, setListParams, subscribe } = useLive(); const [selection, setSelection] = useState({ sort: initialSort, window: initialWindow, chain: initialChain, filter: initialFilter }); const { sort, window: window_, chain, filter } = selection; + const [view, setView] = useState(initialView); + const watchlist = useWatchlist(); const selectionRef = useRef(selection); const generation = useRef(0); const selectionRequest = useRef(null); @@ -65,9 +71,10 @@ export default function LaunchList({ initial, initialHasMore = false, initialSor const listRef = useRef(null); useEffect(() => { - setListParams({ ...selection, limit }); + // the watchlist reads its own endpoint; the shared poll only carries the market list while it is on screen + setListParams(view === "market" ? { ...selection, limit } : null); return () => setListParams(null); - }, [selection, limit, setListParams]); + }, [selection, limit, view, setListParams]); useEffect(() => { const clock = setInterval(() => { @@ -116,9 +123,25 @@ export default function LaunchList({ initial, initialHasMore = false, initialSor } }), [subscribe]); - function pick(s: LaunchSort, w: VolumeWindow = window_, c: ChainKey | null = chain, f: LaunchFilter | null = filter) { + /** The URL carries the view as well as the selection, so going back to the page reopens the same tab. */ + function syncUrl(next: Selection, v: ListView): URLSearchParams { + const p = new URLSearchParams(); + if (next.sort !== "live") p.set("sort", next.sort); + if (next.window !== "all") p.set("window", next.window); + if (next.chain) p.set("chain", next.chain); + if (next.filter) p.set("filter", next.filter); + if (v === "watchlist") p.set("view", "watchlist"); + router.replace(p.size ? `/?${p}` : "/", { scroll: false }); + return p; + } + + function pick(s: LaunchSort, w: VolumeWindow = window_, c: ChainKey | null = chain, f: LaunchFilter | null = filter, v: ListView = view) { const next = { sort: s, window: w, chain: c, filter: f }; - if (JSON.stringify(next) === JSON.stringify(selectionRef.current)) return; + if (JSON.stringify(next) === JSON.stringify(selectionRef.current)) { + if (v !== view) { setView(v); syncUrl(next, v); } + return; + } + setView(v); selectionRef.current = next; const version = ++generation.current; selectionRequest.current?.abort(); @@ -132,13 +155,8 @@ export default function LaunchList({ initial, initialHasMore = false, initialSor setLoadingMore(false); setUpdating(true); setLoadError(null); - const p = new URLSearchParams(); - if (s !== "live") p.set("sort", s); - if (w !== "all") p.set("window", w); - if (c) p.set("chain", c); - if (f) p.set("filter", f); - router.replace(p.size ? `/?${p}` : "/", { scroll: false }); - const request = new URLSearchParams(p); + const request = syncUrl(next, v); + request.delete("view"); request.set("sort", s); // the URL omits the default sort, but the API defaults to "new": the request must always carry it request.set("limit", String(PAGE_SIZE)); void fetch(`/api/launch/list?${request}`, { cache: "no-store", signal: controller.signal }) @@ -225,9 +243,9 @@ export default function LaunchList({ initial, initialHasMore = false, initialSor

    Launches

    {live.totals.launches}
    -

    {sort === "live" ? "Tokens with buyers first. Every launch stays in New." : "Every token. Open from the start."}

    +

    {view === "watchlist" ? "The tokens you starred, and what changed since you last looked." : sort === "live" ? "Tokens with buyers first. Every launch stays in New." : "Every token. Open from the start."}

    -
    +
    - {SORTS.map((s) => )} + {SORTS.map((s) => { const on = view === "market" && s.key === sort; return ; })} +
    @@ -245,17 +267,18 @@ export default function LaunchList({ initial, initialHasMore = false, initialSor { const value = values[0]; if (!value) return; const c = value === "all" ? null : (value as ChainKey); const keep = !filter || FILTERS.some((f) => f.key === filter && filterOnChain(f, c)); pick(sort, window_, c, keep ? filter : null); }}> {CHAIN_FILTERS.map((c) => {c.label})} - {showWindow ? { if (values[0]) pick(sort, values[0] as VolumeWindow); }}> + {showWindow && view === "market" ? { if (values[0]) pick(sort, values[0] as VolumeWindow); }}> {WINDOWS.map((w) => {w === "all" ? "All time" : w})} : null} -
    +
    + {view === "watchlist" ? pick(sort, window_, chain, filter, "market")} /> : <>
    {updating || searching ? <>{searching ? "Searching all launches…" : "Updating view…"} : nq ? <>{shown.length} matches : <>{shown.length} shown · {chain ? CHAIN_SHORT[chain] : "all chains"}} {holding ? "Order held while browsing" : "Updates every 5s"} @@ -268,7 +291,11 @@ export default function LaunchList({ initial, initialHasMore = false, initialSor const rank = !nq && sort !== "new" && (!chip || chip.tier === "live") ? i + 1 : undefined; return {i === firstQuiet ?
  2. Quiet launches · no buyers yet. One row per wallet; every launch stays in New.
  3. : null} -
  4. +
  5. + + {/* outside the row's link: saving never navigates */} +
    +
  6. ; })} {shown.length === 0 ?
  7. @@ -282,6 +309,7 @@ export default function LaunchList({ initial, initialHasMore = false, initialSor
    {!nq && hasMore && rows.length < 200 ? :

    {nq ? "Search includes older launches." : rows.length >= 200 && hasMore ? "Showing the first 200. Search or filter to narrow the list." : shown.length ? "You're all caught up." : "One transaction. Zero platform fee."}

    }
    + }
  8. ); } diff --git a/app/src/components/launchpad/LaunchMechanism.tsx b/app/src/components/launchpad/LaunchMechanism.tsx index 132312ee..8476bf3c 100644 --- a/app/src/components/launchpad/LaunchMechanism.tsx +++ b/app/src/components/launchpad/LaunchMechanism.tsx @@ -9,7 +9,7 @@ import { GITLAWB_DECIMALS, GITLAWB_SYMBOL } from "@/lib/launchpad/gitlawb"; import { BRAND_GITHUB } from "@/lib/brand"; import { CHAIN_KEYS, CHAIN_SHORT, chainList } from "@/lib/chainPublic"; -/** Real network totals sit outside the illustrative launch, never inside it. */ +/** The network's real totals, read as one sentence. The full breakdown sits behind a toggle. */ export default function LaunchMechanism() { const { live } = useLive(); const t = live.totals; @@ -20,29 +20,26 @@ export default function LaunchMechanism() { // `live` figures roll as they change (compact); the rest stay exact text. Both carry the same "≈" guard. const usd = (v: number, live = false) => (live ? : `${t.usd_partial ? "≈" : ""}${fmtUsd(v)}`); const gitlawbBurnedExact = `${fmtUnitsExact(t.gitlawb_burned, GITLAWB_DECIMALS)} ${GITLAWB_SYMBOL}`; // every digit of the raw amount, no float - return
    -
    - Open by design. Free by construction. - $0 platform fee -
    -
    - - - -
    + return
    +

    + {count(t.launches)} tokens launched. {count(t.trades)} trades worth {usd(t.volume_usd, true)}. {usd(t.fees_to_creators_usd, true)} in trading fees to the wallets creators chose. $0 to us. +

    - Across {chainList("&", CHAIN_SHORT)}. Show the network breakdown + The numbers across {chainList("&", CHAIN_SHORT)}
    {CHAIN_KEYS.map((k) =>
    {CHAIN_SHORT[k]} launches
    {count(t.by_chain[k]?.launches ?? 0)}
    )}
    All-time trades
    {count(t.trades)}
    +
    All-time volume
    {usd(t.volume_usd)}
    +
    Fees to recipients
    {usd(t.fees_to_creators_usd)}
    Fees burned
    {usd(t.fees_burned_usd)}
    GITLAWB burned
    {fmtQuote(t.gitlawb_burned, GITLAWB_DECIMALS, GITLAWB_SYMBOL)}
    -

    Creators choose a 0%, 1% or 3% trading fee. It goes to their named recipients or is burned. The platform takes none.

    +

    Creators choose a 0%, 1% or 3% trading fee. It goes to their named recipients or is burned. The platform takes none: no fee address in the factory, no platform cut in the locker.

    + Read the contracts
    ; } -function Metric({ label, value, title, accent = false }: { label: string; value: React.ReactNode; title?: string; accent?: boolean }) { - return
    {label}
    {value}
    ; +function Fig({ children, title, accent = false }: { children: React.ReactNode; title?: string; accent?: boolean }) { + return {children}; } diff --git a/app/src/components/launchpad/LaunchRow.tsx b/app/src/components/launchpad/LaunchRow.tsx index bcb12283..4f41e3f0 100644 --- a/app/src/components/launchpad/LaunchRow.tsx +++ b/app/src/components/launchpad/LaunchRow.tsx @@ -20,7 +20,7 @@ const columns = "md:grid-cols-[minmax(0,1fr)_6.5rem_5.5rem_6rem_5rem_2.5rem]"; export function LaunchListHeader({ window }: { window: VolumeWindow }) { return ( -