Skip to content

Commit c51bcd3

Browse files
Vasanthdev2004kevincodex1
authored andcommitted
ui: market board with cards and denser rows
A cards or rows layout kept in a cookie, rows that say why a token is listed, badges that shrink to their mark on phones, and a neutral chip for a flat change.
1 parent 51e694d commit c51bcd3

17 files changed

Lines changed: 409 additions & 112 deletions

‎app/src/app/(home)/page.tsx‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { Metadata } from "next";
22
import Link from "next/link";
3+
import { cookies } from "next/headers";
34
import LaunchHero from "@/components/launchpad/LaunchHero";
45
import LaunchList from "@/components/launchpad/LaunchList";
56
import LiveRiver from "@/components/launchpad/LiveRiver";
@@ -14,6 +15,7 @@ import { dbConfigured } from "@/lib/db";
1415
import { isFilter } from "@/lib/launchpad/search";
1516
import { nowMs } from "@/lib/launchpad/time";
1617
import { inRiverWindow, riverCoverage } from "@/lib/launchpad/river";
18+
import { LAYOUT_COOKIE, parseLayout } from "@/lib/launchpad/list-layout";
1719
import TrendingStrip from "@/components/launchpad/TrendingStrip";
1820

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

4447
return (
4548
<main className="relative mx-auto max-w-6xl space-y-8 px-4 pb-16">
4649
<LaunchHero configured={LAUNCHPAD_CONFIGURED} />
4750
<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} />
4851
<TrendingStrip initial={trending} />
4952
<div className="grid items-start gap-6 xl:grid-cols-[minmax(0,1fr)_17rem]">
50-
<LaunchList initial={page.items} initialHasMore={page.hasMore} initialSort={sort} initialWindow={window} initialChain={chain} initialFilter={filter} initialView={sp.view === "watchlist" ? "watchlist" : "market"} hasDb={dbConfigured()} />
53+
<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} />
5154
<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">
5255
<JustLaunched initial={fresh.items} serverNow={now} />
5356
<div className="min-w-0 space-y-4">

‎app/src/components/launchpad/ChainLanding.tsx‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { Metadata } from "next";
2+
import { cookies } from "next/headers";
23
import Link from "next/link";
34
import { notFound } from "next/navigation";
45
import { ArrowRight, ArrowUpRight } from "lucide-react";
@@ -14,6 +15,8 @@ import { PAGE_SIZE } from "@/lib/launchpad/paging";
1415
import { ethUsd } from "@/lib/launchpad/ethPrice";
1516
import { dbConfigured } from "@/lib/db";
1617
import { pageMetadata } from "@/lib/seo";
18+
import { nowMs } from "@/lib/launchpad/time";
19+
import { LAYOUT_COOKIE, parseLayout } from "@/lib/launchpad/list-layout";
1720

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

4045
return (
@@ -64,7 +69,7 @@ export default async function ChainLanding({ chain }: { chain: ChainKey }) {
6469
<section className={`${shell.anchorSection} mt-14`} aria-labelledby="chain-launches-heading">
6570
<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>
6671
<div className="mt-6">
67-
<LaunchList initial={page.items} initialHasMore={page.hasMore} initialSort="live" initialWindow="all" initialChain={chain} initialFilter={null} hasDb={dbConfigured()} />
72+
<LaunchList initial={page.items} initialHasMore={page.hasMore} initialSort="live" initialWindow="all" initialChain={chain} initialFilter={null} initialLayout={layout} hasDb={dbConfigured()} serverNow={now} />
6873
</div>
6974
</section>
7075

‎app/src/components/launchpad/ChangeChip.tsx‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@ export default function ChangeChip({ v, className = "", plain = false, context =
77
const available = Number.isFinite(pct);
88
const up = pct >= 0;
99
const market = marketChange(v);
10+
// no move (it rounds to 0.0%) is neither up nor down: it reads neutral, never as a green "+0.0%"
11+
const flat = available && market.direction === "flat";
1012
// a multiple (2× and up) reads the same in both styles; smaller moves keep the pill's signed percent
11-
const txt = !available ? "—" : market.label.endsWith("×") ? market.label : `${up ? "+" : ""}${Math.abs(pct) >= 1000 ? fmtCompact(pct, 0) : pct.toFixed(Math.abs(pct) >= 10 ? 0 : 1)}%`;
12-
const pillColor = !available ? "bg-paper text-muted" : up ? "bg-up-soft text-up" : "bg-down-soft text-down-ink";
13+
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)}%`;
14+
const pillColor = !available || flat ? "bg-paper text-muted" : up ? "bg-up-soft text-up" : "bg-down-soft text-down-ink";
1315
return (
1416
<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}`}>
1517
<span className="truncate">{plain ? market.label : txt}</span>

‎app/src/components/launchpad/GitlawbBadge.tsx‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,18 @@ export function GitlawbMark({ size = 16, className = "" }: { size?: number; clas
1010
* Badge for tokens paired with GITLAWB. Shown wherever a token appears (list, trending, feed, token page,
1111
* dashboard, share card) — the visible perk of picking GITLAWB as the quote asset. "sm" is 20px tall so it sits
1212
* inside a text-sm line without changing row heights. Black like the logo tile,
13-
* white text, a light hairline in dark mode so it still reads as a chip on a dark page.
13+
* white text, a light hairline in dark mode so it still reads as a chip on a dark page. `collapse` shows the mark
14+
* alone on phones, keeping the label for screen readers.
1415
*/
15-
export default function GitlawbBadge({ size = "sm", className = "", label = GITLAWB_SYMBOL }: { size?: "sm" | "md"; className?: string; label?: string }) {
16+
export default function GitlawbBadge({ size = "sm", className = "", label = GITLAWB_SYMBOL, collapse = false }: { size?: "sm" | "md"; className?: string; label?: string; collapse?: boolean }) {
1617
return (
1718
<span
18-
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}`}
19+
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}`}
1920
style={{ background: GITLAWB_LOGO_BG }}
2021
title="Paired with GITLAWB: buyers pay in GITLAWB; any trading fee is paid, or burned, in GITLAWB."
2122
>
2223
<GitlawbMark size={size === "md" ? 18 : 16} />
23-
{label}
24+
{collapse ? <span className="max-sm:sr-only">{label}</span> : label}
2425
</span>
2526
);
2627
}
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
"use client";
2+
3+
import Link from "next/link";
4+
import { useState } from "react";
5+
import { Globe } from "lucide-react";
6+
import TokenAvatar from "./TokenAvatar";
7+
import type { LaunchRow as L } from "@/lib/launchpad/queries";
8+
import { ago } from "@/lib/launchpad/time";
9+
import ChangeChip from "./ChangeChip";
10+
import { QuoteBrandBadge } from "./MuseworldBadge";
11+
import UnlistedPairBadge from "./UnlistedPairBadge";
12+
import { capDisplay } from "@/lib/launchpad/market-cap";
13+
import { addressHue } from "@/lib/launchpad/tint";
14+
import type { ChipParts } from "@/lib/launchpad/ranking";
15+
import { MorphAvatar, MorphName } from "./TokenMorph";
16+
import { ChainLogo } from "./ChainLogo";
17+
import { setPendingToken } from "@/lib/launchpad/token-transition";
18+
import { safeSocials } from "@/lib/launchpad/socials";
19+
import { XMark } from "./BrandMarks";
20+
import { MagicCard } from "@/components/vendor/magic-card";
21+
import { BorderBeam } from "@/components/vendor/border-beam";
22+
import type { RowHighlight } from "./LaunchRow";
23+
24+
/**
25+
* The market list as cards: a banner, the token's mark over it, then only what decides a click (name, market cap,
26+
* ticker, change, live activity) and a buys/sells bar along the bottom edge. Volume, holders and fees stay in the
27+
* row view and on the token page. The name is the card's link and stretches over the whole card, so the social
28+
* links on the banner can be links of their own. `preview` draws the same card for the launch form: no link, no
29+
* page transition, no hover lift, and "now" for its age.
30+
*/
31+
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 }) {
32+
// one string: the compact form carries the mark for a quote with no dollar price
33+
const cap = capDisplay(l.fdv_quote, l.quote_usd, { key: l.quote_key, symbol: l.quote_symbol, decimals: l.quote_decimals }).compact;
34+
const trades = l.buys + l.sells;
35+
const { x, site } = safeSocials(l);
36+
const activity = chip ?? (hl?.kind === "new" ? { tier: "new" as const, count: null, label: "just launched", when: null, more: null } : null);
37+
38+
const avatar = <TokenAvatar chain={l.chain} token={l.token} symbol={l.symbol} image={l.image_url} size={56} className="rounded-2xl" />;
39+
const body = (
40+
<article className="group/card relative flex h-full flex-col overflow-hidden rounded-[inherit] bg-card" title={l.description || `${l.name} (${l.symbol})`}>
41+
<div className="relative h-24 shrink-0 overflow-hidden">
42+
<Banner token={l.token} image={l.image_url} banner={l.banner_url} />
43+
{/* the cover melts into the card, so the mark and the name read on one surface */}
44+
<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" />
45+
<div className="absolute left-2 top-2 flex min-w-0 items-center gap-1 pr-10">
46+
{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}
47+
<QuoteBrandBadge quoteKey={l.quote_key} />
48+
{l.quote_key === "other" ? <UnlistedPairBadge symbol={l.quote_symbol} className="shrink-0" /> : null}
49+
</div>
50+
</div>
51+
<div className="flex flex-1 flex-col px-3.5 pb-3.5">
52+
<div className="flex items-start gap-3">
53+
<span className="relative -mt-8 shrink-0 rounded-[19px] bg-card p-[3px] shadow-[0_6px_16px_-8px_rgb(0_0_0/0.45)]">
54+
{preview ? avatar : <MorphAvatar chain={l.chain} token={l.token}>{avatar}</MorphAvatar>}
55+
</span>
56+
<div className="min-w-0 flex-1 space-y-0.5 pt-1.5">
57+
<div className="flex min-w-0 items-center gap-2">
58+
{preview ? <span className="min-w-0 truncate text-[15px] font-semibold tracking-tight text-ink">{l.name}</span> : <Link
59+
href={`/t/${l.chain}/${l.token}`}
60+
// leaves a note for the token page's loading header, so the mark and name morph on the very first frame
61+
onClick={() => setPendingToken({ chain: l.chain, token: l.token, name: l.name, symbol: l.symbol, image: l.image_url })}
62+
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"
63+
>
64+
<MorphName chain={l.chain} token={l.token}><span>{l.name}</span></MorphName>
65+
</Link>}
66+
{/* above the stretched name link, so each opens its own page */}
67+
{x || site ? (
68+
<span className="relative z-[2] ml-auto flex shrink-0 items-center gap-0.5">
69+
{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}
70+
{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}
71+
</span>
72+
) : null}
73+
</div>
74+
<p className="flex min-w-0 items-center gap-1.5 text-[11px] text-muted">
75+
<ChainLogo chain={l.chain} size={13} />
76+
<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>
77+
</p>
78+
</div>
79+
</div>
80+
<div className="mt-3 flex items-center justify-between gap-2">
81+
<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>
82+
<span className="shrink-0"><span className="sr-only">Change since launch </span><ChangeChip v={l.change_from_launch} /></span>
83+
</div>
84+
{activity ? <Activity chip={activity} /> : null}
85+
</div>
86+
{/* buys against sells, flush with the bottom edge; no trades draws an empty track, never an even split */}
87+
<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"}>
88+
{trades ? <><span className="bg-up" style={{ width: `${(l.buys / trades) * 100}%` }} /><span className="flex-1 bg-down" /></> : <span className="flex-1 bg-line" />}
89+
</div>
90+
</article>
91+
);
92+
if (preview) return <div className="h-full rounded-2xl border border-line">{body}</div>;
93+
return (
94+
<MagicCard className="h-full rounded-2xl transition-transform duration-200 hover:-translate-y-1 motion-reduce:transition-none motion-reduce:hover:translate-y-0">
95+
{rank === 1 ? <BorderBeam size={110} duration={9} /> : null}
96+
{body}
97+
</MagicCard>
98+
);
99+
}
100+
101+
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";
102+
103+
/** Why the card sits where it sits: a live dot that pings, the wallet count in bold, and when the last outside trade was. */
104+
function Activity({ chip }: { chip: ChipParts }) {
105+
const live = chip.tier === "live";
106+
return (
107+
<p className="mt-1.5 flex min-w-0 items-center gap-1.5 text-[11px] leading-4" suppressHydrationWarning>
108+
<span aria-hidden="true" className="relative flex size-1.5 shrink-0">
109+
{live ? <span className="absolute inset-0 animate-ping rounded-full bg-up opacity-60 motion-reduce:hidden" /> : null}
110+
<span className={`relative size-1.5 rounded-full ${live ? "bg-up" : chip.tier === "new" ? "bg-ink" : "bg-line-strong"}`} />
111+
</span>
112+
<span className={`truncate ${live ? "text-up" : chip.tier === "new" ? "text-ink" : "text-muted"}`}>
113+
{chip.count !== null ? <b className="font-semibold tabular-nums">{chip.count} </b> : null}
114+
{chip.label}
115+
{chip.when ? <span className="text-muted"> · {chip.when}</span> : null}
116+
{chip.more ? <span className="text-muted"> · {chip.more}</span> : null}
117+
</span>
118+
</p>
119+
);
120+
}
121+
122+
/**
123+
* The creator's banner when they uploaded one. Otherwise the banner is the logo again, blown up and blurred into a
124+
* soft wash of its own colours. No image at all (or a broken one) falls back to the token's address hue, the same
125+
* colour its default mark uses, as a quiet two-tone gradient. The token page's hero wears the same cover, so the card
126+
* you open and the page it opens look alike.
127+
*/
128+
export function Banner({ token, image, banner }: { token: string; image: string | null; banner: string | null }) {
129+
const [failed, setFailed] = useState<string | null>(null);
130+
const own = banner?.trim() || null;
131+
if (own && own !== failed) {
132+
// eslint-disable-next-line @next/next/no-img-element
133+
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" />;
134+
}
135+
const src = image?.trim() || null;
136+
return (
137+
<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%))` }}>
138+
{src && src !== failed ? (
139+
// eslint-disable-next-line @next/next/no-img-element
140+
<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" />
141+
) : null}
142+
<span className="absolute inset-0 bg-black/15" />
143+
</span>
144+
);
145+
}

0 commit comments

Comments
 (0)