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
1,002 changes: 561 additions & 441 deletions app/src/components/launchpad/LaunchForm.tsx

Large diffs are not rendered by default.

98 changes: 98 additions & 0 deletions app/src/components/launchpad/LaunchPreview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
"use client";

import { useState, type CSSProperties } from "react";
import { Globe, ImagePlus, LayoutGrid, PanelTop } from "lucide-react";
import LaunchCard from "./LaunchCard";
import TokenAvatar from "./TokenAvatar";
import { ChainLogo } from "./ChainLogo";
import { XMark } from "./BrandMarks";
import { QuoteBrandBadge } from "./MuseworldBadge";
import { ToggleGroup, ToggleGroupItem } from "@/components/vendor/toggle-group";
import { capDisplay } from "@/lib/launchpad/market-cap";
import { safeSocials } from "@/lib/launchpad/socials";
import { fallbackTint } from "@/lib/launchpad/tint";
import { CHAIN_LABELS } from "@/lib/chainPublic";
import type { LaunchRow } from "@/lib/launchpad/queries";
import type { ChipParts } from "@/lib/launchpad/ranking";

const JUST_LAUNCHED: ChipParts = { tier: "new", count: null, label: "just launched", when: null, more: null };

type View = "card" | "page";

/**
* What the launch will look like, from the form as it is being filled: the real market card it gets on the board, or
* the top of its token page. Both views sit in one grid cell, so the panel always has the taller one's height and
* switching never moves the form beside it.
*/
export default function LaunchPreview({ row }: { row: LaunchRow }) {
const [view, setView] = useState<View>("card");
// the hidden view keeps its space but leaves the accessibility tree (visibility: hidden)
const layer = (v: View) => `col-start-1 row-start-1 min-w-0 transition-[opacity,visibility] duration-200 motion-reduce:transition-none ${view === v ? "" : "invisible opacity-0"}`;
return (
<div>
<div className="flex items-center justify-between gap-3">
<p className="text-[11px] font-semibold uppercase tracking-[0.08em] text-muted">Preview</p>
<ToggleGroup aria-label="Preview" value={[view]} onValueChange={(values) => { const next = values[0]; if (next === "card" || next === "page") setView(next); }} className="rounded-[9px] p-0.5">
<ToggleGroupItem value="card" className="min-h-6 rounded-[7px] px-2 text-[11px]"><LayoutGrid size={12} aria-hidden="true" />On the board</ToggleGroupItem>
<ToggleGroupItem value="page" className="min-h-6 rounded-[7px] px-2 text-[11px]"><PanelTop size={12} aria-hidden="true" />Token page</ToggleGroupItem>
</ToggleGroup>
</div>
<div className="mt-3 grid">
<div className={layer("card")}><LaunchCard preview l={row} now={0} chip={JUST_LAUNCHED} /></div>
<div className={layer("page")}><PagePreview row={row} /></div>
</div>
</div>
);
}

/**
* The top of the token page in miniature, laid out like the page: the banner when there is one, the ringed mark and
* name beside the opening market cap, then the description and links.
*/
function PagePreview({ row }: { row: LaunchRow }) {
const cap = capDisplay(row.fdv_quote, row.quote_usd, { key: row.quote_key, symbol: row.quote_symbol, decimals: row.quote_decimals }).compact;
const { x, host } = safeSocials(row);
// the page rings the mark in the logo's colour; with no logo yet that is the generated mark's own hue
const tint = row.image_url ? null : fallbackTint(row.token);
return (
<div className="flex h-full flex-col overflow-hidden rounded-2xl border border-line bg-paper p-3">
{row.banner_url ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={row.banner_url} alt="" referrerPolicy="no-referrer" className="h-12 w-full shrink-0 rounded-xl border border-line object-cover" />
) : (
// the page draws no banner without one; the empty slot only shows where it would go
<p className="flex h-12 shrink-0 items-center justify-center gap-1.5 rounded-xl border border-dashed border-line text-[10px] text-muted"><ImagePlus size={12} aria-hidden="true" />No banner yet</p>
)}
<div className="mt-2.5 flex min-w-0 items-center gap-2.5">
<span
className={`shrink-0 rounded-[13px] border-2 p-[2px] ${tint ? "[--tok:var(--tok-light)] dark:[--tok:var(--tok-dark)]" : "border-line"}`}
style={tint ? ({ borderColor: "var(--tok)", "--tok-light": tint.light, "--tok-dark": tint.dark } as CSSProperties) : undefined}
>
<TokenAvatar chain={row.chain} token={row.token} symbol={row.symbol} image={row.image_url} size={36} className="rounded-[9px]" />
</span>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-1.5">
<p className="min-w-0 truncate font-display text-[15px] font-bold tracking-tight text-ink">{row.name}</p>
<QuoteBrandBadge quoteKey={row.quote_key} />
</div>
<p className="flex min-w-0 items-center gap-1 text-[11px] text-muted">
<span className="max-w-28 truncate font-mono text-body">{row.symbol}</span> on <ChainLogo chain={row.chain} size={12} /><span className="truncate">{CHAIN_LABELS[row.chain]}</span>
</p>
</div>
<div className="shrink-0 text-right">
<p className="text-[10px] text-muted">Opens at</p>
<p className="font-mono text-sm font-bold text-ink tnum">{cap}</p>
</div>
</div>
<p className={`mt-2 line-clamp-2 text-[11px] leading-snug ${row.description ? "text-body" : "text-muted"}`}>{row.description ?? "Add a description so traders know what this is."}</p>
{x || host ? (
<div className="mt-2 flex min-w-0 gap-1.5">
{x ? <span className={chip}><XMark className="size-2.5" />@{x}</span> : null}
{host ? <span className={chip}><Globe size={11} aria-hidden="true" /><span className="truncate">{host}</span></span> : null}
</div>
) : null}
</div>
);
}

const chip = "inline-flex min-w-0 items-center gap-1 rounded-full border border-line px-2 py-0.5 text-[10px] font-medium text-body";
14 changes: 13 additions & 1 deletion app/src/components/launchpad/launch-form.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@ test("launch merge preserves optional funding checks and post-launch buy isolati
});

test("launch merge retains chain-scoped marks and honest optional-buy copy", () => {
assert.match(source, /<TokenAvatar chain=\{chain\}/);
// the preview marks carry the chosen chain: it goes into the preview row, which both preview views draw from
assert.match(source, /previewLaunch\(\{ chain, /);
assert.match(readFileSync(new URL("./LaunchPreview.tsx", import.meta.url), "utf8"), /<TokenAvatar chain=\{row\.chain\}/);
assert.match(source, /label=\{initialBuyRaw \? "Launch \+ first buy" : "Launch for free, gas only"\}/);
// a suggested buy is computed from a known, sufficient balance and can be cleared; a typed amount keeps the strict checks
assert.match(source, /suggestFirstBuy\(\{ quote, connected: Boolean\(address\) && onChain, balance: buyBalance, nativeBalance, balanceFailed: buyBalanceFailed \|\| ethBal\.isError, gasReserve, sharesGasBalance: sharedGas, declined: buyDeclined \|\| Boolean\(typedBuy\)/);
Expand Down Expand Up @@ -166,3 +168,13 @@ test("a rejected first-buy entry is ignored, never read as declining the first b
assert.match(source, /const next = resolveFirstBuyInput\(e\.target\.value\); if \(next\.kind === "choose"\) chooseFirstBuy\(next\.value\); else if \(next\.kind === "decline"\) declineFirstBuy\(\);/);
assert.doesNotMatch(source, /sanitizeDecimalInput\(e\.target\.value\); if \(v\) chooseFirstBuy\(v\); else declineFirstBuy\(\)/);
});

test("the preview draws the real board card from the form, with no link and nothing sent", () => {
assert.match(source, /const previewRow = previewLaunch\(\{ chain, name, symbol: symbolClean,/);
assert.match(source, /<LaunchPreview row=\{previewRow\} \/>/);
const preview = readFileSync(new URL("./LaunchPreview.tsx", import.meta.url), "utf8");
assert.match(preview, /<LaunchCard preview l=\{row\} now=\{0\} chip=\{JUST_LAUNCHED\} \/>/);
// both views share one grid cell, so switching them never changes the panel's height
assert.match(preview, /col-start-1 row-start-1/);
assert.doesNotMatch(preview, /\bfetch\s*\(/);
});
7 changes: 6 additions & 1 deletion app/src/components/launchpad/launch-symbol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,12 @@ test("Symbol guidance is associated with the field and the existing launch rules
assert.match(source, /A.Z.*0.9/);
assert.match(source, /const symbolClean = symbol\.trim\(\)\.toUpperCase\(\)/);
assert.match(source, /symbol: symbolClean/);
assert.match(source, /className="[^"]*truncate"[^>]*>\{symbolClean \|\| "TICKER"\}/, "Overlong input must not overflow the preview");
// the preview draws the board card and a mini token page from the form; both truncate the ticker, and a blank one reads TICKER
const preview = readFileSync(new URL("./LaunchPreview.tsx", import.meta.url), "utf8");
const card = readFileSync(new URL("./LaunchCard.tsx", import.meta.url), "utf8");
assert.match(preview, /<span className="[^"]*truncate[^"]*">\{row\.symbol\}<\/span>/, "Overlong input must not overflow the token page preview");
assert.match(card, /<span className="truncate"><span className="font-mono text-body">\{l\.symbol\}<\/span>/, "Overlong input must not overflow the card preview");
assert.match(readFileSync(new URL("../../lib/launchpad/launch-preview.ts", import.meta.url), "utf8"), /const symbol = p\.symbol \|\| "TICKER";/);
assert.match(source, /className="break-all">\{symbolClean \|\| "tokens"\}/, "The buy estimate must wrap overlong input too");
const base = { chain: "base" as const, launcher: "0x00000000000000000000000000000000000c0ffe", salt: `0x${"a".repeat(64)}`, name: "中文" };
for (const [symbol, valid] of [[" sky9 ", true], ["ABCDEFGHIJ", true], ["ABCDEFGHIJK", false], ["中文", false], ["தமிழ்", false], ["SK Y", false], ["", false]] as const) {
Expand Down
46 changes: 46 additions & 0 deletions app/src/lib/launchpad/banner.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";

/**
* Source contracts for token banners: one optional image beside the logo, stored and validated like it, signed like
* every other edit, and shown on the market cards and the token page. The rules themselves are unit-tested in
* images.test.ts, imageProcess.test.ts, metaShared.test.ts and creator.test.ts.
*/
const read = (file: string) => readFileSync(new URL(file, import.meta.url), "utf8").replaceAll("\r\n", "\n");
const schema = read("../../../db/schema.sql");
const route = read("../../app/api/launch/image/route.ts");
const meta = read("./meta.ts");
const editServer = read("./editServer.ts");
const queries = read("./queries.ts");
const form = read("../../components/launchpad/LaunchForm.tsx");
const sheet = read("../../components/launchpad/EditTokenSheet.tsx");
const card = read("../../components/launchpad/LaunchCard.tsx");
const page = read("../../app/t/[chain]/[token]/page.tsx");

test("the banner is one nullable column, added idempotently", () => {
assert.match(schema, /ALTER TABLE bb_launch_meta ADD COLUMN IF NOT EXISTS banner_url text;/);
});

test("uploads pick the processor and the byte cap from the role; everything else is the logo's path", () => {
assert.match(route, /const role = imageRole\(form\.get\("kind"\)\);/);
assert.match(route, /const pre = checkUpload\(bytes, max\);/);
assert.match(route, /out = role === "banner" \? await toBannerWebp\(bytes\) : await toLogoWebp\(bytes\);/);
assert.match(route, /await putImage\(randomImageKey\(\), out\)/, "a random key in the same store, never a user-chosen name");
});

test("the banner is saved at launch, replaced by signed edits, and read with the market list", () => {
assert.match(meta, /INSERT INTO bb_launch_meta \(chain_id, token, launcher, meta_key, name, symbol, description, image_url, banner_url, website, x_handle\)/);
assert.match(editServer, /banner_url = EXCLUDED\.banner_url/);
assert.match(queries, /m\.image_url, m\.banner_url, m\.website/);
assert.match(queries, /banner_url: canonicalImageUrl\(r\.banner_url, imagePublicBase\(\)\),/);
});

test("the launch form and the edit sheet offer it; the cards and the token page show it", () => {
assert.match(form, /banner_url: banner,/);
assert.match(form, /<ImageUpload kind="banner" value=\{banner\} onChange=\{setBanner\}/);
assert.match(sheet, /<ImageUpload kind="banner" value=\{f\.banner_url \?\? ""\}/);
assert.match(card, /<Banner token=\{l\.token\} image=\{l\.image_url\} banner=\{l\.banner_url\} \/>/);
// the page's hero wears the card's own cover, so a banner shows the same way on both (and falls back the same way)
assert.match(page, /<Banner token=\{l\.token\} image=\{l\.image_url\} banner=\{l\.banner_url\} \/>/);
});
37 changes: 37 additions & 0 deletions app/src/lib/launchpad/launch-preview.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { previewAddress, previewLaunch } from "./launch-preview.ts";

const base = { chain: "base" as const, name: " Sky Whale ", symbol: "WHALE", description: " A gentle giant. ", image: "", banner: "", website: "https://skywhale.xyz", xHandle: "skywhale", fdvQuote: 9.246, quote: { key: "eth" as const, symbol: "ETH", decimals: 18, usd: 2725 }, lpFee: 10_000, launcher: null };

test("the preview row carries what the form knows and starts every market figure empty", () => {
const r = previewLaunch(base);
assert.equal(r.name, "Sky Whale");
assert.equal(r.symbol, "WHALE");
assert.equal(r.description, "A gentle giant.");
assert.equal(r.website, "https://skywhale.xyz");
assert.equal(r.x_handle, "skywhale");
assert.equal(r.fdv_quote, 9.246);
assert.equal(r.fdv_usd, 9.246 * 2725);
assert.equal(r.price_quote, 9.246 / 1e9, "a fixed billion supply");
assert.deepEqual([r.buys, r.sells, r.holders, r.change_from_launch], [0, 0, 0, 0]);
assert.equal(r.live_tier, "new");
});

test("blank fields fall back to placeholders, and only https images, banners and sites are kept", () => {
const r = previewLaunch({ ...base, name: " ", symbol: "", description: "", image: "http://x.y/logo.png", banner: "javascript:alert(1)", website: "ftp://x.y", fdvQuote: null, quote: { ...base.quote, usd: null } });
assert.equal(r.name, "Your token");
assert.equal(r.symbol, "TICKER");
assert.equal(r.description, null);
assert.equal(r.image_url, null);
assert.equal(r.banner_url, null);
assert.equal(r.website, null);
assert.equal(r.fdv_quote, 0);
assert.equal(r.fdv_usd, null, "no dollar figure without a price");
});

test("the stand-in address is stable per ticker and always address-shaped", () => {
assert.equal(previewAddress("WHALE"), previewAddress("WHALE"));
assert.notEqual(previewAddress("WHALE"), previewAddress("DOTTIE"));
for (const s of ["", "A", "ABCDEFGHIJKLMNOPQRSTUVWXYZ"]) assert.match(previewAddress(s), /^0x[0-9a-f]{40}$/);
});
96 changes: 96 additions & 0 deletions app/src/lib/launchpad/launch-preview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import type { LaunchRow } from "./queries";
import type { Quote } from "./config";
import { chainIdOf, type ChainKey } from "../chainPublic.ts";

/** Every launch mints the same fixed supply, all of it into the pool (the launch form's "1,000,000,000 supply"). */
const SUPPLY = 1_000_000_000;

/**
* The market row a launch will have, built from the launch form before anything is sent, so the form can preview the
* real board card. Only what the form knows is filled in; everything the chain decides later (address, pool, trades)
* starts empty. Never stored or sent anywhere.
*/
export function previewLaunch(p: {
chain: ChainKey;
name: string;
symbol: string;
description: string;
image: string;
banner: string;
website: string;
xHandle: string | null;
fdvQuote: number | null;
quote: { key: Quote["key"]; symbol: string; decimals: number; usd: number | null };
lpFee: number;
launcher: string | null;
}): LaunchRow {
const https = (v: string) => (/^https:\/\//.test(v.trim()) ? v.trim() : null);
const fdv = p.fdvQuote !== null && Number.isFinite(p.fdvQuote) ? p.fdvQuote : 0;
const usd = p.quote.usd;
const symbol = p.symbol || "TICKER";
return {
chain: p.chain,
chain_id: chainIdOf(p.chain),
token: previewAddress(symbol),
token_id: 0,
launcher: p.launcher ?? "0x0000000000000000000000000000000000000000",
quote: "",
quote_key: p.quote.key,
quote_symbol: p.quote.symbol,
quote_decimals: p.quote.decimals,
quote_decimals_known: true,
pool_id: "",
start_tick: 0,
lp_fee: p.lpFee,
supply: String(SUPPLY),
metadata_uri: "",
name: p.name.trim() || "Your token",
symbol,
block_number: 0,
block_time: new Date(0).toISOString(),
tx_hash: "",
tick: 0,
sqrt_price_x96: null,
volume_quote: "0",
buys: 0,
sells: 0,
last_trade_at: null,
fees_quote_collected: "0",
fees_token_collected: "0",
fees_quote_burned: "0",
fees_token_burned: "0",
recipients: [],
trades_1h: 0,
traders_1h: 0,
traders_1h_ex: 0,
volume_1h: "0",
trades_24h: 0,
traders_24h_ex: 0,
volume_24h: "0",
last_outside_trade_at: null,
live_tier: "new",
launcher_collapsed: 0,
description: p.description.trim() || null,
image_url: https(p.image),
banner_url: https(p.banner),
holders: 0,
website: https(p.website),
x_handle: p.xHandle,
price_quote: fdv / SUPPLY,
fdv_quote: fdv,
change_from_launch: 0,
quote_usd: usd,
price_usd: usd === null ? null : (fdv / SUPPLY) * usd,
fdv_usd: usd === null ? null : fdv * usd,
volume_usd: usd === null ? null : 0,
volume_1h_usd: usd === null ? null : 0,
volume_24h_usd: usd === null ? null : 0,
};
}

/** A stand-in address made from the ticker, so a logo-less preview takes the same colour each time for the same ticker. */
export function previewAddress(symbol: string): string {
let hex = "";
for (const ch of symbol.slice(0, 20)) hex += ch.charCodeAt(0).toString(16).padStart(2, "0");
return `0x${hex.padEnd(40, "0").slice(0, 40)}`;
}
Loading