diff --git a/app/src/app/t/[chain]/[token]/page.tsx b/app/src/app/t/[chain]/[token]/page.tsx index cc973142..f1aede6c 100644 --- a/app/src/app/t/[chain]/[token]/page.tsx +++ b/app/src/app/t/[chain]/[token]/page.tsx @@ -32,6 +32,7 @@ import { jsonLdHtml, tokenCanonical } from "@/lib/seo"; import { stockByAddress } from "@/lib/launchpad/stocksServer"; import { BRAND_DOMAIN, BRAND_X } from "@/lib/brand"; import { clampSocial } from "@/lib/launchpad/ogcard"; +import { capDisplay } from "@/lib/launchpad/market-cap"; export const dynamic = "force-dynamic"; @@ -71,9 +72,7 @@ export default async function TokenPage({ params }: { params: Promise<{ chain: s const mode = feeModeOf(l.lp_fee, l.recipients); const poolKey = { currency0: l.quote as Address, currency1: l.token as Address, fee: l.lp_fee, tickSpacing: TICK_SPACING, hooks: NATIVE as Address }; const priceUsd = l.price_usd; - const fdvUsd = l.fdv_usd; const chainLabel = CHAIN_LABELS[chain]; - const fdvQuoteLabel = fmtQuote(BigInt(Math.round(l.fdv_quote * 10 ** quote.decimals)), quote.decimals, quote.symbol); const shareText = `${l.name} ($${l.symbol}) on ${chainLabel}. ${l.lp_fee === 0 ? "0% fee" : mode === "burn" ? "fees burned" : "no platform fee"}, liquidity locked forever`; const supplyLabel = fmtCompact(Number(BigInt(l.supply)) / 1e18, 0); @@ -171,7 +170,7 @@ export default async function TokenPage({ params }: { params: Promise<{ chain: s - + ); } diff --git a/app/src/components/launchpad/LaunchForm.tsx b/app/src/components/launchpad/LaunchForm.tsx index ef366d22..1da9d121 100644 --- a/app/src/components/launchpad/LaunchForm.tsx +++ b/app/src/components/launchpad/LaunchForm.tsx @@ -12,12 +12,12 @@ import GitlawbBadge from "./GitlawbBadge"; import { toast } from "./TxToasts"; import { btn, card, helper, input, label } from "@/components/ui"; import { ERC20_MIN_ABI, ERC20_TRANSFER_EVENT, LAUNCH_FACTORY_ABI, PERMIT2_ABI, UNIVERSAL_ROUTER_ABI, V4_QUOTER_ABI } from "@/lib/launchpad/abi"; -import { BPS, DEFAULT_SUPPLY, FEE_PRESETS, MCAP_PRESETS, TICK_SPACING, launchpad, quoteUsdOf, type Quote } from "@/lib/launchpad/config"; +import { BPS, DEFAULT_SUPPLY, FEE_PRESETS, TICK_SPACING, launchpad, quoteUsdOf, type Quote } from "@/lib/launchpad/config"; +import { capChipLabel, capDisplay, capEntry, capPick, capPresets, capToQuote } from "@/lib/launchpad/market-cap"; import { fdvForStartTick, fmtCompact, fmtQuoteUnits, fmtUsd, initialBuyPreview, minOut, startTickForFdv, tickToTokensPerQuote, units } from "@/lib/launchpad/math"; import { BUY_PRESETS, defaultFirstBuy, suggestFirstBuy } from "@/lib/launchpad/first-buy"; import { encodeV4ExactInSingle, type PoolKey } from "@/lib/launchpad/swap"; -import { stockMcapPresets } from "@/lib/launchpad/stocks"; -import { GITLAWB_SITE, gitlawbMcapPresets } from "@/lib/launchpad/gitlawb"; +import { GITLAWB_SITE } from "@/lib/launchpad/gitlawb"; import { CHAINS, CHAIN_LABELS, CHAIN_KEYS, BUILDER_DATA_SUFFIX, explorerTx, shortAddr, type ChainKey } from "@/lib/chainPublic"; import { friendlyError } from "@/lib/errors"; import { Spinner } from "@/components/Skeleton"; @@ -203,12 +203,17 @@ export default function LaunchForm({ ethUsd, gitlawbUsd = null, initialChain = " const metaKeyRef = useRef(null); const [phase, setPhase] = useState({ k: "idle" }); - const presets = quote.key === "stock" ? stockMcapPresets(quote.usd ?? 0) : quote.key === "gitlawb" ? gitlawbMcapPresets(quote.usd) : MCAP_PRESETS[quote.key]; - const mcap = customMcap.trim() ? Number(customMcap) : (mcapPick ?? presets[2] ?? 0); + // Starting cap: entered in dollars whenever the quote has a USD price, else in quote units (lib/launchpad/market-cap.ts). + // The tick is always computed from the quote-denominated cap; every figure shown leads with dollars. + const entry = capEntry(quoteUsd); + const presets = capPresets(entry, quote.key); + const pickedPreset = capPick(mcapPick, presets); + const mcapEntered = customMcap.trim() ? Number(customMcap) : (pickedPreset ?? 0); + const mcap = mcapEntered > 0 && Number.isFinite(mcapEntered) ? capToQuote(mcapEntered, entry) : 0; const startTick = mcap > 0 && Number.isFinite(mcap) ? startTickForFdv(mcap, quote.decimals) : null; const fdvPreview = startTick !== null ? fdvForStartTick(startTick, quote.decimals) : null; const tokensPerEth = startTick !== null ? tickToTokensPerQuote(startTick, quote.decimals) : null; - const fmtMcap = (v: number) => (quote.decimals <= 6 ? `${v.toLocaleString("en-US", { maximumFractionDigits: 0 })} ${quote.symbol}` : quote.key === "stock" ? `${v.toFixed(3)} ${quote.symbol}` : `${fmtQuoteUnits(v, quote.decimals)} ${quote.symbol}`); + const cap = (v: number) => capDisplay(v, quoteUsd, quote); const onChain = chainId === CHAIN.id; const symbolClean = symbol.trim().toUpperCase(); @@ -358,7 +363,6 @@ export default function LaunchForm({ ethUsd, gitlawbUsd = null, initialChain = " } } - const previewMcapUsd = fdvPreview !== null && quoteUsd ? fmtUsd(fdvPreview * quoteUsd, { compact: true }) : null; return (
@@ -581,7 +585,7 @@ export default function LaunchForm({ ethUsd, gitlawbUsd = null, initialChain = "
{presets.map((v) => { - const active = !customMcap.trim() && (mcapPick ?? presets[2] ?? presets[0]) === v; + const active = !customMcap.trim() && pickedPreset === v; return ( ); })} @@ -603,15 +607,15 @@ export default function LaunchForm({ ethUsd, gitlawbUsd = null, initialChain = " onChange={(e) => setCustomMcap(e.target.value.replace(/[^0-9.]/g, ""))} placeholder="custom" inputMode="decimal" - aria-label={`custom starting market cap in ${quote.symbol}`} + aria-label={`custom starting market cap in ${entry.unit === "usd" ? "USD" : quote.symbol}`} /> - {quote.symbol} + {entry.unit === "usd" ? "USD" : quote.symbol}
{fdvPreview !== null && tokensPerEth !== null ? (

- Opens at {fmtMcap(fdvPreview)} - {previewMcapUsd && quote.key !== "usdg" ? ≈ {previewMcapUsd} : null} fully diluted. The first {quote.key === "gitlawb" ? "1M " : ""}{quote.symbol} buys about{" "} + Opens at {cap(fdvPreview).main} + · {cap(fdvPreview).detail} fully diluted. The first {quote.key === "gitlawb" ? "1M " : ""}{quote.symbol} buys about{" "} {fmtCompact(tokensPerEth * (quote.key === "gitlawb" ? 1e6 : 1), 0)} tokens, then the price climbs along the curve.

) : null} @@ -722,7 +726,7 @@ export default function LaunchForm({ ethUsd, gitlawbUsd = null, initialChain = "

Estimated buy: about {fmtCompact(buyPreview.tokensOut, 0)} {symbolClean || "tokens"}{" "} ({fmtPct(buyPreview.pctOfSupply)} of supply{buyUsd ? ` · ≈ ${fmtUsd(buyUsd)}` : ""}). Estimated market cap after your buy:{" "} - {fmtMcap(buyPreview.fdvAfter)}. Includes price impact and the pool fee; the exact amount is quoted on-chain right before the buy. + {cap(buyPreview.fdvAfter).main} · {cap(buyPreview.fdvAfter).detail}. Includes price impact and the pool fee; the exact amount is quoted on-chain right before the buy.

) : suggestion.reason === "insufficient" ? (

Suggested {fmtQuoteUnits(Number(defaultFirstBuy(quote)), quote.decimals)} {quote.symbol}, but this wallet holds only gas. The launch stays free; you can buy on the token page later.

@@ -787,7 +791,7 @@ export default function LaunchForm({ ethUsd, gitlawbUsd = null, initialChain = " {description.trim() ?

{description.trim()}

: null}
- + f.pips === feePips)?.label ?? "—"} sub={feePips === 0 ? "free pool" : beneficiary === "burn" ? "burned" : "to beneficiary"} /> diff --git a/app/src/components/launchpad/LaunchRow.tsx b/app/src/components/launchpad/LaunchRow.tsx index 99c6e65b..b9afa041 100644 --- a/app/src/components/launchpad/LaunchRow.tsx +++ b/app/src/components/launchpad/LaunchRow.tsx @@ -10,6 +10,7 @@ import { ago } from "@/lib/launchpad/time"; import ChangeChip from "./ChangeChip"; import GitlawbBadge, { isGitlawbQuote } from "./GitlawbBadge"; import { marketUsd } from "@/lib/launchpad/market-format"; +import { capDisplay } from "@/lib/launchpad/market-cap"; import type { LiveTier } from "@/lib/launchpad/ranking"; const columns = "md:grid-cols-[minmax(0,1fr)_6.5rem_5.5rem_6rem_5rem_2.5rem]"; @@ -37,9 +38,9 @@ export default function LaunchRow({ l, rank, window = "all", hl = null, now, pop const volUsd = window === "1h" ? l.volume_1h_usd : window === "24h" ? l.volume_24h_usd : l.volume_usd; const volLabel = volUsd !== null ? marketUsd(volUsd) : fmtQuote(volRaw, l.quote_decimals, l.quote_symbol); const volumeDetail = volUsd !== null ? fmtUsd(volUsd) : volLabel; - const fdvQuoteLabel = fmtQuote(BigInt(Math.round(l.fdv_quote * 10 ** l.quote_decimals)), l.quote_decimals, l.quote_symbol); - const capLabel = l.fdv_usd !== null ? marketUsd(l.fdv_usd) : fdvQuoteLabel; - const capDetail = l.fdv_usd !== null ? fmtUsd(l.fdv_usd) : fdvQuoteLabel; + const cap = capDisplay(l.fdv_quote, l.quote_usd, { key: l.quote_key, symbol: l.quote_symbol, decimals: l.quote_decimals }); // dollars lead; the quote figure is the detail + const capLabel = cap.main; + const capDetail = cap.usd !== null ? `${fmtUsd(cap.usd)} · ${cap.detail}` : `${cap.main} · ${cap.detail}`; const feeLabel = mode === "free" ? "0% trading fee" : `${pipsToPct(l.lp_fee)} fee → ${mode === "burn" ? "burned" : mode === "split" ? "split" : "beneficiary"}`; const flash = hl ? `bb-row-${hl.kind}` : ""; const age = ; @@ -66,8 +67,8 @@ export default function LaunchRow({ l, rank, window = "all", hl = null, now, pop
Market cap {capLabel} - {l.fdv_usd !== null ? fdvQuoteLabel : l.quote_symbol} - Change since launch + {cap.detail} + {cap.detail}Change since launch
Change since launch
Volume {window} {volLabel}
diff --git a/app/src/components/launchpad/MeDashboard.tsx b/app/src/components/launchpad/MeDashboard.tsx index 7fc92602..ce5f96c1 100644 --- a/app/src/components/launchpad/MeDashboard.tsx +++ b/app/src/components/launchpad/MeDashboard.tsx @@ -17,6 +17,7 @@ import { LAUNCH_LOCKER_ABI, ERC20_MIN_ABI } from "@/lib/launchpad/abi"; import { launchpad } from "@/lib/launchpad/config"; import { earnedRaw, feeShareBps, holdingUsd, isBurnOnly } from "@/lib/launchpad/creator"; import { fmtCompact, fmtQuote, fmtUsd } from "@/lib/launchpad/math"; +import { capDisplay } from "@/lib/launchpad/market-cap"; import type { LaunchRow, WalletTrade } from "@/lib/launchpad/queries"; import type { EditFields } from "@/lib/launchpad/editAuth"; import { ago } from "@/lib/launchpad/time"; @@ -249,7 +250,7 @@ function WalletDashboard({ address, isConnected }: { address: Address | undefine
- mc {l.fdv_usd !== null ? fmtUsd(l.fdv_usd, { compact: true }) : "—"} + mc {capDisplay(l.fdv_quote, l.quote_usd, { key: l.quote_key, symbol: l.quote_symbol, decimals: l.quote_decimals }).compact} · {l.buys + l.sells} trades {now ? · {ago(l.block_time, now)} ago : null}
@@ -298,7 +299,7 @@ function WalletDashboard({ address, isConnected }: { address: Address | undefine {isGitlawbQuote(t.quote_key) ? : null}
- {t.my_buys} buys · {t.my_sells} sells · mc {t.fdv_usd !== null ? fmtUsd(t.fdv_usd, { compact: true }) : "—"} + {t.my_buys} buys · {t.my_sells} sells · mc {capDisplay(t.fdv_quote, t.quote_usd, { key: t.quote_key, symbol: t.quote_symbol, decimals: t.quote_decimals }).compact}
diff --git a/app/src/components/launchpad/TrendingStrip.tsx b/app/src/components/launchpad/TrendingStrip.tsx index 31c9d93b..34cc02f9 100644 --- a/app/src/components/launchpad/TrendingStrip.tsx +++ b/app/src/components/launchpad/TrendingStrip.tsx @@ -10,6 +10,7 @@ import { useLive } from "./LiveProvider"; import type { LaunchRow } from "@/lib/launchpad/queries"; import { fmtQuote } from "@/lib/launchpad/math"; import { marketUsd } from "@/lib/launchpad/market-format"; +import { capDisplay } from "@/lib/launchpad/market-cap"; import { CHAIN_SHORT } from "@/lib/chainPublic"; import { stickyKing } from "@/lib/launchpad/ranking"; import { launchKey, refreshInPlace } from "@/lib/launchpad/list-state"; @@ -90,7 +91,7 @@ export default function TrendingStrip({ initial }: { initial: Snap }) {

{row.name}

{row.symbol}

- Market cap {row.fdv_usd !== null ? marketUsd(row.fdv_usd) : "—"} + Market cap {capDisplay(row.fdv_quote, row.quote_usd, { key: row.quote_key, symbol: row.quote_symbol, decimals: row.quote_decimals }).compact}
diff --git a/app/src/components/launchpad/market-cap-sites.test.ts b/app/src/components/launchpad/market-cap-sites.test.ts new file mode 100644 index 00000000..6d3fdd15 --- /dev/null +++ b/app/src/components/launchpad/market-cap-sites.test.ts @@ -0,0 +1,29 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +/** Every market-cap site renders the dollar-first display and never drops the "no USD price" mark (CodeRabbit, PR #27). */ +const read = (p: string) => readFileSync(new URL(p, import.meta.url), "utf8"); + +test("the home row shows the quote detail on every width", () => { + const row = read("./LaunchRow.tsx"); + assert.match(row, /const cap = capDisplay\(l\.fdv_quote, l\.quote_usd/); + assert.equal((row.match(/>\{cap\.detail\}\{cap\.detail\}<\/span>/, "the mobile block carries the detail"); +}); + +test("one-string sites use the compact form, which carries the mark for an unpriced quote", () => { + for (const [file, expected] of [["./TrendingStrip.tsx", 1], ["./MeDashboard.tsx", 2], ["../../app/t/[chain]/[token]/page.tsx", 1]] as const) { + const src = read(file); + assert.equal((src.match(/capDisplay\([^)]*\)\.compact/g) ?? []).length, expected, `${file} uses .compact`); + assert.doesNotMatch(src, /capDisplay\([^)]*\)\.main/, `${file} never renders .main alone`); + } +}); + +test("the launch form renders main + detail for the opening cap, the preview card and the post-buy estimate", () => { + const form = read("./LaunchForm.tsx"); + assert.equal((form.match(/cap\(fdvPreview\)\.main/g) ?? []).length, 2); + assert.equal((form.match(/cap\(fdvPreview\)\.detail/g) ?? []).length, 2); + assert.match(form, /cap\(buyPreview\.fdvAfter\)\.main\}<\/span>]*> · \{cap\(buyPreview\.fdvAfter\)\.detail\}/); + assert.doesNotMatch(form, /fmtMcap\(/, "the old quote-first formatter is gone"); +}); diff --git a/app/src/lib/launchpad/market-cap.test.ts b/app/src/lib/launchpad/market-cap.test.ts new file mode 100644 index 00000000..b378696c --- /dev/null +++ b/app/src/lib/launchpad/market-cap.test.ts @@ -0,0 +1,69 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { MCAP_DEFAULT_INDEX, MCAP_USD_TARGETS, NO_USD_PRICE, capChipLabel, capDisplay, capEntry, capPick, capPresets, capQuoteLabel, capToQuote } from "./market-cap.ts"; +import { fdvForStartTick, startTickForFdv } from "./math.ts"; + +const eth = { key: "eth" as const, symbol: "ETH", decimals: 18 }; +const usdg = { key: "usdg" as const, symbol: "USDG", decimals: 6 }; +const nvda = { key: "stock" as const, symbol: "NVDA", decimals: 18 }; +const gitlawb = { key: "gitlawb" as const, symbol: "GITLAWB", decimals: 18 }; + +test("capEntry: dollars whenever the quote has a positive price, quote units otherwise", () => { + assert.deepEqual(capEntry(2464.49), { unit: "usd", quoteUsd: 2464.49 }); + assert.deepEqual(capEntry(1), { unit: "usd", quoteUsd: 1 }); + assert.deepEqual(capEntry(null), { unit: "quote" }); + assert.deepEqual(capEntry(0), { unit: "quote" }); + assert.deepEqual(capEntry(Number.NaN), { unit: "quote" }); +}); + +test("capPresets: the same four dollar targets for every priced quote; a quote's own units without a price", () => { + const usd = capEntry(2464.49); + for (const k of ["eth", "usdg", "gitlawb", "stock"] as const) assert.deepEqual(capPresets(usd, k), MCAP_USD_TARGETS); + assert.deepEqual(capPresets(capEntry(null), "eth"), [1, 5, 10, 25], "ETH without a price: the old ETH presets"); + assert.deepEqual(capPresets(capEntry(null), "usdg"), [5_000, 10_000, 25_000, 100_000]); + assert.deepEqual(capPresets(capEntry(null), "stock"), [], "a stock without a price has no presets: the form asks for a custom cap"); + assert.deepEqual(capPresets(capEntry(null), "gitlawb"), []); + assert.equal(MCAP_USD_TARGETS[MCAP_DEFAULT_INDEX], 25_000); +}); + +test("capPick: a pick survives only among the presets it was made from; otherwise the default", () => { + assert.equal(capPick(100_000, MCAP_USD_TARGETS), 100_000); + assert.equal(capPick(null, MCAP_USD_TARGETS), 25_000); + assert.equal(capPick(25_000, [1, 5, 10, 25]), 10, "a $25K pick does not become 25,000 ETH after switching to an unpriced quote"); + assert.equal(capPick(10, [1, 5, 10, 25]), 10); + assert.equal(capPick(10, []), null, "no presets, no pick: the form needs a custom cap"); +}); + +test("capToQuote feeds the tick maths: $25K at $2,500/ETH is 10 ETH, and the tick round-trips within one spacing step", () => { + const usd = capEntry(2500); + assert.equal(capToQuote(25_000, usd), 10); + assert.equal(capToQuote(10, capEntry(null)), 10, "quote mode is the identity"); + const tick = startTickForFdv(capToQuote(25_000, usd), 18); + const shown = fdvForStartTick(tick, 18) * 2500; + assert.ok(Math.abs(shown / 25_000 - 1) < 0.02, `the cap the tick actually produces is shown, within one 2% tick step of the target: $${shown.toFixed(0)}`); + const usdgTick = startTickForFdv(capToQuote(10_000, capEntry(1)), 6); + const usdgShown = fdvForStartTick(usdgTick, 6); + assert.ok(Math.abs(usdgShown / 10_000 - 1) < 0.02); +}); + +test("labels: chips in dollars or quote units; quote text per quote kind", () => { + assert.equal(capChipLabel(25_000, capEntry(2500), eth), "$25K"); + assert.equal(capChipLabel(100_000, capEntry(1), usdg), "$100K"); + assert.equal(capChipLabel(10, capEntry(null), eth), "10 ETH"); + assert.equal(capQuoteLabel(10.1234, eth), "10.123 ETH"); + assert.equal(capQuoteLabel(25_000, usdg), "25,000 USDG"); + assert.equal(capQuoteLabel(0.5, usdg), "0.5 USDG", "fractional USDG keeps its cents (CodeRabbit, PR #27)"); + assert.equal(capQuoteLabel(99.99, usdg), "99.99 USDG"); + assert.equal(capQuoteLabel(100.4, usdg), "100 USDG", "from 100 units up, whole units"); + assert.equal(capQuoteLabel(0.11208, nvda), "0.112 NVDA"); + assert.equal(capQuoteLabel(1_250_000, gitlawb), "1.25M GITLAWB"); +}); + +test("capDisplay: dollars lead with the quote figure as detail; without a price the quote figure leads and says so", () => { + assert.deepEqual(capDisplay(10, 2500, eth), { main: "$25K", detail: "10 ETH", compact: "$25K", usd: 25_000 }); + assert.deepEqual(capDisplay(25_000, 1, usdg), { main: "$25K", detail: "25,000 USDG", compact: "$25K", usd: 25_000 }); + assert.deepEqual(capDisplay(0.112, 223.05, nvda), { main: "$24.98", detail: "0.112 NVDA", compact: "$24.98", usd: 0.112 * 223.05 }, "under $1,000 the figure keeps cents"); + assert.deepEqual(capDisplay(10, null, eth), { main: "10 ETH", detail: NO_USD_PRICE, compact: "10 ETH · no USD price", usd: null }, "one-string sites still carry the mark"); + assert.deepEqual(capDisplay(0.5, 0, nvda), { main: "0.500 NVDA", detail: NO_USD_PRICE, compact: "0.500 NVDA · no USD price", usd: null }); + assert.deepEqual(capDisplay(0.5, 1, usdg), { main: "$0.5", detail: "0.5 USDG", compact: "$0.5", usd: 0.5 }); +}); diff --git a/app/src/lib/launchpad/market-cap.ts b/app/src/lib/launchpad/market-cap.ts new file mode 100644 index 00000000..433f9e0d --- /dev/null +++ b/app/src/lib/launchpad/market-cap.ts @@ -0,0 +1,68 @@ +/** + * Market cap is a dollar figure everywhere; the quote-denominated figure is a detail. Pure, node --test loads it directly. + * + * The launch form lets the creator enter the starting cap in dollars whenever the quote has a USD price (ETH, USDG, + * GITLAWB and stocks with a live price) and converts to quote units for the tick maths; when the quote has no price + * the form falls back to quote units, as before. The same four dollar targets apply to every priced quote, so an + * ETH launch no longer opens at "10 ETH" while a USDG launch opens at "$10K". + */ + +import { MCAP_PRESETS, type Quote } from "./config"; +import { fmtCompact, fmtQuoteUnits } from "./math"; +import { marketUsd } from "./market-format"; + +export const MCAP_USD_TARGETS = [5_000, 10_000, 25_000, 100_000]; +export const MCAP_DEFAULT_INDEX = 2; // $25K, about what the old 10 ETH default was + +/** How the form takes the starting cap: in dollars (with the quote's USD price) or in quote units (no price). */ +export type CapEntry = { unit: "usd"; quoteUsd: number } | { unit: "quote" }; + +export function capEntry(quoteUsd: number | null): CapEntry { + return quoteUsd !== null && Number.isFinite(quoteUsd) && quoteUsd > 0 ? { unit: "usd", quoteUsd } : { unit: "quote" }; +} + +/** Preset caps in the entry unit: the dollar targets, or the quote-unit presets a quote has without a price (none for stocks/GITLAWB). */ +export function capPresets(entry: CapEntry, quoteKey: Quote["key"]): number[] { + return entry.unit === "usd" ? MCAP_USD_TARGETS : MCAP_PRESETS[quoteKey]; +} + +/** The preset to use: the creator's pick when it belongs to this quote's presets (a pick does not carry across quotes), else the default. */ +export function capPick(pick: number | null, presets: number[]): number | null { + if (pick !== null && presets.includes(pick)) return pick; + return presets[MCAP_DEFAULT_INDEX] ?? presets[0] ?? null; +} + +/** A cap in the entry unit → quote units, which is what the start tick is computed from. */ +export function capToQuote(v: number, entry: CapEntry): number { + return entry.unit === "usd" ? v / entry.quoteUsd : v; +} + +/** Preset chip text: "$25K" in dollar mode, "10 ETH" in quote mode. */ +export function capChipLabel(v: number, entry: CapEntry, quote: Pick): string { + return entry.unit === "usd" ? `$${fmtCompact(v, 0)}` : `${fmtQuoteUnits(v, quote.decimals)} ${quote.symbol}`; +} + +/** A quote-denominated cap as quote text: "9.87 ETH", "25,000 USDG", "0.112 NVDA". */ +export function capQuoteLabel(fdvQuote: number, quote: Pick): string { + if (quote.decimals <= 6) return `${fdvQuote.toLocaleString("en-US", { maximumFractionDigits: Math.abs(fdvQuote) < 100 ? 2 : 0 })} ${quote.symbol}`; // a 0.5 USDG cap is "0.5 USDG", not "1 USDG" + if (quote.key === "stock") return `${fdvQuote.toFixed(3)} ${quote.symbol}`; + return `${fmtQuoteUnits(fdvQuote, quote.decimals)} ${quote.symbol}`; +} + +export const NO_USD_PRICE = "no USD price"; + +/** + * How a cap is shown anywhere on the site: the dollar figure leads and the quote figure is the detail; without a USD + * price the quote figure leads and the detail says so. Never a bare dash. `compact` is the one-string form for sites + * with room for a single figure: the dollar figure when priced, else the quote figure with the mark, so an unpriced + * cap is never shown as if it were dollars. + */ +export function capDisplay(fdvQuote: number, quoteUsd: number | null, quote: Pick): { main: string; detail: string; compact: string; usd: number | null } { + const quoteText = capQuoteLabel(fdvQuote, quote); + if (quoteUsd !== null && Number.isFinite(quoteUsd) && quoteUsd > 0) { + const usd = fdvQuote * quoteUsd; + const main = marketUsd(usd); + return { main, detail: quoteText, compact: main, usd }; + } + return { main: quoteText, detail: NO_USD_PRICE, compact: `${quoteText} · ${NO_USD_PRICE}`, usd: null }; +}