diff --git a/app/src/components/launchpad/LaunchForm.tsx b/app/src/components/launchpad/LaunchForm.tsx index 8c15e96..54d336f 100644 --- a/app/src/components/launchpad/LaunchForm.tsx +++ b/app/src/components/launchpad/LaunchForm.tsx @@ -1,15 +1,23 @@ "use client"; import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; +import { ChartCandlestick, Check, CircleDollarSign, Globe, Rocket } from "lucide-react"; import { useRouter } from "next/navigation"; -import { useAccount, useBalance, useConfig, useReadContract, useSwitchChain } from "wagmi"; +import { useBalance, useConfig, useReadContract, useSwitchChain } from "wagmi"; +import { useHydratedAccount } from "@/lib/useHydratedAccount"; import { getPublicClient, getWalletClient } from "wagmi/actions"; import { maxUint160, maxUint256, parseEventLogs, parseUnits, zeroAddress, type Address, type Hex, type PublicClient, type WalletClient } from "viem"; -import TokenAvatar from "./TokenAvatar"; +import LaunchPreview from "./LaunchPreview"; +import { previewLaunch } from "@/lib/launchpad/launch-preview"; import ImageUpload from "./ImageUpload"; import FeeChip, { feeModeOf } from "./FeeChip"; import LaunchFeeSettings, { type FeeBeneficiary } from "./LaunchFeeSettings"; import { QuoteBrandBadge } from "./MuseworldBadge"; +import { GitlawbMark } from "./GitlawbBadge"; +import { TwigMark } from "./TwigBadge"; +import { ChainLogo } from "./ChainLogo"; +import { XMark } from "./BrandMarks"; +import { ToggleGroup, ToggleGroupItem } from "@/components/vendor/toggle-group"; 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"; @@ -207,7 +215,7 @@ export default function LaunchForm({ ethUsd, gitlawbUsd = null, initialChain = D }; }, [chain, quoteKey, stockQ]); const config = useConfig(); - const { address, isConnected, chainId } = useAccount(); + const { address, isConnected, chainId } = useHydratedAccount(); const [pickerOpen, setPickerOpen] = useState(false); const { switchChainAsync, isPending: switching } = useSwitchChain(); @@ -215,6 +223,7 @@ export default function LaunchForm({ ethUsd, gitlawbUsd = null, initialChain = D const [symbol, setSymbol] = useState(""); const [description, setDescription] = useState(""); const [image, setImage] = useState(""); + const [banner, setBanner] = useState(""); const [website, setWebsite] = useState(""); const [x, setX] = useState(""); const [mcapPick, setMcapPick] = useState(null); @@ -277,17 +286,25 @@ export default function LaunchForm({ ethUsd, gitlawbUsd = null, initialChain = D // "1,000,000 TWIG", not "1000000 TWIG": every digit of what the buy will send, grouped (never rounded, it is what the creator confirms) const initialBuyLabel = initialBuyRaw ? `${fmtUnitsExact(initialBuyRaw, quote.decimals)} ${quote.symbol}` : ""; const errors: string[] = []; - if (name.trim().length === 0 || name.trim().length > 32) errors.push("Name: 1–32 characters."); - if (!/^[A-Z0-9]{1,10}$/.test(symbolClean)) errors.push("Symbol: use 1–10 English letters (A–Z) or digits (0–9)."); + // the token step's checks, named once: the validation list and the step rail both read them + const nameOk = name.trim().length > 0 && name.trim().length <= 32; + const symbolOk = /^[A-Z0-9]{1,10}$/.test(symbolClean); + const httpsOrEmpty = (v: string) => !v || /^https:\/\//.test(v.trim()); + if (!nameOk) errors.push("Name: 1–32 characters."); + if (!symbolOk) errors.push("Symbol: use 1–10 English letters (A–Z) or digits (0–9)."); if (startTick === null) errors.push("Starting market cap must be a positive number."); if (quoteKey === "stock" && !stock) errors.push(STOCK_PICK_MESSAGE); if (isBrandQuote && quote.usd === null && !customMcap.trim() && startTick === null) errors.push(`${quote.symbol} price unavailable right now: enter a custom starting market cap in ${quote.symbol}, or reload.`); - if (image && !/^https:\/\//.test(image.trim())) errors.push("Image must be an https URL."); - if (website && !/^https:\/\//.test(website.trim())) errors.push("Website must be an https URL."); + if (!httpsOrEmpty(image)) errors.push("Image must be an https URL."); + if (!httpsOrEmpty(banner)) errors.push("Banner must be an https URL."); + if (!httpsOrEmpty(website)) errors.push("Website must be an https URL."); const xParsed = parseXHandle(x); if (!xParsed.ok) errors.push("X: enter a handle or an x.com link."); + // the market row this launch will have, for the preview: the same card it gets on the board + const previewRow = previewLaunch({ chain, name, symbol: symbolClean, description, image, banner, website, xHandle: xParsed.ok ? xParsed.handle || null : null, fdvQuote: fdvPreview, quote: { key: quote.key, symbol: quote.symbol, decimals: quote.decimals, usd: quoteUsd }, lpFee: feePips, launcher: address ?? null }); const split = useMemo(() => buildRecipients(rows), [rows]); if (feePips > 0 && beneficiary === "custom") errors.push(...split.errors); + const buyChecksFrom = errors.length; // the first-buy checks run last, so the step rail can tell whether any of them failed if (initialBuyRaw === undefined) errors.push(`First buy: enter an amount in ${quote.symbol}, or leave it empty.`); // the launch is irreversible and the buy comes after it: never let a launch through while the buy's funding is unknown @@ -298,6 +315,14 @@ export default function LaunchForm({ ethUsd, gitlawbUsd = null, initialChain = D if (initialBuyRaw && quote.key !== "eth" && !sharedGas && address && nativeBalance === undefined) errors.push(ethBal.isError ? `First buy: could not read your ${NATIVE_SYMBOL} balance for gas. Retry, or clear the amount.` : `First buy: checking your ${NATIVE_SYMBOL} balance for gas…`); if (initialBuyRaw && quote.key !== "eth" && !sharedGas && nativeBalance !== undefined && nativeBalance < gasReserve) errors.push(`First buy: not enough ${NATIVE_SYMBOL} for gas (the launch, the approval and the buy each need a little ${NATIVE_SYMBOL}).`); const valid = errors.length === 0; + // the rail beside the steps: a check once a step is complete (the first buy is optional, so it is complete unless its amount fails a check) + const done = { + chain: cfg.configured && !(quoteKey === "stock" && !stock), + token: nameOk && symbolOk && httpsOrEmpty(image) && httpsOrEmpty(banner) && httpsOrEmpty(website) && xParsed.ok, + price: startTick !== null, + fees: !(feePips > 0 && beneficiary === "custom" && split.errors.length > 0), + buy: errors.length === buyChecksFrom, + }; const buyPreview = initialBuyRaw && startTick !== null ? initialBuyPreview({ startTick, amountInRaw: initialBuyRaw, lpFeePips: feePips, quoteDecimals: quote.decimals }) : null; const buyUsd = initialBuyRaw && quoteUsd ? units(initialBuyRaw, quote.decimals) * quoteUsd : null; const fmtPct = (p: number) => (p >= 10 ? p.toFixed(0) : p >= 1 ? p.toFixed(1) : p.toFixed(2)) + "%"; @@ -331,7 +356,7 @@ export default function LaunchForm({ ethUsd, gitlawbUsd = null, initialChain = D const res = await fetch("/api/launch/meta", { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ chain, launcher: address, salt: s, meta_key: metaKey, name: name.trim(), symbol: symbolClean, description, image_url: image, website, x_handle: x }), + body: JSON.stringify({ chain, launcher: address, salt: s, meta_key: metaKey, name: name.trim(), symbol: symbolClean, description, image_url: image, banner_url: banner, website, x_handle: x }), }); const j = (await res.json()) as { uri?: string; token?: string; error?: string }; return { status: res.status, ...j }; @@ -428,486 +453,551 @@ export default function LaunchForm({ ethUsd, gitlawbUsd = null, initialChain = D } + // the launch action: what is still missing, the button and the transaction's progress. The sticky console shows it on + // wide screens and the last step shows it on phones; CSS draws one copy at a time. + const action = ( +
+ {quoteKey === "stock" && !stock ? ( +
+ {STOCK_PICK_MESSAGE} +
+ ) : null} + {errors.length > 0 && (name || symbol) ? ( + + ) : null} + setPickerOpen(true)} + onSwitch={() => void switchChainAsync({ chainId: CHAIN.id })} + label={initialBuyRaw ? "Launch + first buy" : "Launch for free, gas only"} + /> + +
+ ); + // what a preset opens at once the price snaps to the pool's tick spacing, in the quote: "$25K" opens at 9.433 ETH + const presetOpens = (v: number) => cap(fdvForStartTick(startTickForFdv(capToQuote(v, entry), quote.decimals), quote.decimals)).detail; + return ( -
+
{ e.preventDefault(); void launch(); }} > - {/* chain + quote */} -
-
-

Chain

- same launch, same rules, on every chain -
-
- {CHAIN_KEYS.map((k) => { - const active = chain === k; - const ok = launchpad(k).configured; - return ( - - ); - })} -
- {/* every chain offers at least one fixed quote plus tokenized stocks; Base also offers TWIG, Robinhood Chain GITLAWB */} - {cfg.quotes.length > 0 ? ( -
- Priced in -
- {[...cfg.quotes.map((q) => ({ key: q.key, label: q.symbol })), ...(STOCK_SOURCE[chain] ? [{ key: "stock" as const, label: "Stock" }] : [])].map((q) => ( - - ))} +
    + {/* 1 · chain + quote */} + +
    +
    +

    Chain

    + same launch, same rules, on every chain
    - - {quote.key === "usdg" || quote.key === "usdc" ? `Buyers pay with ${quote.symbol}; market cap and fees are in dollars.` : isBrandQuote ? `Buyers pay with ${quote.symbol}; fees are paid in ${quote.symbol}, or burned.` : quote.key === "stock" ? (CHAIN_COPY[chain].stock?.pays ?? "") : "Buyers pay with ETH."} - - {brandQuote && quote.key !== brandQuote.key ? ( - - ) : null} -
- ) : null} - {isBrandQuote ? ( -
- - {quote.logo ? ( - // eslint-disable-next-line @next/next/no-img-element - - ) : null} - {quote.symbol} - {quote.name} - {quote.usd ? {fmtUsd(quote.usd)} : price unavailable} - -

- Your token carries the badge on the launch list, trending, the activity feed, its page and its share card.{" "} - {quote.key === "twig" ? ( - <>TWIG is Twigpine's token on Base: a 1:1 wrapper of GITLAWB that anyone can wrap or unwrap any time, with no fee. No owner, no transfer restrictions. Name no beneficiary and the fees are burned as TWIG when collected. Priced at GITLAWB's price, from the Uniswap v4 WETH/GITLAWB pool on Base. - ) : ( - <>GITLAWB is Gitlawb's token{CHAIN_COPY[chain].gitlawbOrigin}: an ordinary ERC-20, no transfer restrictions, no issuer switch. Name no beneficiary and the fees are burned as GITLAWB when collected. Price from the Uniswap v4 WETH/GITLAWB pool on Base. - )}{" "} - {quote.key === "twig" ? "wrap.twigpine.com" : "gitlawb.com"} ↗ -

-
- ) : null} - {quoteKey === "stock" ? ( -
-
- {stock ? ( - - {stock.logo ? ( - // eslint-disable-next-line @next/next/no-img-element - - ) : null} - {stock.symbol} - {CHAIN_COPY[chain].stock?.badge} - {stock.name} - {stock.usd ? {fmtUsd(stock.usd)} : null} +
+ {CHAIN_KEYS.map((k) => { + const active = chain === k; + const ok = launchpad(k).configured; + return ( + ); + })} +
+ {/* every chain offers at least one fixed quote plus tokenized stocks; Base also offers TWIG, Robinhood Chain GITLAWB */} + {cfg.quotes.length > 0 ? ( +
+
+ Priced in + { const next = values[0] as Quote["key"] | undefined; if (!next) return; setQuoteKey(next); setMcapPick(null); setCustomMcap(""); }}> + {[...cfg.quotes.map((q) => ({ key: q.key, label: q.symbol })), ...(STOCK_SOURCE[chain] ? [{ key: "stock" as const, label: "Stock" }] : [])].map((q) => ( + + + {q.label} + + ))} + +
+

+ {/* the picked quote key, not the resolved quote: with Stock chosen and no stock yet, the quote still falls back to the first one */} + {quoteKey === "stock" ? (CHAIN_COPY[chain].stock?.pays ?? "") : quote.key === "usdg" || quote.key === "usdc" ? `Buyers pay with ${quote.symbol}; market cap and fees are in dollars.` : isBrandQuote ? `Buyers pay with ${quote.symbol}; fees are paid in ${quote.symbol}, or burned.` : "Buyers pay with ETH."} +

+ {brandQuote && quote.key !== brandQuote.key ? ( + + ) : null} +
+ ) : null} + {isBrandQuote ? ( +
+ + {quote.logo ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : null} + {quote.symbol} + {quote.name} + {quote.usd ? {fmtUsd(quote.usd)} : price unavailable} - ) : ( - <> - setStockQ(e.target.value)} placeholder="Search ticker, e.g. AAPL" aria-label="search stock tokens" autoComplete="off" /> +

+ Your token carries the badge on the launch list, trending, the activity feed, its page and its share card.{" "} + {quote.key === "twig" ? ( + <>TWIG is Twigpine's token on Base: a 1:1 wrapper of GITLAWB that anyone can wrap or unwrap any time, with no fee. No owner, no transfer restrictions. Name no beneficiary and the fees are burned as TWIG when collected. Priced at GITLAWB's price, from the Uniswap v4 WETH/GITLAWB pool on Base. + ) : ( + <>GITLAWB is Gitlawb's token{CHAIN_COPY[chain].gitlawbOrigin}: an ordinary ERC-20, no transfer restrictions, no issuer switch. Name no beneficiary and the fees are burned as GITLAWB when collected. Price from the Uniswap v4 WETH/GITLAWB pool on Base. + )}{" "} + {quote.key === "twig" ? "wrap.twigpine.com" : "gitlawb.com"} ↗ +

+
+ ) : null} + {quoteKey === "stock" ? ( +
+
+ {stock ? ( + + {stock.logo ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : null} + {stock.symbol} + {CHAIN_COPY[chain].stock?.badge} + {stock.name} + {stock.usd ? {fmtUsd(stock.usd)} : null} + + + ) : ( + <> + setStockQ(e.target.value)} placeholder="Search ticker, e.g. AAPL" aria-label="search stock tokens" autoComplete="off" /> + + + )} +
+ {!stock ? ( +
    + {stockHits.map((h) => ( +
  • + +
  • + ))} + {stockHits.length === 0 ?
  • {CHAIN_COPY[chain].stock?.empty}
  • : null} +
+ ) : null} +

{CHAIN_COPY[chain].stock?.issuer}

+
+ ) : null} +
+ + + {/* 2 · identity */} + +
+

Token

+
+
+
+ +
+ setName(e.target.value)} placeholder="Clear Sky" maxLength={32} autoComplete="off" /> + +
+
+
+ + { + // Rewriting an in-progress IME composition breaks the candidate window: keep it verbatim until it ends. + setSymbol((e.nativeEvent as InputEvent).isComposing ? e.target.value : uppercaseInPlace(e.target)); + }} + onCompositionEnd={(e) => setSymbol(uppercaseInPlace(e.currentTarget))} + onKeyDown={(e) => { + // Some IMEs end composition before the confirming Enter keydown. + if (e.key === "Enter" && (e.nativeEvent.isComposing || e.nativeEvent.keyCode === 229)) e.preventDefault(); + }} + placeholder="SKY" + autoComplete="off" + autoCapitalize="off" + autoCorrect="off" + spellCheck={false} + aria-describedby="symbol-help" + aria-invalid={Boolean(symbol) && !/^[A-Z0-9]{1,10}$/.test(symbolClean)} + /> +
+
+ {/* under both fields, so the symbol's rule reads in one line instead of a narrow column */} +

+ Symbol: 1–10 English letters (A–Z) or digits (0–9), published in uppercase. Your token name can use other languages. +

+
+
+ +