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
5 changes: 4 additions & 1 deletion app/src/app/(home)/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import Link from "next/link";
import { cookies } from "next/headers";
import LaunchHero from "@/components/launchpad/LaunchHero";
import LaunchList from "@/components/launchpad/LaunchList";
import LiveRiver from "@/components/launchpad/LiveRiver";
Expand All @@ -14,6 +15,7 @@ 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 { LAYOUT_COOKIE, parseLayout } from "@/lib/launchpad/list-layout";
import TrendingStrip from "@/components/launchpad/TrendingStrip";

export const dynamic = "force-dynamic";
Expand All @@ -40,14 +42,15 @@ export default async function Home({ searchParams }: { searchParams: Promise<{ s
]);
const trending = fetchedTrending ?? trendingFrom(page.items); // the default view's first page doubles as the strip's candidates
const now = nowMs();
const layout = parseLayout((await cookies()).get(LAYOUT_COOKIE)?.value);

return (
<main className="relative mx-auto max-w-6xl space-y-8 px-4 pb-16">
<LaunchHero configured={LAUNCHPAD_CONFIGURED} />
<LiveRiver initial={feed.filter((item) => inRiverWindow(item, now))} serverNow={now} coveredSince={riverCoverage(feed, RIVER_SEED, now)} lastActivityAt={feed[0] ? new Date(feed[0].at).getTime() : 0} />
<TrendingStrip initial={trending} />
<div className="grid items-start gap-6 xl:grid-cols-[minmax(0,1fr)_17rem]">
<LaunchList initial={page.items} initialHasMore={page.hasMore} initialSort={sort} initialWindow={window} initialChain={chain} initialFilter={filter} initialView={sp.view === "watchlist" ? "watchlist" : "market"} hasDb={dbConfigured()} />
<LaunchList initial={page.items} initialHasMore={page.hasMore} initialSort={sort} initialWindow={window} initialChain={chain} initialFilter={filter} initialView={sp.view === "watchlist" ? "watchlist" : "market"} initialLayout={layout} hasDb={dbConfigured()} serverNow={now} />
<aside aria-label="New launches and community" className="grid min-w-0 gap-4 md:grid-cols-2 xl:sticky xl:top-24 xl:grid-cols-1">
<JustLaunched initial={fresh.items} serverNow={now} />
<div className="min-w-0 space-y-4">
Expand Down
7 changes: 6 additions & 1 deletion app/src/components/launchpad/ChainLanding.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Metadata } from "next";
import { cookies } from "next/headers";
import Link from "next/link";
import { notFound } from "next/navigation";
import { ArrowRight, ArrowUpRight } from "lucide-react";
Expand All @@ -14,6 +15,8 @@ import { PAGE_SIZE } from "@/lib/launchpad/paging";
import { ethUsd } from "@/lib/launchpad/ethPrice";
import { dbConfigured } from "@/lib/db";
import { pageMetadata } from "@/lib/seo";
import { nowMs } from "@/lib/launchpad/time";
import { LAYOUT_COOKIE, parseLayout } from "@/lib/launchpad/list-layout";

/** Metadata for /<chain>: its own title, snippet and canonical (never "/", see app/metadata.test.ts). */
export function chainLandingMetadata(chain: ChainKey): Metadata {
Expand All @@ -35,6 +38,8 @@ export default async function ChainLanding({ chain }: { chain: ChainKey }) {
getLaunchTotalsForRequest(usd), // the root layout already ran it this request: shared, not repeated
]);
const launches = totals.by_chain[chain].launches;
const now = nowMs();
const layout = parseLayout((await cookies()).get(LAYOUT_COOKIE)?.value);
const others = CHAIN_KEYS.filter((k) => k !== chain && hasChainPage(k));

return (
Expand Down Expand Up @@ -64,7 +69,7 @@ export default async function ChainLanding({ chain }: { chain: ChainKey }) {
<section className={`${shell.anchorSection} mt-14`} aria-labelledby="chain-launches-heading">
<h2 id="chain-launches-heading" className="font-display text-2xl sm:text-3xl font-bold tracking-tight text-ink">Tokens on {CHAIN_LABELS[chain]}</h2>
<div className="mt-6">
<LaunchList initial={page.items} initialHasMore={page.hasMore} initialSort="live" initialWindow="all" initialChain={chain} initialFilter={null} hasDb={dbConfigured()} />
<LaunchList initial={page.items} initialHasMore={page.hasMore} initialSort="live" initialWindow="all" initialChain={chain} initialFilter={null} initialLayout={layout} hasDb={dbConfigured()} serverNow={now} />
</div>
</section>

Expand Down
6 changes: 4 additions & 2 deletions app/src/components/launchpad/ChangeChip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ export default function ChangeChip({ v, className = "", plain = false, context =
const available = Number.isFinite(pct);
const up = pct >= 0;
const market = marketChange(v);
// no move (it rounds to 0.0%) is neither up nor down: it reads neutral, never as a green "+0.0%"
const flat = available && market.direction === "flat";
// a multiple (2× and up) reads the same in both styles; smaller moves keep the pill's signed percent
const txt = !available ? "—" : market.label.endsWith("×") ? market.label : `${up ? "+" : ""}${Math.abs(pct) >= 1000 ? fmtCompact(pct, 0) : pct.toFixed(Math.abs(pct) >= 10 ? 0 : 1)}%`;
const pillColor = !available ? "bg-paper text-muted" : up ? "bg-up-soft text-up" : "bg-down-soft text-down-ink";
const txt = !available ? "—" : flat || market.label.endsWith("×") ? market.label : `${up ? "+" : ""}${Math.abs(pct) >= 1000 ? fmtCompact(pct, 0) : pct.toFixed(Math.abs(pct) >= 10 ? 0 : 1)}%`;
const pillColor = !available || flat ? "bg-paper text-muted" : up ? "bg-up-soft text-up" : "bg-down-soft text-down-ink";
return (
<span className={`inline-flex max-w-full items-center text-[11px] font-mono font-bold tnum ${plain ? (market.direction === "flat" ? "text-muted" : market.direction === "up" ? "text-up" : "text-down-ink") : `rounded-md px-1.5 h-5 ${pillColor}`} ${className}`} title={`${available ? `${pct}%` : "—"} ${context}`}>
<span className="truncate">{plain ? market.label : txt}</span>
Expand Down
9 changes: 5 additions & 4 deletions app/src/components/launchpad/GitlawbBadge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,18 @@ export function GitlawbMark({ size = 16, className = "" }: { size?: number; clas
* Badge for tokens paired with GITLAWB. Shown wherever a token appears (list, trending, feed, token page,
* dashboard, share card) — the visible perk of picking GITLAWB as the quote asset. "sm" is 20px tall so it sits
* inside a text-sm line without changing row heights. Black like the logo tile,
* white text, a light hairline in dark mode so it still reads as a chip on a dark page.
* white text, a light hairline in dark mode so it still reads as a chip on a dark page. `collapse` shows the mark
* alone on phones, keeping the label for screen readers.
*/
export default function GitlawbBadge({ size = "sm", className = "", label = GITLAWB_SYMBOL }: { size?: "sm" | "md"; className?: string; label?: string }) {
export default function GitlawbBadge({ size = "sm", className = "", label = GITLAWB_SYMBOL, collapse = false }: { size?: "sm" | "md"; className?: string; label?: string; collapse?: boolean }) {
return (
<span
className={`inline-flex shrink-0 items-center rounded-md border border-black/10 font-semibold leading-none whitespace-nowrap text-white dark:border-white/25 ${size === "md" ? "h-6 gap-1.5 pl-[3px] pr-2 text-[11px]" : "h-5 gap-1 pl-[2px] pr-1.5 text-[10px] tracking-wide"} ${className}`}
className={`inline-flex shrink-0 items-center rounded-md border border-black/10 font-semibold leading-none whitespace-nowrap text-white dark:border-white/25 ${size === "md" ? "h-6 gap-1.5 pl-[3px] pr-2 text-[11px]" : "h-5 gap-1 pl-[2px] pr-1.5 text-[10px] tracking-wide"} ${collapse ? "max-sm:pr-[2px]" : ""} ${className}`}
style={{ background: GITLAWB_LOGO_BG }}
title="Paired with GITLAWB: buyers pay in GITLAWB; any trading fee is paid, or burned, in GITLAWB."
>
<GitlawbMark size={size === "md" ? 18 : 16} />
{label}
{collapse ? <span className="max-sm:sr-only">{label}</span> : label}
</span>
);
}
Expand Down
145 changes: 145 additions & 0 deletions app/src/components/launchpad/LaunchCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
"use client";

import Link from "next/link";
import { useState } from "react";
import { Globe } from "lucide-react";
import TokenAvatar from "./TokenAvatar";
import type { LaunchRow as L } from "@/lib/launchpad/queries";
import { ago } from "@/lib/launchpad/time";
import ChangeChip from "./ChangeChip";
import { QuoteBrandBadge } from "./MuseworldBadge";
import UnlistedPairBadge from "./UnlistedPairBadge";
import { capDisplay } from "@/lib/launchpad/market-cap";
import { addressHue } from "@/lib/launchpad/tint";
import type { ChipParts } from "@/lib/launchpad/ranking";
import { MorphAvatar, MorphName } from "./TokenMorph";
import { ChainLogo } from "./ChainLogo";
import { setPendingToken } from "@/lib/launchpad/token-transition";
import { safeSocials } from "@/lib/launchpad/socials";
import { XMark } from "./BrandMarks";
import { MagicCard } from "@/components/vendor/magic-card";
import { BorderBeam } from "@/components/vendor/border-beam";
import type { RowHighlight } from "./LaunchRow";

/**
* The market list as cards: a banner, the token's mark over it, then only what decides a click (name, market cap,
* ticker, change, live activity) and a buys/sells bar along the bottom edge. Volume, holders and fees stay in the
* row view and on the token page. The name is the card's link and stretches over the whole card, so the social
* links on the banner can be links of their own. `preview` draws the same card for the launch form: no link, no
* page transition, no hover lift, and "now" for its age.
*/
export default function LaunchCard({ l, rank, hl = null, now, pop = false, chip = null, preview = false }: { l: L; rank?: number; hl?: RowHighlight; now: number; pop?: boolean; chip?: ChipParts | null; preview?: boolean }) {
// one string: the compact form carries the mark for a quote with no dollar price
const cap = capDisplay(l.fdv_quote, l.quote_usd, { key: l.quote_key, symbol: l.quote_symbol, decimals: l.quote_decimals }).compact;
const trades = l.buys + l.sells;
const { x, site } = safeSocials(l);
const activity = chip ?? (hl?.kind === "new" ? { tier: "new" as const, count: null, label: "just launched", when: null, more: null } : null);

const avatar = <TokenAvatar chain={l.chain} token={l.token} symbol={l.symbol} image={l.image_url} size={56} className="rounded-2xl" />;
const body = (
<article className="group/card relative flex h-full flex-col overflow-hidden rounded-[inherit] bg-card" title={l.description || `${l.name} (${l.symbol})`}>
<div className="relative h-24 shrink-0 overflow-hidden">
<Banner token={l.token} image={l.image_url} banner={l.banner_url} />
{/* the cover melts into the card, so the mark and the name read on one surface */}
<span aria-hidden="true" className="pointer-events-none absolute inset-x-0 bottom-0 h-2/3 bg-linear-to-b from-transparent via-card/45 to-card" />
<div className="absolute left-2 top-2 flex min-w-0 items-center gap-1 pr-10">
{rank !== undefined ? <span className={`grid h-6 min-w-6 shrink-0 place-items-center rounded-full px-1.5 text-xs font-bold tabular-nums ${rank <= 3 ? "bg-white text-black shadow-sm" : "bg-black/55 text-white ring-1 ring-white/15 backdrop-blur-md"}`}><span className="sr-only">Rank </span>{rank}</span> : null}
<QuoteBrandBadge quoteKey={l.quote_key} />
{l.quote_key === "other" ? <UnlistedPairBadge symbol={l.quote_symbol} className="shrink-0" /> : null}
</div>
</div>
<div className="flex flex-1 flex-col px-3.5 pb-3.5">
<div className="flex items-start gap-3">
<span className="relative -mt-8 shrink-0 rounded-[19px] bg-card p-[3px] shadow-[0_6px_16px_-8px_rgb(0_0_0/0.45)]">
{preview ? avatar : <MorphAvatar chain={l.chain} token={l.token}>{avatar}</MorphAvatar>}
</span>
<div className="min-w-0 flex-1 space-y-0.5 pt-1.5">
<div className="flex min-w-0 items-center gap-2">
{preview ? <span className="min-w-0 truncate text-[15px] font-semibold tracking-tight text-ink">{l.name}</span> : <Link
href={`/t/${l.chain}/${l.token}`}
// leaves a note for the token page's loading header, so the mark and name morph on the very first frame
onClick={() => setPendingToken({ chain: l.chain, token: l.token, name: l.name, symbol: l.symbol, image: l.image_url })}
className="min-w-0 truncate text-[15px] font-semibold tracking-tight text-ink outline-none after:absolute after:inset-0 after:z-[1] after:rounded-[inherit] focus-visible:after:ring-2 focus-visible:after:ring-brand"
>
<MorphName chain={l.chain} token={l.token}><span>{l.name}</span></MorphName>
</Link>}
{/* above the stretched name link, so each opens its own page */}
{x || site ? (
<span className="relative z-[2] ml-auto flex shrink-0 items-center gap-0.5">
{x ? <a href={`https://x.com/${x}`} target="_blank" rel="noopener noreferrer nofollow" aria-label={`${l.name} on X (@${x})`} title={`@${x} on X`} className={social}><XMark className="size-3" /></a> : null}
{site ? <a href={site} target="_blank" rel="noopener noreferrer nofollow" aria-label={`${l.name} website`} title={site} className={social}><Globe size={13} aria-hidden="true" /></a> : null}
</span>
) : null}
</div>
<p className="flex min-w-0 items-center gap-1.5 text-[11px] text-muted">
<ChainLogo chain={l.chain} size={13} />
<span className="truncate"><span className="font-mono text-body">{l.symbol}</span><span aria-hidden="true"> · </span>{preview ? "now" : <><span className="sr-only">launched </span><time dateTime={l.block_time} title={new Date(l.block_time).toUTCString()} suppressHydrationWarning>{ago(l.block_time, now)}</time><span className="sr-only"> ago</span></>}</span>
</p>
</div>
</div>
<div className="mt-3 flex items-center justify-between gap-2">
<span key={hl?.at ?? "rest"} className={`min-w-0 truncate font-mono text-lg font-bold tracking-tight text-ink tnum ${pop ? "bb-pop" : ""}`}><span className="sr-only">Market cap </span>{cap}</span>
<span className="shrink-0"><span className="sr-only">Change since launch </span><ChangeChip v={l.change_from_launch} /></span>
</div>
{activity ? <Activity chip={activity} /> : null}
</div>
{/* buys against sells, flush with the bottom edge; no trades draws an empty track, never an even split */}
<div className="mt-auto flex h-[3px] shrink-0 gap-px" title={trades ? `${l.buys.toLocaleString("en-US")} buys, ${l.sells.toLocaleString("en-US")} sells` : "No trades yet"}>
{trades ? <><span className="bg-up" style={{ width: `${(l.buys / trades) * 100}%` }} /><span className="flex-1 bg-down" /></> : <span className="flex-1 bg-line" />}
</div>
</article>
);
if (preview) return <div className="h-full rounded-2xl border border-line">{body}</div>;
return (
<MagicCard className="h-full rounded-2xl transition-transform duration-200 hover:-translate-y-1 motion-reduce:transition-none motion-reduce:hover:translate-y-0">
{rank === 1 ? <BorderBeam size={110} duration={9} /> : null}
{body}
</MagicCard>
);
}

const social = "grid size-7 place-items-center rounded-full text-muted transition-colors hover:bg-ink/[0.06] hover:text-ink motion-reduce:transition-none";

/** Why the card sits where it sits: a live dot that pings, the wallet count in bold, and when the last outside trade was. */
function Activity({ chip }: { chip: ChipParts }) {
const live = chip.tier === "live";
return (
<p className="mt-1.5 flex min-w-0 items-center gap-1.5 text-[11px] leading-4" suppressHydrationWarning>
<span aria-hidden="true" className="relative flex size-1.5 shrink-0">
{live ? <span className="absolute inset-0 animate-ping rounded-full bg-up opacity-60 motion-reduce:hidden" /> : null}
<span className={`relative size-1.5 rounded-full ${live ? "bg-up" : chip.tier === "new" ? "bg-ink" : "bg-line-strong"}`} />
</span>
<span className={`truncate ${live ? "text-up" : chip.tier === "new" ? "text-ink" : "text-muted"}`}>
{chip.count !== null ? <b className="font-semibold tabular-nums">{chip.count} </b> : null}
{chip.label}
{chip.when ? <span className="text-muted"> · {chip.when}</span> : null}
{chip.more ? <span className="text-muted"> · {chip.more}</span> : null}
</span>
</p>
);
}

/**
* The creator's banner when they uploaded one. Otherwise the banner is the logo again, blown up and blurred into a
* soft wash of its own colours. No image at all (or a broken one) falls back to the token's address hue, the same
* colour its default mark uses, as a quiet two-tone gradient. The token page's hero wears the same cover, so the card
* you open and the page it opens look alike.
*/
export function Banner({ token, image, banner }: { token: string; image: string | null; banner: string | null }) {
const [failed, setFailed] = useState<string | null>(null);
const own = banner?.trim() || null;
if (own && own !== failed) {
// eslint-disable-next-line @next/next/no-img-element
return <img src={own} alt="" aria-hidden="true" referrerPolicy="no-referrer" loading="lazy" decoding="async" onError={() => setFailed(own)} className="absolute inset-0 size-full object-cover transition-transform duration-700 ease-out group-hover/card:scale-[1.04] motion-reduce:transition-none" />;
}
const src = image?.trim() || null;
return (
<span aria-hidden="true" className="absolute inset-0 block" style={{ background: `linear-gradient(135deg, hsl(${addressHue(token)} 55% 46%), hsl(${(addressHue(token) + 35) % 360} 50% 30%))` }}>
{src && src !== failed ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={src} alt="" referrerPolicy="no-referrer" loading="lazy" decoding="async" onError={() => setFailed(src)} className="absolute inset-0 size-full scale-[2.2] object-cover blur-2xl saturate-[1.35] transition-transform duration-700 ease-out group-hover/card:scale-[2.4] motion-reduce:transition-none" />
) : null}
<span className="absolute inset-0 bg-black/15" />
</span>
);
}
Loading
Loading