From 79d254dae04a2afe67f435842ad2c018403f9821 Mon Sep 17 00:00:00 2001 From: Vasanth T Date: Wed, 14 Oct 2026 18:02:34 +0530 Subject: [PATCH 01/18] feat(i18n): English and Simplified Chinese, starting with the site shell The site can now read in Simplified Chinese (zh-CN). English stays the source text and the fallback: a Chinese message that is missing reads in English, never as a key. - Locale: the NEXT_LOCALE cookie the new language switch sets, else the browser's Accept-Language (any Chinese tag reads Simplified Chinese). The URL is the same in every language, and follows the locale. - Library: use-intl (the core of next-intl) and its 8 small pure-JS dependencies. next-intl itself pulls in @swc/core and @parcel/watcher native binaries for every platform for its extraction tooling, which the site does not need. useTranslations in client components; getTranslations from @/i18n/server in server components. - Messages: one JSON file per area and language under src/i18n/messages, typed from the English files, so a key missing from English fails tsc. messages.test.ts checks every Chinese key exists in English with the same placeholders, that the translated areas are complete, and that no message uses an em dash. - Translated: header and phone menu (navigation, search, bridge, notifications, theme, language, live visits), wallet chip and account menu, the search dialog (Chinese and English queries both find pages and actions), and the footer. - Fonts: Chinese falls back to the platform's CJK face after Geist. - src/i18n/README.md: how to add text, Chinese style, and the glossary. Tests that read English text from component source now check the message key in the source and the text in the English messages. --- app/package-lock.json | 95 +++++++++++++++++++ app/package.json | 1 + app/src/app/globals.css | 7 +- app/src/app/layout.tsx | 8 +- app/src/components/CommandPalette.tsx | 27 +++--- app/src/components/ConnectButton.tsx | 6 +- app/src/components/Footer.tsx | 62 ++++++------ app/src/components/HeaderNav.tsx | 59 +++++++----- app/src/components/IntlProvider.tsx | 17 ++++ app/src/components/LanguageSwitcher.tsx | 67 +++++++++++++ app/src/components/NotificationSettings.tsx | 24 ++--- app/src/components/ThemeToggle.tsx | 8 +- app/src/components/WalletMenu.tsx | 49 +++++----- app/src/components/bridge/BridgeProvider.tsx | 6 +- app/src/components/footer.test.ts | 14 ++- app/src/components/launchpad/LivePulse.tsx | 12 ++- .../launchpad/trading-chart.test.ts | 6 +- .../components/notification-settings.test.ts | 12 ++- app/src/components/sections/about.test.ts | 4 +- app/src/components/wallet-menu.test.ts | 29 ++++-- app/src/i18n/README.md | 63 ++++++++++++ app/src/i18n/locales.ts | 41 ++++++++ app/src/i18n/messages.test.ts | 65 +++++++++++++ app/src/i18n/messages/en/activity.json | 1 + app/src/i18n/messages/en/common.json | 13 +++ app/src/i18n/messages/en/footer.json | 34 +++++++ app/src/i18n/messages/en/header.json | 61 ++++++++++++ app/src/i18n/messages/en/hero.json | 1 + app/src/i18n/messages/en/market.json | 1 + app/src/i18n/messages/en/posts.json | 1 + app/src/i18n/messages/en/search.json | 74 +++++++++++++++ app/src/i18n/messages/en/wallet.json | 33 +++++++ app/src/i18n/messages/index.ts | 88 +++++++++++++++++ app/src/i18n/messages/zh-CN/activity.json | 1 + app/src/i18n/messages/zh-CN/common.json | 13 +++ app/src/i18n/messages/zh-CN/footer.json | 34 +++++++ app/src/i18n/messages/zh-CN/header.json | 61 ++++++++++++ app/src/i18n/messages/zh-CN/hero.json | 1 + app/src/i18n/messages/zh-CN/market.json | 1 + app/src/i18n/messages/zh-CN/posts.json | 1 + app/src/i18n/messages/zh-CN/search.json | 74 +++++++++++++++ app/src/i18n/messages/zh-CN/wallet.json | 33 +++++++ app/src/i18n/server.ts | 21 ++++ app/src/i18n/useAgo.ts | 19 ++++ app/src/lib/command-palette.ts | 44 ++++++--- app/src/lib/launchpad/time.ts | 22 +++-- 46 files changed, 1167 insertions(+), 147 deletions(-) create mode 100644 app/src/components/IntlProvider.tsx create mode 100644 app/src/components/LanguageSwitcher.tsx create mode 100644 app/src/i18n/README.md create mode 100644 app/src/i18n/locales.ts create mode 100644 app/src/i18n/messages.test.ts create mode 100644 app/src/i18n/messages/en/activity.json create mode 100644 app/src/i18n/messages/en/common.json create mode 100644 app/src/i18n/messages/en/footer.json create mode 100644 app/src/i18n/messages/en/header.json create mode 100644 app/src/i18n/messages/en/hero.json create mode 100644 app/src/i18n/messages/en/market.json create mode 100644 app/src/i18n/messages/en/posts.json create mode 100644 app/src/i18n/messages/en/search.json create mode 100644 app/src/i18n/messages/en/wallet.json create mode 100644 app/src/i18n/messages/index.ts create mode 100644 app/src/i18n/messages/zh-CN/activity.json create mode 100644 app/src/i18n/messages/zh-CN/common.json create mode 100644 app/src/i18n/messages/zh-CN/footer.json create mode 100644 app/src/i18n/messages/zh-CN/header.json create mode 100644 app/src/i18n/messages/zh-CN/hero.json create mode 100644 app/src/i18n/messages/zh-CN/market.json create mode 100644 app/src/i18n/messages/zh-CN/posts.json create mode 100644 app/src/i18n/messages/zh-CN/search.json create mode 100644 app/src/i18n/messages/zh-CN/wallet.json create mode 100644 app/src/i18n/server.ts create mode 100644 app/src/i18n/useAgo.ts diff --git a/app/package-lock.json b/app/package-lock.json index 905828d0..891a85db 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -26,6 +26,7 @@ "server-only": "^0.0.1", "sharp": "^0.35.5", "tailwind-merge": "^3.6.0", + "use-intl": "4.14.9", "viem": "^2.47.6", "wagmi": "^3.5.0" }, @@ -1381,6 +1382,27 @@ "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", "license": "MIT" }, + "node_modules/@formatjs/fast-memoize": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-3.1.7.tgz", + "integrity": "sha512-zXfhLpvA6T7+efdt9JLbBwZ00tT7NsBMDVnDu8rpHeNNv8KfRZAMo2gkG0k9lK/Nzc//3kJ9pImsfuJxk3KhUA==", + "license": "MIT" + }, + "node_modules/@formatjs/icu-messageformat-parser": { + "version": "3.5.21", + "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-3.5.21.tgz", + "integrity": "sha512-Vnl7IOSyMRaP4Pjv48etkT2nlcIwzh4QWIa3hewzGlZZPBbft+TbMir4MijpDViOLN/1hvkGC10oYMuilsG+fw==", + "license": "MIT", + "dependencies": { + "@formatjs/icu-skeleton-parser": "2.1.12" + } + }, + "node_modules/@formatjs/icu-skeleton-parser": { + "version": "2.1.12", + "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-2.1.12.tgz", + "integrity": "sha512-ca7tZMLpfHX3sUD7hP6gE/qr6Iq89EDd3ZLeRhbsB7r0UD55DGJq+tmFLzrGrnx5StahUevQmQQVtbDRHsfDHA==", + "license": "MIT" + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -2364,6 +2386,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@schummar/icu-type-parser": { + "version": "1.21.5", + "resolved": "https://registry.npmjs.org/@schummar/icu-type-parser/-/icu-type-parser-1.21.5.tgz", + "integrity": "sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==", + "license": "MIT" + }, "node_modules/@scure/base": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", @@ -5472,6 +5500,21 @@ "hermes-estree": "0.25.1" } }, + "node_modules/icu-minify": { + "version": "4.14.9", + "resolved": "https://registry.npmjs.org/icu-minify/-/icu-minify-4.14.9.tgz", + "integrity": "sha512-PmO+fNnC3K3mfPKTnm3RrPERrhQZnjm1AlpyZQ0ShbXi8Y/kYZ3YOde8TEjCLz9k3P2DmzkBn8lykGrCAi1Ajw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/amannn" + } + ], + "license": "MIT", + "dependencies": { + "@formatjs/icu-messageformat-parser": "^3.4.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -5524,6 +5567,31 @@ "node": ">= 0.4" } }, + "node_modules/intl-messageformat": { + "version": "11.2.15", + "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-11.2.15.tgz", + "integrity": "sha512-W4VNytsXyWgW5lvVchmsb9xgA5rSKqoflu3/IQ9UCtB0z4R+Bx9MoEkwFT/+30RCl/KzUoH0QGGe9lppF5SuvQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@formatjs/fast-memoize": "3.1.7", + "@formatjs/icu-messageformat-parser": "3.5.18" + } + }, + "node_modules/intl-messageformat/node_modules/@formatjs/icu-messageformat-parser": { + "version": "3.5.18", + "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-3.5.18.tgz", + "integrity": "sha512-wX1efcL8d7K5QapOTRsP/OLTA/kpwe4LwqWyNxDNg5iRs8i9jJbCyxB5P8jc4KOW5oiLzz2DJr+BRY5xMV9+bA==", + "license": "MIT", + "dependencies": { + "@formatjs/icu-skeleton-parser": "2.1.11" + } + }, + "node_modules/intl-messageformat/node_modules/@formatjs/icu-skeleton-parser": { + "version": "2.1.11", + "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-2.1.11.tgz", + "integrity": "sha512-j8cUmOJzVgkHuS0QiQ6ga76UIoLOFSAMWhs7aZJztH3aAdCOAE6vpC8KVvFB4cU10ON0y2/5oOVmPJ43s2lTwA==", + "license": "MIT" + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -8262,6 +8330,33 @@ "punycode": "^2.1.0" } }, + "node_modules/use-intl": { + "version": "4.14.9", + "resolved": "https://registry.npmjs.org/use-intl/-/use-intl-4.14.9.tgz", + "integrity": "sha512-37ktvdCCz+6JY2VFPl/GV69Ds4ahiUT2c2xEZZRZYxdgGLxFsUbdxzjQVRm3YMIOUU0VPs6zvlifb4Yjk4IxIg==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/amannn" + } + ], + "license": "MIT", + "dependencies": { + "@formatjs/fast-memoize": "^3.1.0", + "@schummar/icu-type-parser": "1.21.5", + "icu-minify": "^4.14.9", + "intl-messageformat": "^11.1.0" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/use-sync-external-store": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", diff --git a/app/package.json b/app/package.json index 9dee25df..b102f18a 100644 --- a/app/package.json +++ b/app/package.json @@ -30,6 +30,7 @@ "server-only": "^0.0.1", "sharp": "^0.35.5", "tailwind-merge": "^3.6.0", + "use-intl": "4.14.9", "viem": "^2.47.6", "wagmi": "^3.5.0" }, diff --git a/app/src/app/globals.css b/app/src/app/globals.css index 14dbe376..7bb1e703 100644 --- a/app/src/app/globals.css +++ b/app/src/app/globals.css @@ -10,13 +10,14 @@ /* Chart code reads tokens at runtime; retain even tokens with no utility class. */ @theme static { - --font-sans: var(--font-geist), ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + /* Geist carries Latin only; Chinese falls through to the platform's CJK face (html lang picks Simplified glyphs) */ + --font-sans: var(--font-geist), ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif; /* figures: every price, amount and count. The same Geist as the words, with tabular digits (see .font-mono below), the way other launchpads set their numbers; a typewriter face for money read as a terminal, not a market. */ - --font-mono: var(--font-geist), ui-sans-serif, system-ui, sans-serif; + --font-mono: var(--font-geist), ui-sans-serif, system-ui, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif; /* addresses and transaction hashes: a true monospace, so 0x strings line up and read character by character */ --font-code: var(--font-geist-mono), ui-monospace, SFMono-Regular, Menlo, monospace; - --font-display: var(--font-geist), ui-sans-serif, system-ui, sans-serif; + --font-display: var(--font-geist), ui-sans-serif, system-ui, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif; /* surfaces */ --color-paper: #fafaf8; diff --git a/app/src/app/layout.tsx b/app/src/app/layout.tsx index 0571eeb9..c3d22e55 100644 --- a/app/src/app/layout.tsx +++ b/app/src/app/layout.tsx @@ -13,6 +13,9 @@ import { siteJsonLdHtml, siteVerification } from "@/lib/seo"; import Footer from "@/components/Footer"; import RouteProgress from "@/components/RouteProgress"; import ThemeProvider from "@/components/ThemeProvider"; +import IntlProvider from "@/components/IntlProvider"; +import { getLocale } from "@/i18n/server"; +import { messagesFor } from "@/i18n/messages"; import { Suspense } from "react"; import { geist, geistMono } from "./fonts"; @@ -39,14 +42,16 @@ export const viewport: Viewport = { themeColor: "#FAFAF8", colorScheme: "light", export default async function RootLayout({ children }: { children: React.ReactNode }) { // Minted per request in src/proxy.ts; next-themes' inline theme script must carry it to run under the CSP. const nonce = (await headers()).get("x-nonce") ?? undefined; + const locale = await getLocale(); const usd = await ethUsd(); const [feed, totals] = await Promise.all([getLaunchFeed(24, usd).catch(() => []), getLaunchTotalsForRequest(usd)]); // Organization + WebSite + WebApplication: the entity record search engines reconcile the brand word against. const siteJsonLd = siteJsonLdHtml({ siteUrl: SITE_URL, brand: BRAND, domain: BRAND_DOMAIN, description: SITE_DESCRIPTION, sameAs: [`https://x.com/${BRAND_X}`, BRAND_GITHUB] }); return ( - + "), false); }); + +test("the picker reads in the page's language", () => { + const [empty, filled] = renderPreviews([{ value: "" }, { value: "https://images.example/logo.png" }], "zh-CN"); + assert.ok(empty.includes(">上传图片

")); + assert.ok(empty.includes('aria-label="上传代币图片"')); + assert.ok(filled.includes(">更换图片

")); + assert.ok(filled.includes(' { + const read = (file: string) => readFileSync(new URL(file, import.meta.url), "utf8").replaceAll("\r\n", "\n"); + const picker = read("./ImageUpload.tsx"); + // the reasons as the upload route and the image checks it runs write them + const given = ["../../app/api/launch/image/route.ts", "../../lib/launchpad/images.ts", "../../lib/launchpad/imageProcess.ts"].map(read).join("\n"); + const mapped = new Map([...picker.matchAll(/^\s*\["([^"]+)", "(\w+)"\],$/gm)].map((m) => [m[2], m[1]])); + for (const [key, english] of Object.entries(en.launch.upload.reasons)) { + if (key === "tooBig") continue; // the template, read back by pattern below + assert.equal(mapped.get(key), english, `REASONS maps "${english}" to upload.reasons.${key}`); + assert.ok(given.includes(`"${english}"`), `upload.reasons.${key} ("${english}") is not a reason the code gives`); + } + assert.equal(en.launch.upload.reasons.tooBig, "image must be ≤ {mb} MB"); + assert.match(given, /`image must be ≤ \$\{\w+ \/ 1024 \/ 1024\} MB`/); + assert.ok(picker.includes(String.raw`/^image must be ≤ ([\d.]+) MB$/.exec(reason)`)); + // a refused upload is shown through the map; anything else (a network error) reads as it came + assert.match(picker, /throw new Error\(j\.error \? explain\(j\.error\) : t\("failedStatus", \{ status: r\.status \}\)\)/); + assert.equal(en.launch.upload.failedStatus, "upload failed ({status})"); +}); diff --git a/app/src/components/launchpad/launch-fees.test.ts b/app/src/components/launchpad/launch-fees.test.ts index 50519f2a..e5f00a9b 100644 --- a/app/src/components/launchpad/launch-fees.test.ts +++ b/app/src/components/launchpad/launch-fees.test.ts @@ -4,9 +4,12 @@ import test from "node:test"; import { runInNewContext } from "node:vm"; import ts from "typescript"; import { isAddress } from "viem"; +import { createTranslator } from "use-intl/core"; import { BPS, DEAD, FEE_PRESETS, MAX_RECIPIENTS } from "../../lib/launchpad/config.ts"; import { bpsToPct, buildRecipients, isBurnAddress } from "../../lib/launchpad/recipients.ts"; import { shortAddr } from "../../lib/chainPublic.ts"; +import { en, messagesFor } from "../../i18n/messages/index.ts"; +import type { Locale } from "../../i18n/locales.ts"; type Element = { type: string; props: Record; children: unknown[] }; const source = readFileSync(new URL("./LaunchFeeSettings.tsx", import.meta.url), "utf8"); @@ -15,14 +18,17 @@ const ast = ts.createSourceFile("LaunchFeeSettings.tsx", source, ts.ScriptTarget const declaration = ast.statements.find((node) => ts.isFunctionDeclaration(node) && node.name?.text === "LaunchFeeSettings"); assert.ok(declaration); const { outputText } = ts.transpileModule(`(${declaration.getText(ast).replace(/^export default /, "")})`, { compilerOptions: { target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.React } }); -// Exercise the actual JSX and change handlers without a wallet, browser, or transaction. +// Exercise the actual JSX and change handlers without a wallet, browser, or transaction, in either language +// (use-intl's own translator over the real messages stands in for useTranslations). // Native arrow-key navigation and layout are verified in the browser separately. -const render = runInNewContext(outputText, { +const renderIn = (locale: Locale) => runInNewContext(outputText, { React: { createElement: (type: string, props: Record | null, ...children: unknown[]) => ({ type, props: props ?? {}, children }), Fragment: "fragment" }, useId: () => "fees", FEE_PRESETS, MAX_RECIPIENTS, isAddress, shortAddr, bpsToPct, isBurnAddress, card: "card", styles: new Proxy({}, { get: (_target, key) => key }), ArrowUpRight: "ArrowUpRight", Flame: "Flame", LockKeyhole: "LockKeyhole", Wallet: "Wallet", + useTranslations: (namespace: string) => createTranslator({ locale, messages: messagesFor(locale), namespace, timeZone: "UTC" }), }) as (props: Record) => Element; +const render = renderIn("en"); const defaults = { feePips: 0, beneficiary: "burn", split: buildRecipients([{ payout: "", pct: "" }]), children: null, onFeeChange: () => {}, onBeneficiaryChange: () => {} }; function elements(node: unknown): Element[] { @@ -110,10 +116,28 @@ test("custom allocation renders the parent editor and validation, with honest pe assert.ok(!elements(render({ ...defaults, beneficiary: "custom", split, children: editor })).includes(editor)); }); +test("in Chinese the fee card keeps every disclosure the English one makes", () => { + const zh = renderIn("zh-CN"); + const free = text(zh(defaults)); + assert.match(free, /0% 平台费/); + assert.match(free, /不收取任何交易手续费/); + assert.match(free, /仍会产生网络 Gas 和价格影响/); + assert.match(free, /手续费率之后无法更改/); + const paid = text(zh({ ...defaults, feePips: 30_000, beneficiary: "burn" })); + assert.match(paid, /每交易 100 个单位,其中\s+3 个单位是交易手续费/); + assert.match(paid, /手续费率和分配之后都无法更改/); + assert.match(paid, /Openlaunch 不收取任何部分/); + assert.match(paid, /领取时销毁\s+0x…dEaD/); + // in either language the card says the platform takes nothing, and promises nothing more + assert.match(en.launch.fees.routeHelp, /Openlaunch takes none\./); + assert.doesNotMatch(JSON.stringify(messagesFor("zh-CN").launch.fees), /保证|零风险|无风险|最低|最好/); +}); + test("fee redesign preserves split validation, editor controls and the exact launch payload", () => { assert.match(formSource, /if \(feePips > 0 && beneficiary === "custom"\) errors\.push\(\.\.\.split\.errors\)/); assert.match(formSource, /split=\{split\}/); - assert.match(formSource, /aria-label=\{`beneficiary \$\{i \+ 1\} address`\}/); + assert.match(formSource, /aria-label=\{t\("split\.address", \{ n: i \+ 1 \}\)\}/); + assert.equal(en.launch.form.split.address, "beneficiary {n} address"); assert.match(formSource, /onChange=\{\(e\) => setRow\(i, \{ pct: e.target.value.trim\(\) \}\)\}/); assert.match(formSource, /quickAdd\(DEAD\)/); assert.match(formSource, /rows.length >= MAX_RECIPIENTS/); @@ -149,9 +173,10 @@ test("an unconfigured chain says Coming soon in production and explains the miss visit(formAst); assert.ok(declaration?.initializer); const { outputText } = ts.transpileModule(`(${declaration.initializer.getText(formAst)})`, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }); - const copy = (NODE_ENV: string) => runInNewContext(outputText, { process: { env: { NODE_ENV } } }); + // the constant names the message (launch.form.chain.), so either language reads the same rule + const copy = (NODE_ENV: string) => en.launch.form.chain[runInNewContext(outputText, { process: { env: { NODE_ENV } } }) as "comingSoon" | "notConfigured"]; assert.equal(copy("production"), "Coming soon."); assert.equal(copy("development"), "Not configured here. Contract settings are missing in this environment."); assert.equal(copy("test"), "Not configured here. Contract settings are missing in this environment."); - assert.match(formSource, /\{!ok \? UNCONFIGURED_CHAIN_COPY : /, "the chain button renders this copy for an unconfigured chain"); + assert.match(formSource, /\{!ok \? t\(`chain\.\$\{UNCONFIGURED_CHAIN_COPY\}`\) : /, "the chain button renders this copy for an unconfigured chain"); }); diff --git a/app/src/components/launchpad/launch-form.test.ts b/app/src/components/launchpad/launch-form.test.ts index 9acd962d..4cf796ac 100644 --- a/app/src/components/launchpad/launch-form.test.ts +++ b/app/src/components/launchpad/launch-form.test.ts @@ -4,8 +4,10 @@ import test from "node:test"; import { runInNewContext } from "node:vm"; import ts from "typescript"; import { minOut } from "../../lib/launchpad/math.ts"; +import { en, messagesFor } from "../../i18n/messages/index.ts"; const source = readFileSync(new URL("./LaunchForm.tsx", import.meta.url), "utf8"); +const copy = en.launch.form; const ast = ts.createSourceFile("LaunchForm.tsx", source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); test("launch merge preserves optional funding checks and post-launch buy isolation", () => { @@ -25,15 +27,19 @@ test("launch merge preserves optional funding checks and post-launch buy isolati assert.ok(buyCall >= 0, "Optional first buy call must remain"); assert.ok(receiptCheck < buyCall, "The receipt status check must run before the first buy"); assert.match(source, /if \(buyHash\) await fetch\(`\/api\/launch\/sync\?chain=\$\{chain\}&tx=\$\{buyHash\}/); - assert.match(source, /Launched, but the first buy did not go through/); - assert.match(source, /buying: "Launched! Buying your first tokens…"/); + assert.match(source, /title: t\("toast\.buyFailed"\), sub: t\("toast\.buyFailedSub", \{ reason: explain\(buyProblem\) \}\)/); + assert.equal(copy.toast.buyFailed, "Launched, but the first buy did not go through"); + assert.match(source, /buying: t\("buying"\)/); + assert.equal(copy.submit.buying, "Launched! Buying your first tokens…"); }); test("launch merge retains chain-scoped marks and honest optional-buy copy", () => { // 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"), /\{t\("buy\.decline"\)\}<\/button>/); + assert.equal(copy.buy.decline, "No first buy"); + assert.match(source, /

\{t\("buy\.why"\)\}<\/p>/); + assert.match(copy.buy.why, /Clear it and the launch stays free/); // a cleared suggestion is visible and reversible; only the explicit button persists for the session assert.match(source, /suggestion\.reason === "declined" && !typedBuy/); assert.match(source, /onClick=\{suggestAgain\}/); @@ -55,10 +63,11 @@ test("launch merge retains chain-scoped marks and honest optional-buy copy", () assert.match(source, /useSyncExternalStore\(subscribeFirstBuyDeclined, getFirstBuyDeclined, getFirstBuyDeclinedServer\)/); assert.doesNotMatch(source, /sessionStorage/, "the form never touches sessionStorage directly"); assert.match(source, /suggestion\.reason === "unknown-balance"/); - assert.match(source, /Other traders can buy before you/); - assert.match(source, /First-buy slippage tolerance: \{FIRST_BUY_SLIPPAGE_BPS \/ 100\}%/); - assert.match(source, /Network gas and pool fees apply/); - assert.doesNotMatch(source, /first holder/i); + assert.match(source, /t\("buy\.slippage", \{ pct: FIRST_BUY_SLIPPAGE_BPS \/ 100 \}\)/); + assert.match(copy.buy.slippage, /Other traders can buy before you/); + assert.match(copy.buy.slippage, /First-buy slippage tolerance: \{pct\}%/); + assert.match(copy.buy.slippage, /Network gas and pool fees apply/); + assert.doesNotMatch(source + JSON.stringify(en.launch), /first holder/i); // (no em-dash policing: the site copy voice is the maintainers' call) }); @@ -113,29 +122,41 @@ for (const status of ["success", "reverted"]) { test("stock quote: one blocking message, a chip that names the issuer, a way out, and no test hooks in production markup", () => { // the sentence lives once and is shown by the validation list and the submit-card status box - assert.match(source, /const STOCK_PICK_MESSAGE = "Pick a stock to price the token in, or switch the quote\."/); - assert.equal(source.match(/STOCK_PICK_MESSAGE/g)?.length, 3); - assert.match(source, /if \(quoteKey === "stock" && !stock\) errors.push\(STOCK_PICK_MESSAGE\)/); - assert.match(source, /\{quoteKey === "stock" && !stock \? \(\s*

\s*\{STOCK_PICK_MESSAGE\}/); + assert.match(source, /const stockPick = t\("stock\.pick"\);/); + assert.equal(copy.stock.pick, "Pick a stock to price the token in, or switch the quote."); + assert.equal(source.match(/stockPick/g)?.length, 3); + assert.match(source, /if \(quoteKey === "stock" && !stock\) errors.push\(stockPick\)/); + assert.match(source, /\{quoteKey === "stock" && !stock \? \(\s*
\s*\{stockPick\}/); // the only live region is that status box: the search results must not be re-announced on every keystroke assert.doesNotMatch(source, /aria-live/); assert.doesNotMatch(source, /data-testid|\(registry\)|Quote = \{/); // the picked stock chip says whose stock it is and can be cleared; "Switch quote" returns to the first configured quote - assert.match(source, /\{stock\.symbol\}\s*\{CHAIN_COPY\[chain\]\.stock\?\.badge\}<\/span>/); - assert.match(source, /badge: "Coinbase stock"/); - assert.match(source, /badge: "Robinhood stock"/); + assert.match(source, /\{stock\.symbol\}\s*\{stockCopy\?\.badge\}<\/span>/); + assert.equal(copy.stock.coinbase.badge, "Coinbase stock"); + assert.equal(copy.stock.robinhood.badge, "Robinhood stock"); // a chain without a stock registry (Arc) says so in the copy table and gets no Stock quote button at all assert.match(source, /arc: \{[^}]*stock: null,/); - assert.match(source, /\.\.\.\(STOCK_SOURCE\[chain\] \? \[\{ key: "stock" as const, label: "Stock" \}\] : \[\]\)/); - assert.match(source, /aria-label="clear stock quote"/); - assert.match(source, /Switch quote/); + assert.match(source, /\.\.\.\(STOCK_SOURCE\[chain\] \? \[\{ key: "stock" as const, label: t\("quote\.stock"\) \}\] : \[\]\)/); + assert.equal(copy.quote.stock, "Stock"); + assert.match(source, /aria-label=\{t\("stock\.clear"\)\}/); + assert.equal(copy.stock.clear, "clear stock quote"); + assert.match(source, /\{t\("stock\.switchQuote"\)\}/); + assert.equal(copy.stock.switchQuote, "Switch quote"); assert.match(source, /setQuoteKey\(cfg\.quotes\[0\]\?\.key \?\? "eth"\);\s*setStock\(null\);\s*setStockQ\(""\);\s*setMcapPick\(null\);\s*setCustomMcap\(""\);/); // switching chains drops the picked stock: a registry address from one chain must never become the other chain's quote // ...but re-clicking the active chain is a no-op, so it cannot wipe the picked stock assert.match(source, /if \(k === chain\) return;[^}]*setChain\(k\);\s*setQuoteKey\(launchpad\(k\)\.quotes\[0\]\.key\);[^}]*setStock\(null\);\s*setStockQ\(""\);\s*setStockHits\(\[\]\);/); // the issuer disclaimer is rendered from the per-chain copy table (a Record: a new chain must write its own) assert.match(source, /const CHAIN_COPY: Record\{CHAIN_COPY\[chain\]\.stock\?\.issuer\}<\/p>/); + assert.match(source, /const registry = CHAIN_COPY\[chain\]\.stock;/); + assert.match(source, /

\{stockCopy\?\.issuer\}<\/p>/); + // the disclaimers are legal wording: in Chinese they name the same rule, issuer and excluded places as in English + const zh = messagesFor("zh-CN").launch.form.stock; + assert.match(copy.stock.coinbase.issuer, /under Regulation S and are not offered to persons in the US, UK, Canada, Australia, Singapore or Switzerland/); + assert.match(zh.coinbase.issuer, /Regulation S/); + for (const place of ["美国", "英国", "加拿大", "澳大利亚", "新加坡", "瑞士"]) assert.ok(zh.coinbase.issuer.includes(place), place); + assert.match(copy.stock.robinhood.issuer, /not offered to US persons/); + assert.match(zh.robinhood.issuer, /不向美国人士提供/); }); test("stock picker: a one-page list says how many stocks there are, and a stock without a USD price explains the stock-unit cap", () => { @@ -143,27 +164,34 @@ test("stock picker: a one-page list says how many stocks there are, and a stock assert.match(source, /const d = \(await res\.json\(\)\) as \{ stocks: Quote\[\]; stocksTotal\?: number; stocksMatched\?: number \};/); // a reply from a server that does not send the match count yet (mid-deploy) reads as "nothing left out" assert.match(source, /setStockHits\(d\.stocks \?\? \[\]\);\s*setStockTotal\(d\.stocksTotal \?\? 0\);\s*setStockMatched\(d\.stocksMatched \?\? 0\);\s*setStockAnswered\(stockQ\);/); - assert.doesNotMatch(source, /\d+ Coinbase tokenized stocks/); - assert.match(source, /empty: "No match\. Search a ticker or a company name, e\.g\. NVDA, AMD or Netflix\."/); + assert.doesNotMatch(source + JSON.stringify(en.launch), /\d+ Coinbase tokenized stocks/); + assert.equal(copy.stock.coinbase.empty, "No match. Search a ticker or a company name, e.g. NVDA, AMD or Netflix."); // how many the page leaves out: against the registry for an empty search, against the matches for a typed one ("S" matches more than fit). // Counted only when the page answers the text as typed (a reply still on its way describes the old text) and no stock is picked. assert.match(source, /const stockMore = !stock && stockAnswered === stockQ && stockHits\.length > 0 \? Math\.max\(0, \(stockQ\.trim\(\) \? stockMatched : stockTotal\) - stockHits\.length\) : 0;/); - assert.match(source, /\{stockMore > 0 \? \(\s*

\s*\{stockQ\.trim\(\) \? `Showing \$\{stockHits\.length\} of \$\{stockHits\.length \+ stockMore\} matches\. Keep typing to narrow it down\.` : `Showing \$\{stockHits\.length\} of \$\{stockHits\.length \+ stockMore\}\. Search a ticker or a company name for the rest\.`\}\s*<\/p>\s*\) : null\}/); + assert.match(source, /\{stockMore > 0 \? \(\s*

\s*\{stockQ\.trim\(\) \? t\("stock\.showingMatches", \{ shown: stockHits\.length, total: stockHits\.length \+ stockMore \}\) : t\("stock\.showing", \{ shown: stockHits\.length, total: stockHits\.length \+ stockMore \}\)\}\s*<\/p>\s*\) : null\}/); + assert.equal(copy.stock.showingMatches, "Showing {shown} of {total} matches. Keep typing to narrow it down."); + assert.equal(copy.stock.showing, "Showing {shown} of {total}. Search a ticker or a company name for the rest."); // the counts belong to one chain's registry, like the hits: switching chains drops them all assert.match(source, /setStockHits\(\[\]\);\s*setStockTotal\(0\);\s*setStockMatched\(0\);\s*setStockAnswered\(null\);/); // most Coinbase stocks have no price feed: the cap box then takes stock units, and one line says so (never for ETH or a priced stock) - assert.match(source, /\{quote\.key === "stock" && entry\.unit === "quote" \? \(\s*

\s*\{quote\.symbol\} has no USD price here: enter the starting market cap in \{quote\.symbol\}\.\s*<\/p>\s*\) : null\}/); + assert.match(source, /\{quote\.key === "stock" && entry\.unit === "quote" \? \(\s*

\s*\{t\("price\.noUsd", \{ symbol: quote\.symbol \}\)\}\s*<\/p>\s*\) : null\}/); + assert.equal(copy.price.noUsd, "{symbol} has no USD price here: enter the starting market cap in {symbol}."); }); test("stock picker: a stock nobody holds yet is marked in the list, and picking it says why nobody can buy with it yet", () => { // only a confirmed "none issued" (issued === false) is marked; a stock the server could not read (null) or never reads (Robinhood) is not - assert.match(source, /title=\{h\.issued === false \? `\$\{h\.name\}: none issued yet` : h\.name\}/); - assert.match(source, /\{h\.issued === false \? not issued yet<\/span> : null\}/); + assert.match(source, /title=\{h\.issued === false \? t\("stock\.noneIssuedTitle", \{ name: h\.name \?\? h\.symbol \}\) : h\.name\}/); + assert.equal(copy.stock.noneIssuedTitle, "{name}: none issued yet"); + assert.match(source, /\{h\.issued === false \? \{t\("stock\.notIssued"\)\}<\/span> : null\}/); + assert.equal(copy.stock.notIssued, "not issued yet"); // a warning, not a block: the launch works and trading starts once the issuer puts some in circulation - assert.match(source, /\{stock\?\.issued === false \? \(\s*

\s*None of \{stock\.symbol\} has been issued yet, so nobody holds any and nobody can buy your token with it until Coinbase issues some\. You can still launch now\.\s*<\/p>\s*\) : null\}/); + assert.match(source, /\{stock\?\.issued === false \? \(\s*

\s*\{t\("stock\.noneIssued", \{ symbol: stock\.symbol \}\)\}\s*<\/p>\s*\) : null\}/); + assert.equal(copy.stock.noneIssued, "None of {symbol} has been issued yet, so nobody holds any and nobody can buy your token with it until Coinbase issues some. You can still launch now."); assert.doesNotMatch(source, /issued === true|issued !== false|!\w+\.issued\b/, "never read a missing or unknown flag as \"not issued\""); // and no first buy is suggested in it (first-buy.ts): the step says why instead of showing an amount nobody can hold - assert.match(source, /\) : suggestion\.reason === "not-issued" \? \(\s*

Nothing suggested: no \{quote\.symbol\} has been issued yet, so there is none to buy with\. The launch stays free\.<\/p>/); + assert.match(source, /\) : suggestion\.reason === "not-issued" \? \(\s*

\{t\("buy\.notIssued", \{ symbol: quote\.symbol \}\)\}<\/p>/); + assert.equal(copy.buy.notIssued, "Nothing suggested: no {symbol} has been issued yet, so there is none to buy with. The launch stays free."); }); test("beneficiary split: recipients come from the tested helper, and the summary reflects the split", () => { @@ -181,7 +209,8 @@ test("beneficiary split: recipients come from the tested helper, and the summary assert.match(source, /const feeMode = feePips > 0 && beneficiary !== "burn" && recipients\.length === 0 \? \(beneficiary === "custom" && rows\.length > 1 \? "split" : "creator"\) : feeModeOf\(feePips, recipients\);/); assert.match(source, //); assert.match(source, /describeShares\(recipients, shortAddr\)/); - assert.match(source, /split \$\{recipients\.length \|\| rows\.length\} ways/); + assert.match(source, /t\("feeRoute\.split", \{ count: recipients\.length \|\| rows\.length \}\)/); + assert.equal(copy.feeRoute.split, "split {count} ways"); }); test("custom market-cap entry clears the preset pick when sanitization rejects the value", () => { @@ -201,7 +230,12 @@ test("the preview draws the real board card from the form, with no link and noth assert.match(source, /const previewRow = previewLaunch\(\{ chain, name, symbol: symbolClean,/); assert.match(source, //); const preview = readFileSync(new URL("./LaunchPreview.tsx", import.meta.url), "utf8"); - assert.match(preview, //); + assert.match(preview, //); + assert.match(preview, /const justLaunched: ChipParts = \{ tier: "new", count: null, label: t\("justLaunched"\), when: null, more: null \};/); + assert.equal(en.launch.preview.justLaunched, "just launched"); + // a blank name previews as the placeholder in the reader's language; the lib keeps its English one for other callers + assert.match(source, /launcher: address \?\? null, unnamed: t\("unnamed"\) \}\);/); + assert.equal(copy.unnamed, "Your token"); // 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*\(/); @@ -211,3 +245,55 @@ test("a first-buy chip is labelled by the one formatter, so its label never roun assert.match(source, /\{fmtQuoteUnits\(Number\(v\), quote\.decimals\)\} \{quote\.symbol\}<\/span>/); assert.doesNotMatch(source, /fmtCompact\(Number\(v\), 0\)/); }); + +test("the form's words are messages, and the English ones read as the form always did", () => { + // validation names the field and its rule + assert.match(source, /if \(!nameOk\) errors\.push\(t\("token\.nameInvalid"\)\);/); + assert.equal(copy.token.nameInvalid, "Name: 1–32 characters."); + assert.match(source, /if \(!symbolOk\) errors\.push\(t\("token\.symbolInvalid"\)\);/); + assert.equal(copy.token.symbolInvalid, "Symbol: use 1–10 English letters (A–Z) or digits (0–9)."); + assert.equal(copy.token.xInvalid, "X: enter a handle or an x.com link."); + assert.equal(copy.token.xPlaceholder, "@handle or x.com link"); + // the TWIG / GITLAWB nudge carries the quote's badge inside its sentence, so the badge sits where each language puts it + assert.equal(copy.quote.pair, "Pair with {symbol}, get the badge"); + assert.match(source, /t\.rich\(quote\.key === "twig" \? "quote\.brandTwig" : "quote\.brandGitlawb", \{/); + assert.match(copy.quote.brandGitlawb, /^Your token carries the <\/badge> badge on the launch list, trending, the activity feed, its page and its share card\. GITLAWB is Twigpine's token\{bridged, select, yes \{, bridged 1:1 from Base to Robinhood Chain over LayerZero\} other \{\}\}: /); + assert.match(source, /robinhood: \{ stock: "robinhood", bridgedGitlawb: true \}/); + // what the launch does, step by step, and the one-paragraph summary; the buy sentence follows only when there is a buy + assert.equal(copy.launch.summary, "One transaction on {chain}: deploys the token, creates the Uniswap v4 pool ({quote} / your token), locks 100% of the supply in it forever, and registers the fee routing. Cost: gas only, usually a few cents. Nothing is refundable and nothing can be edited afterwards."); + assert.equal(copy.launch.summaryBuy, `${copy.launch.summary} Then a second transaction buys {amount} of your token; if you reject it, the launch still stands.`); + assert.equal(copy.launch.summaryBuyApproval, `${copy.launch.summary} Then a second transaction buys {amount} of your token (with a one-time approval the first time); if you reject it, the launch still stands.`); + assert.match(source, /t\(quote\.key !== "eth" \? "launch\.summaryBuyApproval" : "launch\.summaryBuy", \{ chain: CHAIN_LABEL, quote: quote\.symbol, amount: initialBuyLabel \}\)/); + // the progress line keeps its explorer link around the hash in every language + for (const key of ["txDone", "txSent", "buyQuote", "buyApprove", "buySign", "buySent"] as const) { + assert.match(copy.phase[key], /\{hash\}<\/tx>/, key); + assert.match(messagesFor("zh-CN").launch.form.phase[key], /\{hash\}<\/tx>/, key); + } + assert.match(source, /tx: \(chunks\) => { + const read = (file: string) => readFileSync(new URL(file, import.meta.url), "utf8").replaceAll("\r\n", "\n"); + const form = source.replaceAll("\r\n", "\n"); + // the reasons as the code writes them: friendlyError's, the metadata route's and the form's own throws (not its map) + const given = [read("../../lib/errors.ts"), read("../../app/api/launch/meta/route.ts"), form.replace(/const REASONS = new Map[\s\S]*?\]\);/, "")].join("\n"); + const mapped = new Map([...form.matchAll(/^\s*\["([^"]+)", "(\w+)"\],$/gm)].map((m) => [m[2], m[1]])); + for (const [key, english] of Object.entries(copy.reasons)) { + if (key === "slippage" || key === "reverted") continue; // the two templates, read back by pattern below + assert.equal(mapped.get(key), english, `REASONS maps "${english}" to reasons.${key}`); + assert.ok(given.includes(`"${english}"`), `reasons.${key} ("${english}") is not a reason the code gives`); + } + assert.equal(copy.reasons.slippage, "Price moved more than {pct}%. Try again."); + assert.match(given, /`Price moved more than \$\{slippagePct\}%\. Try again\.`/); + assert.ok(source.includes(String.raw`/^Price moved more than ([\d.]+)%\. Try again\.$/.exec(reason)`)); + assert.equal(copy.reasons.reverted, "Reverted: {name}"); + assert.match(given, /`Reverted: \$\{name\}`/); + assert.ok(source.includes(String.raw`/^Reverted: (\w+)$/.exec(reason)`)); + // both places a reason is shown read it through the map; anything unknown (a wallet's own words) reads as it came + assert.match(source, /\{explain\(phase\.message\)\}/); + assert.match(source, /reason: explain\(buyProblem\)/); + assert.match(source, /return reason;\s*\}, \[t\]\);/); +}); diff --git a/app/src/components/launchpad/launch-machine.test.ts b/app/src/components/launchpad/launch-machine.test.ts index c3ce4e63..6c2b9697 100644 --- a/app/src/components/launchpad/launch-machine.test.ts +++ b/app/src/components/launchpad/launch-machine.test.ts @@ -26,9 +26,14 @@ test("hero separates the permanent position lock from circulating token supply", assert.doesNotMatch(zh.copy, /供应量[^。]*永久|永久[^。]*供应量/); assert.doesNotMatch(JSON.stringify(en.hero), /\bbest\b|safest|guaranteed|audited|risk.free/i); assert.doesNotMatch(JSON.stringify(zh), /最好|最佳|最安全|保证|审计|零风险|无风险/); - assert.match(machine, /liquidity position stays in an ownerless locker forever/i); - assert.match(machine, /Tokens remain tradeable/); + assert.match(machine, /description: t\(`stages\.\$\{key\}\.description`\)/); + assert.match(en.launch.machine.stages.lock.description, /liquidity position stays in an ownerless locker forever/i); + assert.match(en.launch.machine.stages.lock.description, /Tokens remain tradeable/); assert.doesNotMatch(hero + machine, /100% of (?:the )?supply[, ]+forever|supply locked forever/i); + // the machine's words in either language: the lock is the position's, never the supply's + assert.doesNotMatch(JSON.stringify(en.launch.machine), /100% of (?:the )?supply[, ]+forever|supply locked forever/i); + assert.match(messagesFor("zh-CN").launch.machine.stages.lock.description, /流动性仓位永久存放在无所有者的锁仓合约中/); + assert.doesNotMatch(JSON.stringify(messagesFor("zh-CN").launch.machine), /供应量[^。"]*永久|永久[^。"]*供应量/); }); test("hero metrics preserve shared live totals without inventing per-chain dollar sums", () => { @@ -42,7 +47,8 @@ test("hero metrics preserve shared live totals without inventing per-chain dolla // while any quote is unpriced the three dollar figures say "≈" with a tooltip instead of a confident undercount assert.match(metrics, /t\.usd_partial \? "≈" : ""/); for (const field of ["volume_usd", "fees_to_creators_usd", "fees_burned_usd"]) assert.ok(metrics.includes(`usd(t.${field}`), `${field} not routed through the ≈ guard`); - assert.match(metrics, /no USD price right now/); + assert.match(metrics, /const usdNote = t\.usd_partial \? tr\("usdNote"\) : undefined;/); + assert.match(en.launch.totals.usdNote, /no USD price right now/); // the GITLAWB burn: a GITLAWB amount (compact, exact in the title), never a USD figure, no per-chain split, behind the breakdown toggle assert.match(metrics, /fmtQuote\(t\.gitlawb_burned, GITLAWB_DECIMALS, GITLAWB_SYMBOL\)/); assert.match(metrics, /title=\{gitlawbBurnedExact\}/); @@ -51,11 +57,14 @@ test("hero metrics preserve shared live totals without inventing per-chain dolla assert.doesNotMatch(metrics, /by_chain\.\w+\.gitlawb_burned/, "no per-chain split for the GITLAWB burn"); // the breakdown opens in place, under the strip (a disclosure that works without a pointer); the GITLAWB burn stays inside it const details = metrics.indexOf("= 0 && metrics.indexOf(">GITLAWB burned<") > details, "the GITLAWB burn stays behind the breakdown toggle"); - assert.match(metrics, /All-time volume/); - assert.match(metrics, /Fees to recipients/); + assert.ok(details >= 0 && metrics.indexOf('>{tr("gitlawbBurned")}<') > details, "the GITLAWB burn stays behind the breakdown toggle"); + assert.equal(en.launch.totals.gitlawbBurned, "GITLAWB burned"); + assert.match(metrics, /tr\("volume"\)/); + assert.equal(en.launch.totals.volume, "All-time volume"); + assert.match(metrics, /tr\("toRecipients"\)/); + assert.equal(en.launch.totals.toRecipients, "Fees to recipients"); assert.doesNotMatch(metrics, /volume_quote_eth|volume_quote_usdg|\bfetch\s*\(|\bsetInterval\s*\(/); - assert.doesNotMatch(metrics, /\bTVL\b|creator profit|paid out/i); + assert.doesNotMatch(metrics + JSON.stringify(en.launch.totals), /\bTVL\b|creator profit|paid out/i); }); test("launch machine keeps proof links and the header CTA handoff contract", () => { @@ -68,7 +77,8 @@ test("launch machine keeps proof links and the header CTA handoff contract", () test("decorative geometry has readable keyboard-operable mechanism controls", () => { assert.match(machine, /]*aria-hidden="true"[^>]*focusable="false"/); - assert.match(machine, /role="group" aria-label="Explore the launch mechanism"/); + assert.match(machine, /role="group" aria-label=\{t\("explore"\)\}/); + assert.equal(en.launch.machine.explore, "Explore the launch mechanism"); assert.match(machine, /]*type="button"[^>]*aria-pressed=/); assert.match(machine, /aria-controls="launch-stage-detail"/); assert.match(machine, /id="launch-stage-detail"/); @@ -78,8 +88,9 @@ test("decorative geometry has readable keyboard-operable mechanism controls", () test("hero autoplay loops on CSS clocks with a user pause and cleaned-up observers", () => { assert.match(css, /animation: token-cycle[^;]+infinite/); assert.match(css, /animation-play-state: var\(--motion-state\)/); - assert.match(machine, /Pause launch animation/); - assert.match(machine, /Play launch animation/); + assert.match(machine, /paused \|\| inspecting \? t\("play"\) : t\("pause"\)/); + assert.equal(en.launch.machine.pause, "Pause launch animation"); + assert.equal(en.launch.machine.play, "Play launch animation"); assert.doesNotMatch(machine, /\bsetInterval\s*\(|\bsetTimeout\s*\(| { assert.equal(attribute("aria-describedby")?.initializer?.getText(ast), '"symbol-help"'); - assert.match(source, /id="symbol-help"/); - assert.match(source, /A.Z.*0.9/); + assert.match(source, /

\s*\{t\("token\.symbolHelp"\)\}\s*<\/p>/); + // the rule reads the same in either language: A–Z and 0–9, while the name can use any language + assert.match(en.launch.form.token.symbolHelp, /A.Z.*0.9/); + assert.match(messagesFor("zh-CN").launch.form.token.symbolHelp, /A.Z.*0.9/); assert.match(source, /const symbolClean = symbol\.trim\(\)\.toUpperCase\(\)/); assert.match(source, /symbol: symbolClean/); // the preview draws the board card and a mini token page from the form; both truncate the ticker, and a blank one reads TICKER @@ -142,7 +145,10 @@ test("Symbol guidance is associated with the field and the existing launch rules assert.match(preview, /\{row\.symbol\}<\/span>/, "Overlong input must not overflow the token page preview"); assert.match(card, /\{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"); + assert.match(source, /sym: \(chunks\) => \{chunks\}<\/span>/, "The buy estimate must wrap overlong input too"); + assert.match(source, /symbol: symbolClean \|\| t\("buy\.tokens"\)/); + assert.equal(en.launch.form.buy.tokens, "tokens"); + assert.match(en.launch.form.buy.estimate, /\{symbol\}<\/sym>/); 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) { const result = validateMeta({ ...base, symbol }); diff --git a/app/src/i18n/messages/en/launch.json b/app/src/i18n/messages/en/launch.json index 0967ef42..c8c1d467 100644 --- a/app/src/i18n/messages/en/launch.json +++ b/app/src/i18n/messages/en/launch.json @@ -1 +1,421 @@ -{} +{ + "page": { + "title": "Launch a token for free", + "description": "Deploy a token on Base, Robinhood Chain or Arc with 100% of supply locked as Uniswap v4 liquidity. No platform fee. Gas only.", + "heading": "Launch a token", + "intro": "Pick a chain, fill this in, sign once, done. No platform fee. You only pay gas.", + "loading": "loading launch form" + }, + "form": { + "optional": "· optional", + "suggested": "· suggested", + "unnamed": "Your token", + "chain": { + "heading": "Chain", + "note": "same launch, same rules, on every chain", + "comingSoon": "Coming soon.", + "notConfigured": "Not configured here. Contract settings are missing in this environment.", + "blurb": { + "base": "Priced in ETH, TWIG or a Coinbase tokenized stock. Gas ≈ cents.", + "robinhood": "Priced in USDG (dollars), ETH, GITLAWB or a Robinhood Stock Token. Gas ≈ cents.", + "arc": "Priced in USDC (dollars). Gas is paid in USDC too: cents per trade, well under a dollar to launch." + } + }, + "quote": { + "pricedIn": "Priced in", + "label": "Quote asset", + "stock": "Stock", + "paysDollars": "Buyers pay with {symbol}; market cap and fees are in dollars.", + "paysBrand": "Buyers pay with {symbol}; fees are paid in {symbol}, or burned.", + "paysEth": "Buyers pay with ETH.", + "pairTitle": "Pair with {symbol} and your token carries the {symbol} badge everywhere on the site", + "pair": "Pair with {symbol}, get the badge", + "priceUnavailable": "price unavailable", + "brandTwig": "Your token carries the badge on the launch list, trending, the activity feed, its page and its share card. 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. {site} ↗", + "brandGitlawb": "Your token carries the badge on the launch list, trending, the activity feed, its page and its share card. GITLAWB is Twigpine's token{bridged, select, yes {, bridged 1:1 from Base to Robinhood Chain over LayerZero} other {}}: 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. {site} ↗" + }, + "stock": { + "pick": "Pick a stock to price the token in, or switch the quote.", + "clear": "clear stock quote", + "searchPlaceholder": "Search ticker, e.g. AAPL", + "searchLabel": "search stock tokens", + "switchQuote": "Switch quote", + "noneIssuedTitle": "{name}: none issued yet", + "notIssued": "not issued yet", + "noneIssued": "None of {symbol} has been issued yet, so nobody holds any and nobody can buy your token with it until Coinbase issues some. You can still launch now.", + "showingMatches": "Showing {shown} of {total} matches. Keep typing to narrow it down.", + "showing": "Showing {shown} of {total}. Search a ticker or a company name for the rest.", + "coinbase": { + "pays": "Buyers pay with a Coinbase tokenized stock; fees are paid in that stock.", + "badge": "Coinbase stock", + "empty": "No match. Search a ticker or a company name, e.g. NVDA, AMD or Netflix.", + "issuer": "Coinbase tokenized stocks are securities issued by Coinbase under Regulation S and are not offered to persons in the US, UK, Canada, Australia, Singapore or Switzerland. That is Coinbase's rule for the stock token, not ours. The pool itself is ordinary Uniswap v4." + }, + "robinhood": { + "pays": "Buyers pay with a Robinhood Stock Token; fees are paid in that stock.", + "badge": "Robinhood stock", + "empty": "No match. 194 Robinhood Stock Tokens are available, e.g. AAPL, TSLA, NVDA, SPY.", + "issuer": "Robinhood Stock Tokens are tokenised securities issued by Robinhood and are not offered to US persons. That is Robinhood's rule for the stock token, not ours. The pool itself is ordinary Uniswap v4." + } + }, + "token": { + "heading": "Token", + "name": "Name", + "namePlaceholder": "Clear Sky", + "symbol": "Symbol", + "symbolHelp": "Symbol: 1–10 English letters (A–Z) or digits (0–9), published in uppercase. Your token name can use other languages.", + "description": "Description", + "descriptionPlaceholder": "What is this? One or two lines.", + "left": "{count} left", + "artwork": "Artwork", + "artworkHint": "· optional, but tokens with a logo get traded", + "artworkHelp": "The logo marks your token everywhere; the banner runs across the top of its card and page.", + "website": "Website", + "xPlaceholder": "@handle or x.com link", + "nameInvalid": "Name: 1–32 characters.", + "symbolInvalid": "Symbol: use 1–10 English letters (A–Z) or digits (0–9).", + "imageInvalid": "Image must be an https URL.", + "bannerInvalid": "Banner must be an https URL.", + "websiteInvalid": "Website must be an https URL.", + "xInvalid": "X: enter a handle or an x.com link." + }, + "price": { + "heading": "Starting market cap", + "supply": "1,000,000,000 supply · all of it in the pool", + "custom": "custom", + "customLabel": "custom starting market cap in {unit}", + "noUsd": "{symbol} has no USD price here: enter the starting market cap in {symbol}.", + "opensAt": "Opens at {cap} · {detail} fully diluted. The first {symbol} buys about {tokens} tokens, then the price climbs along the curve.", + "opensAtMillion": "Opens at {cap} · {detail} fully diluted. The first 1M {symbol} buys about {tokens} tokens, then the price climbs along the curve.", + "capInvalid": "Starting market cap must be a positive number.", + "brandUnpriced": "{symbol} price unavailable right now: enter a custom starting market cap in {symbol}, or reload." + }, + "split": { + "address": "beneficiary {n} address", + "burned": "burned", + "share": "beneficiary {n} share, percent", + "remove": "remove beneficiary {n}", + "add": "+ Add address", + "addMe": "+ Me ({address})", + "addBurn": "+ Burn a share", + "complete": "Shares add up to 100%", + "left": "{pct}% left to assign", + "over": "{pct}% over", + "help": "Shares in percent, up to two decimals, must total exactly 100%. A row with 0x…dEaD burns that share." + }, + "buy": { + "heading": "First buy", + "note": "a second transaction, right after the launch confirms", + "placeholder": "none", + "amountLabel": "first buy amount in {symbol}", + "decline": "No first buy", + "balance": "balance {amount}", + "estimateLabel": "Estimated buy", + "estimate": "about {amount} {symbol} ({share} of supply)", + "estimateUsd": "about {amount} {symbol} ({share} of supply · ≈ {usd})", + "tokens": "tokens", + "capAfterLabel": "Estimated market cap after your buy", + "estimateHelp": "Includes price impact and the pool fee; the exact amount is quoted on-chain right before the buy.", + "notIssued": "Nothing suggested: no {symbol} has been issued yet, so there is none to buy with. The launch stays free.", + "onlyGas": "Suggested {amount} {symbol}, but this wallet holds only gas. The launch stays free; you can buy on the token page later.", + "notEnough": "Suggested {amount} {symbol}, but this wallet does not hold enough {symbol} for the buy plus its gas. The launch stays free; you can buy on the token page later.", + "noGas": "Suggested {amount} {symbol}, but this wallet has no {native} left for the buy's gas. The launch stays free; you can buy on the token page later.", + "unknownBalance": "Could not read your balance, so nothing is suggested. The launch stays free; you can still type an amount.", + "declined": "No first buy. The launch stays free.", + "declinedAgain": "No first buy. The launch stays free. Suggest {amount} {symbol} again", + "why": "A token with no holders and no price move looks dead on every screener and sits under quiet launches on the home page. Your first buy opens the chart. Clear it and the launch stays free.", + "slippage": "Other traders can buy before you. First-buy slippage tolerance: {pct}%. Network gas and pool fees apply.", + "errors": { + "amount": "First buy: enter an amount in {symbol}, or leave it empty.", + "balanceFailed": "First buy: could not read your {symbol} balance. Retry, or clear the amount.", + "balanceChecking": "First buy: checking your balance…", + "shortWithGas": "First buy: not enough {symbol} (leave a little for gas).", + "short": "First buy: not enough {symbol} in this wallet.", + "gasFailed": "First buy: could not read your {native} balance for gas. Retry, or clear the amount.", + "gasChecking": "First buy: checking your {native} balance for gas…", + "gasShort": "First buy: not enough {native} for gas (the launch, the approval and the buy each need a little {native})." + } + }, + "launch": { + "heading": "Launch", + "note": "what happens when you sign", + "deploy": "Deploys a plain ERC-20", + "deployDetail": "no mint, no pause, no blacklist, no tax", + "pool": "Opens a Uniswap v4 pool", + "poolDetail": "{quote} / your token on {chain}, no hook", + "lock": "Locks 100% of supply as liquidity", + "lockDetail": "the position NFT lives in an ownerless locker, forever", + "fees": "Routes trading fees", + "feesNone": "nothing to route at 0%", + "feesBurned": "burned at collect time", + "feesNamed": "to the beneficiaries you name, claimable any time", + "feesShares": "{shares}, claimable any time", + "buy": "Buys your first tokens", + "buyDetail": "{amount} right after the launch confirms, with a second wallet prompt", + "summary": "One transaction on {chain}: deploys the token, creates the Uniswap v4 pool ({quote} / your token), locks 100% of the supply in it forever, and registers the fee routing. Cost: gas only, usually a few cents. Nothing is refundable and nothing can be edited afterwards.", + "summaryBuy": "One transaction on {chain}: deploys the token, creates the Uniswap v4 pool ({quote} / your token), locks 100% of the supply in it forever, and registers the fee routing. Cost: gas only, usually a few cents. Nothing is refundable and nothing can be edited afterwards. Then a second transaction buys {amount} of your token; if you reject it, the launch still stands.", + "summaryBuyApproval": "One transaction on {chain}: deploys the token, creates the Uniswap v4 pool ({quote} / your token), locks 100% of the supply in it forever, and registers the fee routing. Cost: gas only, usually a few cents. Nothing is refundable and nothing can be edited afterwards. Then a second transaction buys {amount} of your token (with a one-time approval the first time); if you reject it, the launch still stands." + }, + "console": { + "atLaunch": "At launch", + "opensAt": "Opens at", + "firstBuy": "First buy", + "firstBuySuggested": "First buy · suggested", + "none": "none", + "ofSupply": "~{pct} of supply", + "untouched": "pool opens untouched", + "tradingFee": "Trading fee", + "platformFee": "Platform fee", + "always": "always" + }, + "feeRoute": { + "free": "free pool", + "burned": "burned", + "split": "split {count} ways", + "beneficiary": "to beneficiary" + }, + "submit": { + "notConfigured": "Launchpad not configured", + "connecting": "Connecting…", + "connect": "Connect wallet to launch", + "switching": "Switching…", + "switchTo": "Switch to {chain}", + "saving": "Saving details…", + "simulating": "Checking the launch…", + "signing": "Confirm in your wallet…", + "sent": "Confirming on {chain}…", + "buying": "Launched! Buying your first tokens…", + "indexing": "Almost there…", + "done": "Launched!", + "launchBuy": "Launch + first buy", + "launchFree": "Launch for free, gas only" + }, + "phase": { + "txDone": "Transaction {hash} confirmed. Taking you to your token.", + "txSent": "Transaction {hash} sent.", + "buyQuote": "Launch {hash} confirmed. Pricing your first buy…", + "buyApprove": "Launch {hash} confirmed. Approve the quote token in your wallet (one time)…", + "buySign": "Launch {hash} confirmed. Confirm the buy in your wallet…", + "buySent": "Launched. Buy {hash} sent, waiting for confirmation…" + }, + "toast": { + "live": "{name} is live on {chain}", + "liveSub": "Liquidity locked forever. Taking you to your token.", + "bought": "You bought {amount} {symbol}", + "boughtAbout": "You bought about {amount} {symbol}", + "buyConfirmed": "Buy confirmed on {chain}", + "buyFailed": "Launched, but the first buy did not go through", + "buyFailedSub": "{reason} You can buy on the token page." + }, + "reasons": { + "cancelled": "You cancelled in your wallet.", + "generic": "Something went wrong.", + "gas": "Not enough of the gas token (ETH, or USDC on Arc) for this transaction plus gas.", + "saltUsed": "This salt was already used. Try again (a fresh salt is generated).", + "badFee": "Fee out of range (max 3%).", + "badTick": "Start price out of range.", + "badSupply": "Supply too large.", + "quoteOrdering": "Token address must sort above the quote. Try again with a new salt.", + "nativeQuote": "This chain does not take its native asset as a quote. Price the token in USDC instead.", + "noLiquidity": "Supply too small to seed liquidity.", + "unknownLaunch": "Unknown launch.", + "badRecipients": "Beneficiary shares must add up to 100%.", + "metadata": "could not save metadata", + "metadataChanged": "metadata URI changed during the salt search", + "txReverted": "Transaction reverted on-chain.", + "buyReverted": "The buy reverted on-chain.", + "slowDown": "slow down", + "unconfigured": "launchpad unconfigured", + "slippage": "Price moved more than {pct}%. Try again.", + "reverted": "Reverted: {name}" + } + }, + "fees": { + "heading": "Trading fee", + "platform": "0% platform fee", + "intro": "Choose what traders pay on each buy and sell.", + "rateLegend": "Trading fee rate", + "rateLabel": "{rate} trading fee", + "noFee": "No trading fee", + "perHundred": "{amount} per 100 traded", + "routeLegend": "Where should the fees go?", + "routeHelp": "The full trading fee follows your allocation. Openlaunch takes none.", + "routes": { + "burn": { + "title": "Burn the fees", + "description": "Sent to 0x…dEaD when collected. Nobody receives them." + }, + "me": { + "title": "Your wallet", + "description": "The wallet you connect to launch." + }, + "custom": { + "title": "Wallets or a split", + "description": "Choose up to {max} beneficiaries, including a partial burn." + } + }, + "summary": { + "none": "No fees to distribute", + "yours": "Your fee allocation", + "across": "100% across {count, plural, one {# destination} other {# destinations}}", + "incomplete": "Allocation incomplete", + "one": "100% to one destination" + }, + "burnAddress": "Burn address", + "ofFees": "{pct}% of fees", + "tradingFee": "Trading fee", + "beneficiaries": "Beneficiaries", + "yourWallet": "Your wallet", + "completeSplit": "Complete the split above", + "allFees": "100% of fees", + "destination": { + "paid": "Paid directly to each wallet at collection. Burn shares go to 0x…dEaD.", + "incomplete": "Add valid addresses and shares totaling 100% before launch.", + "burned": "Burned at collection", + "connect": "Connect your wallet before launch", + "claimable": "Claimable by" + }, + "example": "For every 100 units traded, {count, plural, one {# unit is} other {# units are}} the trading fee. Network gas and price impact still apply.", + "exampleFree": "No trading fees are collected. Network gas and price impact still apply.", + "permanent": "Fixed at launch. The fee rate cannot be changed later.", + "permanentAllocation": "Fixed at launch. The fee rate and allocation cannot be changed later." + }, + "preview": { + "label": "Preview", + "board": "On the board", + "page": "Token page", + "justLaunched": "just launched", + "noBanner": "No banner yet", + "onChain": " on ", + "opensAt": "Opens at", + "noDescription": "Add a description so traders know what this is." + }, + "receipt": { + "title": "The launch receipt", + "verify": "Verify the launch transaction", + "tx": "Launch tx", + "supply": "Fixed supply", + "intoPool": "Into the pool at launch", + "liquidity": "Liquidity", + "lockerTitle": "An ownerless locker holds the position. Read the code.", + "locked": "Locked forever", + "platformFee": "Platform fee", + "footnote": "No withdrawal key. No platform cut. This does not prevent token prices from falling." + }, + "upload": { + "connect": "Connect a wallet to upload.", + "maxSize": "Max {mb} MB.", + "formats": "PNG, JPEG, WebP or GIF only.", + "failedStatus": "upload failed ({status})", + "failed": "upload failed", + "uploadBanner": "Upload token banner", + "uploadImage": "Upload token image", + "uploading": "Uploading…", + "changeBanner": "Change banner", + "addBanner": "Upload banner", + "resizing": "Resizing to {size}", + "bannerHint": "Wide, about 3:1 · PNG, JPEG, WebP or GIF · max {mb} MB", + "removeBanner": "Remove banner", + "changeImage": "Change image", + "addImage": "Upload image", + "logoHintStacked": "Square · PNG, JPEG, WebP or GIF · max 2 MB", + "logoHint": "PNG, JPEG, WebP or GIF · max 2 MB · drop it here or tap", + "pasteBanner": "or paste a banner URL", + "pasteImage": "or paste an image URL", + "bannerUrl": "Banner URL", + "imageUrl": "Image URL", + "reasons": { + "notConfigured": "uploads not configured", + "slowDown": "slow down", + "badForm": "bad form", + "connectFirst": "connect a wallet first", + "fileRequired": "file required", + "limit": "upload limit reached, try later", + "formats": "PNG, JPEG, WebP or GIF only", + "notImage": "not a valid image", + "logoSmall": "image too small (min 16×16)", + "bannerSmall": "banner too small (min 300×100)", + "storage": "storage error, try again", + "tooBig": "image must be ≤ {mb} MB" + } + }, + "totals": { + "label": "Network totals", + "trades": "Trades", + "tradesNote": "buys and sells, all on-chain", + "launched": "Tokens launched", + "launchedNote": "on {chains}", + "volume": "All-time volume", + "volumeNote": "through Uniswap v4 pools", + "toRecipients": "Fees to recipients", + "toRecipientsNote": "to the wallets creators chose", + "platformFee": "Platform fee", + "platformFeeNote": "to us, on every chain", + "chains": "{first} & {last}", + "chainSeparator": ", ", + "breakdown": "The numbers across {chains}", + "chainLaunches": "{chain} launches", + "allTrades": "All-time trades", + "burned": "Fees burned", + "gitlawbBurned": "GITLAWB burned", + "gitlawbBurnedTitle": "Sent to 0x…dEaD by GITLAWB-quoted launches on Base and Robinhood Chain", + "twigBurned": "TWIG burned", + "twigBurnedTitle": "Sent to 0x…dEaD by TWIG-quoted launches on Base", + "usdNote": "Some launches are quoted in an asset with no USD price right now; dollar totals exclude them until it returns.", + "volumeTitle": "{usd} · quote volume valued at current rates", + "feesTitle": "{usd} · quote-asset fees valued at current rates", + "feesExplained": "Creators choose a 0%, 1% or 3% trading fee. It goes to their named recipients or is burned. The platform takes none: no fee address in the factory, no platform cut in the locker.", + "readContracts": "Read the contracts" + }, + "machine": { + "eyebrow": "One transaction. Built to stay.", + "newLaunchOn": "New launch on {chain}", + "motionDisabled": "Animation disabled by reduced motion", + "play": "Play launch animation", + "pause": "Pause launch animation", + "motionOff": "Motion off", + "playShort": "Play", + "pauseShort": "Pause", + "explore": "Explore the launch mechanism", + "verify": "Verify the locker", + "stages": { + "token": { + "label": "Your token", + "title": "An idea becomes a token.", + "description": "Your name. Your symbol. A fixed supply, created in one transaction." + }, + "pool": { + "label": "The pool", + "title": "Open from the first trade.", + "description": "100% of the supply enters a Uniswap v4 pool at the starting market cap you choose." + }, + "lock": { + "label": "The lock", + "title": "Liquidity locked. No keys kept.", + "description": "The liquidity position stays in an ownerless locker forever. Tokens remain tradeable." + } + }, + "drawing": { + "noKey": "NO WITHDRAWAL KEY", + "token": "01 / YOUR TOKEN", + "fixedSupply": "Fixed supply", + "pool": "02 / POOL", + "lock": "03 / LOCK", + "noExpiry": "No expiry." + } + }, + "recipients": { + "none": "Beneficiaries: add at least one address, or choose Burn it.", + "tooMany": "Beneficiaries: at most {max} addresses.", + "invalid": "Beneficiary {n}: enter a valid address.", + "zero": "Beneficiary {n}: the zero address cannot receive fees. Use 0x…dEaD to burn a share.", + "duplicate": "Beneficiary {n}: same address as beneficiary {first}. Give it one row with the combined share.", + "badShare": "Beneficiary {n}: share must be a percentage between 0.01 and 100, with at most two decimals.", + "zeroShare": "Beneficiary {n}: share must be at least 0.01%.", + "under": "Beneficiaries: shares add up to {total}%. {left}% left to assign.", + "over": "Beneficiaries: shares add up to {total}%. Remove {over}%.", + "shareTo": "{pct}% to {address}", + "shareBurned": "{pct}% burned", + "separator": ", " + } +} diff --git a/app/src/i18n/messages/zh-CN/launch.json b/app/src/i18n/messages/zh-CN/launch.json index 0967ef42..2584e3e0 100644 --- a/app/src/i18n/messages/zh-CN/launch.json +++ b/app/src/i18n/messages/zh-CN/launch.json @@ -1 +1,421 @@ -{} +{ + "page": { + "title": "免费发行代币", + "description": "在 Base、Robinhood Chain 或 Arc 上部署代币,100% 的供应量锁定为 Uniswap v4 流动性。无平台费,只需支付 Gas。", + "heading": "发行代币", + "intro": "选一条链,填好信息,签名一次,就完成了。无平台费,你只需支付 Gas。", + "loading": "正在加载发行表单" + }, + "form": { + "optional": "· 选填", + "suggested": "· 建议", + "unnamed": "你的代币", + "chain": { + "heading": "链", + "note": "每条链上都是同样的发行、同样的规则", + "comingSoon": "即将上线。", + "notConfigured": "此处尚未配置。当前环境缺少合约设置。", + "blurb": { + "base": "以 ETH、TWIG 或 Coinbase 代币化股票计价。Gas 约几美分。", + "robinhood": "以 USDG(美元)、ETH、GITLAWB 或 Robinhood Stock Token 计价。Gas 约几美分。", + "arc": "以 USDC(美元)计价。Gas 也用 USDC 支付:每笔交易几美分,发行一次远低于 1 美元。" + } + }, + "quote": { + "pricedIn": "计价资产", + "label": "计价资产", + "stock": "股票", + "paysDollars": "买家用 {symbol} 支付;市值和手续费以美元计。", + "paysBrand": "买家用 {symbol} 支付;手续费以 {symbol} 支付,或被销毁。", + "paysEth": "买家用 ETH 支付。", + "pairTitle": "与 {symbol} 配对,你的代币会在全站带有 {symbol} 徽章", + "pair": "与 {symbol} 配对,获得 徽章", + "priceUnavailable": "暂无价格", + "brandTwig": "你的代币会在发行列表、热门、动态信息流、代币页面和分享卡片上带有 徽章。TWIG 是 Twigpine 在 Base 上的代币:GITLAWB 的 1:1 包装代币,任何人都可以随时包装或解包,无需费用。没有所有者,没有转账限制。不指定受益人时,手续费会在领取时以 TWIG 形式销毁。按 GITLAWB 的价格计价,价格来自 Base 上的 Uniswap v4 WETH/GITLAWB 资金池。{site} ↗", + "brandGitlawb": "你的代币会在发行列表、热门、动态信息流、代币页面和分享卡片上带有 徽章。GITLAWB 是 Twigpine 的代币{bridged, select, yes {,通过 LayerZero 从 Base 1:1 跨链到 Robinhood Chain} other {}}:普通的 ERC-20,没有转账限制,没有发行方开关。不指定受益人时,手续费会在领取时以 GITLAWB 形式销毁。价格来自 Base 上的 Uniswap v4 WETH/GITLAWB 资金池。{site} ↗" + }, + "stock": { + "pick": "请选择一只股票为代币计价,或切换计价资产。", + "clear": "清除股票计价", + "searchPlaceholder": "搜索股票代码,例如 AAPL", + "searchLabel": "搜索股票代币", + "switchQuote": "切换计价资产", + "noneIssuedTitle": "{name}:尚未发行", + "notIssued": "尚未发行", + "noneIssued": "{symbol} 尚未发行,因此还没有人持有,在 Coinbase 发行之前,也没有人能用它买入你的代币。你现在仍然可以发行。", + "showingMatches": "显示 {total} 个匹配结果中的 {shown} 个。继续输入可缩小范围。", + "showing": "显示 {total} 个中的 {shown} 个。搜索股票代码或公司名称查看其余部分。", + "coinbase": { + "pays": "买家用 Coinbase 代币化股票支付;手续费以该股票支付。", + "badge": "Coinbase 股票", + "empty": "没有匹配结果。可搜索股票代码或公司名称,例如 NVDA、AMD 或 Netflix。", + "issuer": "Coinbase 代币化股票是 Coinbase 依据 Regulation S 发行的证券,不向位于美国、英国、加拿大、澳大利亚、新加坡或瑞士的人士提供。这是 Coinbase 对该股票代币的规定,不是我们的规定。资金池本身就是普通的 Uniswap v4。" + }, + "robinhood": { + "pays": "买家用 Robinhood Stock Token 支付;手续费以该股票支付。", + "badge": "Robinhood 股票", + "empty": "没有匹配结果。共有 194 个 Robinhood Stock Token 可选,例如 AAPL、TSLA、NVDA、SPY。", + "issuer": "Robinhood Stock Token 是 Robinhood 发行的代币化证券,不向美国人士提供。这是 Robinhood 对该股票代币的规定,不是我们的规定。资金池本身就是普通的 Uniswap v4。" + } + }, + "token": { + "heading": "代币", + "name": "代币名称", + "namePlaceholder": "晴空", + "symbol": "代币代码", + "symbolHelp": "代币代码:1–10 个英文字母(A–Z)或数字(0–9),以大写形式发布。代币名称可以使用其他语言。", + "description": "简介", + "descriptionPlaceholder": "这是什么?一两句话就好。", + "left": "还可输入 {count} 字", + "artwork": "图片", + "artworkHint": "· 选填,但有 Logo 的代币会有人交易", + "artworkHelp": "Logo 会在各处标识你的代币;横幅横跨代币卡片和页面的顶部。", + "website": "网站", + "xPlaceholder": "@用户名 或 x.com 链接", + "nameInvalid": "代币名称:1–32 个字符。", + "symbolInvalid": "代币代码:请使用 1–10 个英文字母(A–Z)或数字(0–9)。", + "imageInvalid": "图片必须是 https 网址。", + "bannerInvalid": "横幅必须是 https 网址。", + "websiteInvalid": "网站必须是 https 网址。", + "xInvalid": "X:请输入用户名或 x.com 链接。" + }, + "price": { + "heading": "初始市值", + "supply": "供应量 1,000,000,000 · 全部注入资金池", + "custom": "自定义", + "customLabel": "以 {unit} 计的自定义初始市值", + "noUsd": "{symbol} 在这里没有美元价格:请以 {symbol} 输入初始市值。", + "opensAt": "开盘完全稀释市值 {cap} · {detail}。最初的 1 {symbol} 约可买入 {tokens} 个代币,之后价格沿曲线上涨。", + "opensAtMillion": "开盘完全稀释市值 {cap} · {detail}。最初的 1M {symbol} 约可买入 {tokens} 个代币,之后价格沿曲线上涨。", + "capInvalid": "初始市值必须是正数。", + "brandUnpriced": "{symbol} 目前无法获取价格:请以 {symbol} 输入自定义初始市值,或重新加载。" + }, + "split": { + "address": "受益人 {n} 的地址", + "burned": "销毁", + "share": "受益人 {n} 的份额(百分比)", + "remove": "移除受益人 {n}", + "add": "+ 添加地址", + "addMe": "+ 我({address})", + "addBurn": "+ 销毁部分份额", + "complete": "份额合计 100%", + "left": "还有 {pct}% 待分配", + "over": "超出 {pct}%", + "help": "份额以百分比填写,最多两位小数,合计必须正好是 100%。地址为 0x…dEaD 的行会销毁该份额。" + }, + "buy": { + "heading": "首次买入", + "note": "第二笔交易,在发行确认后立即发送", + "placeholder": "无", + "amountLabel": "以 {symbol} 计的首次买入金额", + "decline": "不进行首次买入", + "balance": "余额 {amount}", + "estimateLabel": "预计买入", + "estimate": "约 {amount} {symbol}(占供应量 {share})", + "estimateUsd": "约 {amount} {symbol}(占供应量 {share} · ≈ {usd})", + "tokens": "代币", + "capAfterLabel": "买入后的预计市值", + "estimateHelp": "已计入价格影响和资金池手续费;确切数量会在买入前于链上报价。", + "notIssued": "没有建议金额:{symbol} 尚未发行,所以无法用它买入。发行仍然免费。", + "onlyGas": "建议买入 {amount} {symbol},但此钱包的余额只够支付 Gas。发行仍然免费;你可以稍后在代币页面买入。", + "notEnough": "建议买入 {amount} {symbol},但此钱包的 {symbol} 不够支付这笔买入及其 Gas。发行仍然免费;你可以稍后在代币页面买入。", + "noGas": "建议买入 {amount} {symbol},但此钱包已没有 {native} 支付这笔买入的 Gas。发行仍然免费;你可以稍后在代币页面买入。", + "unknownBalance": "无法读取你的余额,因此没有建议金额。发行仍然免费;你仍然可以输入金额。", + "declined": "不进行首次买入。发行仍然免费。", + "declinedAgain": "不进行首次买入。发行仍然免费。恢复建议的 {amount} {symbol}", + "why": "没有持有人、价格也没有变化的代币,在每个筛选器上看起来都像死币,在首页也会被归入冷清的发行。你的首次买入会开启图表。清空它,发行仍然免费。", + "slippage": "其他交易者可能先于你买入。首次买入滑点容差:{pct}%。需支付网络 Gas 和资金池手续费。", + "errors": { + "amount": "首次买入:请输入以 {symbol} 计的金额,或留空。", + "balanceFailed": "首次买入:无法读取你的 {symbol} 余额。请重试,或清空金额。", + "balanceChecking": "首次买入:正在检查你的余额…", + "shortWithGas": "首次买入:{symbol} 不足(需要留一点用于 Gas)。", + "short": "首次买入:此钱包中的 {symbol} 不足。", + "gasFailed": "首次买入:无法读取你用于 Gas 的 {native} 余额。请重试,或清空金额。", + "gasChecking": "首次买入:正在检查你用于 Gas 的 {native} 余额…", + "gasShort": "首次买入:用于 Gas 的 {native} 不足(发行、授权和买入各需要一点 {native})。" + } + }, + "launch": { + "heading": "发行", + "note": "签名后会发生什么", + "deploy": "部署一个普通的 ERC-20", + "deployDetail": "不能增发、不能暂停、没有黑名单、没有交易税", + "pool": "开设一个 Uniswap v4 资金池", + "poolDetail": "{quote} / 你的代币,在 {chain} 上,无 Hook", + "lock": "将 100% 的供应量锁定为流动性", + "lockDetail": "仓位 NFT 永久存放在无所有者的锁仓合约中", + "fees": "分配交易手续费", + "feesNone": "0% 时无需分配", + "feesBurned": "领取时销毁", + "feesNamed": "给你指定的受益人,可随时领取", + "feesShares": "{shares},可随时领取", + "buy": "买入你的首批代币", + "buyDetail": "发行确认后立即用 {amount} 买入,钱包会再弹出一次确认", + "summary": "在 {chain} 上的一笔交易:部署代币,创建 Uniswap v4 资金池({quote} / 你的代币),将 100% 的供应量永久锁定在其中,并登记手续费分配。费用:只需 Gas,通常几美分。一切都不可退还,之后也无法修改任何内容。", + "summaryBuy": "在 {chain} 上的一笔交易:部署代币,创建 Uniswap v4 资金池({quote} / 你的代币),将 100% 的供应量永久锁定在其中,并登记手续费分配。费用:只需 Gas,通常几美分。一切都不可退还,之后也无法修改任何内容。随后第二笔交易会用 {amount} 买入你的代币;即使你拒绝这笔交易,发行依然有效。", + "summaryBuyApproval": "在 {chain} 上的一笔交易:部署代币,创建 Uniswap v4 资金池({quote} / 你的代币),将 100% 的供应量永久锁定在其中,并登记手续费分配。费用:只需 Gas,通常几美分。一切都不可退还,之后也无法修改任何内容。随后第二笔交易会用 {amount} 买入你的代币(首次需要一次性授权);即使你拒绝这笔交易,发行依然有效。" + }, + "console": { + "atLaunch": "发行时", + "opensAt": "开盘市值", + "firstBuy": "首次买入", + "firstBuySuggested": "首次买入 · 建议", + "none": "无", + "ofSupply": "约占供应量 {pct}", + "untouched": "开盘时无人买入", + "tradingFee": "交易手续费", + "platformFee": "平台费", + "always": "始终" + }, + "feeRoute": { + "free": "免费资金池", + "burned": "销毁", + "split": "分给 {count} 方", + "beneficiary": "给受益人" + }, + "submit": { + "notConfigured": "发射台未配置", + "connecting": "连接中…", + "connect": "连接钱包以发行", + "switching": "切换中…", + "switchTo": "切换到 {chain}", + "saving": "正在保存信息…", + "simulating": "正在检查发行…", + "signing": "请在钱包中确认…", + "sent": "正在 {chain} 上确认…", + "buying": "已发行!正在买入你的首批代币…", + "indexing": "马上就好…", + "done": "已发行!", + "launchBuy": "发行 + 首次买入", + "launchFree": "免费发行,只需 Gas" + }, + "phase": { + "txDone": "交易 {hash} 已确认。正在前往你的代币页面。", + "txSent": "交易 {hash} 已发送。", + "buyQuote": "发行交易 {hash} 已确认。正在为首次买入报价…", + "buyApprove": "发行交易 {hash} 已确认。请在钱包中授权计价代币(仅需一次)…", + "buySign": "发行交易 {hash} 已确认。请在钱包中确认买入…", + "buySent": "已发行。买入交易 {hash} 已发送,等待确认…" + }, + "toast": { + "live": "{name} 已在 {chain} 上线", + "liveSub": "流动性已永久锁定。正在前往你的代币页面。", + "bought": "你买入了 {amount} {symbol}", + "boughtAbout": "你买入了约 {amount} {symbol}", + "buyConfirmed": "买入已在 {chain} 上确认", + "buyFailed": "已发行,但首次买入没有成功", + "buyFailedSub": "{reason} 你可以在代币页面买入。" + }, + "reasons": { + "cancelled": "你已在钱包中取消。", + "generic": "出了点问题。", + "gas": "Gas 代币(ETH,或 Arc 上的 USDC)不足以支付这笔交易及其 Gas。", + "saltUsed": "该盐值已被使用。请重试(会生成新的盐值)。", + "badFee": "手续费超出范围(最高 3%)。", + "badTick": "起始价格超出范围。", + "badSupply": "供应量过大。", + "quoteOrdering": "代币地址的排序必须在计价资产之上。请使用新的盐值重试。", + "nativeQuote": "这条链不接受原生资产作为计价资产。请改用 USDC 为代币计价。", + "noLiquidity": "供应量太小,无法注入流动性。", + "unknownLaunch": "未知的发行。", + "badRecipients": "受益人份额合计必须为 100%。", + "metadata": "无法保存元数据", + "metadataChanged": "盐值搜索期间元数据 URI 发生了变化", + "txReverted": "交易在链上被回滚。", + "buyReverted": "买入交易在链上被回滚。", + "slowDown": "操作太频繁,请稍后再试", + "unconfigured": "发射台未配置", + "slippage": "价格变动超过 {pct}%。请重试。", + "reverted": "交易回滚:{name}" + } + }, + "fees": { + "heading": "交易手续费", + "platform": "0% 平台费", + "intro": "选择交易者每次买入和卖出时支付多少。", + "rateLegend": "交易手续费率", + "rateLabel": "{rate} 交易手续费", + "noFee": "无交易手续费", + "perHundred": "每交易 100 收取 {amount}", + "routeLegend": "手续费应该归谁?", + "routeHelp": "全部交易手续费都按你的分配发放。Openlaunch 不收取任何部分。", + "routes": { + "burn": { + "title": "销毁手续费", + "description": "领取时发送到 0x…dEaD,没有人会收到。" + }, + "me": { + "title": "你的钱包", + "description": "你连接后用于发行的钱包。" + }, + "custom": { + "title": "多个钱包或分成", + "description": "最多选择 {max} 个受益人,可包含部分销毁。" + } + }, + "summary": { + "none": "没有可分配的手续费", + "yours": "你的手续费分配", + "across": "100% 分配到 {count} 个去向", + "incomplete": "分配未完成", + "one": "100% 归一个去向" + }, + "burnAddress": "销毁地址", + "ofFees": "手续费的 {pct}%", + "tradingFee": "交易手续费", + "beneficiaries": "受益人", + "yourWallet": "你的钱包", + "completeSplit": "请完成上方的分成", + "allFees": "手续费的 100%", + "destination": { + "paid": "领取时直接支付到每个钱包。销毁份额会发送到 0x…dEaD。", + "incomplete": "发行前请添加有效地址,并使份额合计为 100%。", + "burned": "领取时销毁", + "connect": "请在发行前连接钱包", + "claimable": "领取地址" + }, + "example": "每交易 100 个单位,其中 {count} 个单位是交易手续费。仍会产生网络 Gas 和价格影响。", + "exampleFree": "不收取任何交易手续费。仍会产生网络 Gas 和价格影响。", + "permanent": "发行时即固定。手续费率之后无法更改。", + "permanentAllocation": "发行时即固定。手续费率和分配之后都无法更改。" + }, + "preview": { + "label": "预览", + "board": "列表中", + "page": "代币页面", + "justLaunched": "刚刚发行", + "noBanner": "暂无横幅", + "onChain": " 在 上", + "opensAt": "开盘市值", + "noDescription": "添加简介,让交易者知道这是什么。" + }, + "receipt": { + "title": "发行凭证", + "verify": "验证发行交易", + "tx": "发行交易", + "supply": "固定供应量", + "intoPool": "发行时注入资金池", + "liquidity": "流动性", + "lockerTitle": "仓位由无所有者的锁仓合约持有。查看代码。", + "locked": "永久锁定", + "platformFee": "平台费", + "footnote": "没有提取密钥,没有平台抽成。这并不能防止代币价格下跌。" + }, + "upload": { + "connect": "请先连接钱包再上传。", + "maxSize": "最大 {mb} MB。", + "formats": "仅支持 PNG、JPEG、WebP 或 GIF。", + "failedStatus": "上传失败({status})", + "failed": "上传失败", + "uploadBanner": "上传代币横幅", + "uploadImage": "上传代币图片", + "uploading": "上传中…", + "changeBanner": "更换横幅", + "addBanner": "上传横幅", + "resizing": "正在调整为 {size}", + "bannerHint": "宽幅,约 3:1 · PNG、JPEG、WebP 或 GIF · 最大 {mb} MB", + "removeBanner": "移除横幅", + "changeImage": "更换图片", + "addImage": "上传图片", + "logoHintStacked": "方形 · PNG、JPEG、WebP 或 GIF · 最大 2 MB", + "logoHint": "PNG、JPEG、WebP 或 GIF · 最大 2 MB · 拖放到这里或点击", + "pasteBanner": "或粘贴横幅网址", + "pasteImage": "或粘贴图片网址", + "bannerUrl": "横幅网址", + "imageUrl": "图片网址", + "reasons": { + "notConfigured": "上传功能未配置", + "slowDown": "操作太频繁,请稍后再试", + "badForm": "表单无效", + "connectFirst": "请先连接钱包", + "fileRequired": "请选择文件", + "limit": "已达到上传上限,请稍后再试", + "formats": "仅支持 PNG、JPEG、WebP 或 GIF", + "notImage": "不是有效的图片", + "logoSmall": "图片太小(最小 16×16)", + "bannerSmall": "横幅太小(最小 300×100)", + "storage": "存储出错,请重试", + "tooBig": "图片不能超过 {mb} MB" + } + }, + "totals": { + "label": "全网统计", + "trades": "交易笔数", + "tradesNote": "买入和卖出,全部在链上", + "launched": "已发行代币", + "launchedNote": "在 {chains} 上", + "volume": "累计成交量", + "volumeNote": "通过 Uniswap v4 资金池", + "toRecipients": "给接收方的手续费", + "toRecipientsNote": "进入创建者选择的钱包", + "platformFee": "平台费", + "platformFeeNote": "付给我们的,每条链都是", + "chains": "{first} 和 {last}", + "chainSeparator": "、", + "breakdown": "{chains} 的各项数据", + "chainLaunches": "{chain} 发行数", + "allTrades": "累计交易笔数", + "burned": "已销毁手续费", + "gitlawbBurned": "已销毁 GITLAWB", + "gitlawbBurnedTitle": "由 Base 和 Robinhood Chain 上以 GITLAWB 计价的发行发送到 0x…dEaD", + "twigBurned": "已销毁 TWIG", + "twigBurnedTitle": "由 Base 上以 TWIG 计价的发行发送到 0x…dEaD", + "usdNote": "部分发行目前以没有美元价格的资产计价;在价格恢复之前,美元合计不包含这些发行。", + "volumeTitle": "{usd} · 按当前汇率计算的计价资产成交量", + "feesTitle": "{usd} · 按当前汇率计算的计价资产手续费", + "feesExplained": "创建者可选择 0%、1% 或 3% 的交易手续费,付给他们指定的接收方,或被销毁。平台不收取任何部分:工厂合约中没有手续费地址,锁仓合约中没有平台抽成。", + "readContracts": "查看合约源码" + }, + "machine": { + "eyebrow": "一笔交易,长久存续。", + "newLaunchOn": "{chain} 上有新发行", + "motionDisabled": "已开启减少动态效果,动画已停用", + "play": "播放发行动画", + "pause": "暂停发行动画", + "motionOff": "动画已关闭", + "playShort": "播放", + "pauseShort": "暂停", + "explore": "了解发行机制", + "verify": "验证锁仓合约", + "stages": { + "token": { + "label": "你的代币", + "title": "一个想法变成一个代币。", + "description": "你的名称、你的代币代码、固定的供应量,一笔交易即可创建。" + }, + "pool": { + "label": "资金池", + "title": "从第一笔交易起就开放。", + "description": "100% 的供应量按你选择的初始市值注入 Uniswap v4 资金池。" + }, + "lock": { + "label": "锁定", + "title": "流动性已锁定,不留任何密钥。", + "description": "流动性仓位永久存放在无所有者的锁仓合约中。代币仍可交易。" + } + }, + "drawing": { + "noKey": "无提取密钥", + "token": "01 / 你的代币", + "fixedSupply": "固定供应量", + "pool": "02 / 资金池", + "lock": "03 / 锁定", + "noExpiry": "永不到期。" + } + }, + "recipients": { + "none": "受益人:请至少添加一个地址,或选择销毁。", + "tooMany": "受益人:最多 {max} 个地址。", + "invalid": "受益人 {n}:请输入有效地址。", + "zero": "受益人 {n}:零地址无法接收手续费。如需销毁份额,请使用 0x…dEaD。", + "duplicate": "受益人 {n}:与受益人 {first} 的地址相同。请合并为一行,填写合计份额。", + "badShare": "受益人 {n}:份额必须是 0.01 到 100 之间的百分比,最多两位小数。", + "zeroShare": "受益人 {n}:份额至少为 0.01%。", + "under": "受益人:份额合计 {total}%,还有 {left}% 待分配。", + "over": "受益人:份额合计 {total}%,请减少 {over}%。", + "shareTo": "{pct}% 给 {address}", + "shareBurned": "{pct}% 销毁", + "separator": "," + } +} diff --git a/app/src/lib/launchpad/launch-preview.ts b/app/src/lib/launchpad/launch-preview.ts index 7e00f7b0..4c51e0f6 100644 --- a/app/src/lib/launchpad/launch-preview.ts +++ b/app/src/lib/launchpad/launch-preview.ts @@ -8,7 +8,7 @@ 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. + * starts empty. Never stored or sent anywhere. `unnamed` stands in for a blank name, in the reader's language. */ export function previewLaunch(p: { chain: ChainKey; @@ -23,6 +23,7 @@ export function previewLaunch(p: { quote: { key: Quote["key"]; symbol: string; decimals: number; usd: number | null }; lpFee: number; launcher: string | null; + unnamed?: string; }): LaunchRow { const https = (v: string) => (/^https:\/\//.test(v.trim()) ? v.trim() : null); const fdv = p.fdvQuote !== null && Number.isFinite(p.fdvQuote) ? p.fdvQuote : 0; @@ -45,7 +46,7 @@ export function previewLaunch(p: { lp_fee: p.lpFee, supply: String(SUPPLY), metadata_uri: "", - name: p.name.trim() || "Your token", + name: p.name.trim() || (p.unnamed ?? "Your token"), symbol, block_number: 0, block_time: new Date(0).toISOString(), diff --git a/app/src/lib/launchpad/recipients.ts b/app/src/lib/launchpad/recipients.ts index 035a07d6..dc18e336 100644 --- a/app/src/lib/launchpad/recipients.ts +++ b/app/src/lib/launchpad/recipients.ts @@ -1,4 +1,5 @@ import { getAddress, isAddress, type Address } from "viem"; +import { clientTranslator } from "@/i18n/client"; import { BPS, DEAD, MAX_RECIPIENTS } from "./config"; /** @@ -49,33 +50,37 @@ export function bpsToPct(bps: number): string { return `${whole}.${String(frac).padStart(2, "0").replace(/0$/, "")}`; } -/** Parses the editor rows into factory recipients. Never throws; every problem is a message the form can list. */ +/** + * Parses the editor rows into factory recipients. Never throws; every problem is a message the form can list, in the + * reader's language (launch.recipients; English outside the browser). + */ export function buildRecipients(rows: RecipientRow[]): SplitResult { + const t = clientTranslator("launch"); const errors: string[] = []; const recipients: Recipient[] = []; const seen = new Map(); let total = 0; - if (rows.length === 0) errors.push("Beneficiaries: add at least one address, or choose Burn it."); - if (rows.length > MAX_RECIPIENTS) errors.push(`Beneficiaries: at most ${MAX_RECIPIENTS} addresses.`); + if (rows.length === 0) errors.push(t("recipients.none")); + if (rows.length > MAX_RECIPIENTS) errors.push(t("recipients.tooMany", { max: MAX_RECIPIENTS })); rows.forEach((row, i) => { const n = i + 1; const raw = row.payout.trim(); let payout: Address | null = null; - if (!isAddress(raw)) errors.push(`Beneficiary ${n}: enter a valid address.`); - else if (raw.toLowerCase() === ZERO) errors.push(`Beneficiary ${n}: the zero address cannot receive fees. Use 0x…dEaD to burn a share.`); + if (!isAddress(raw)) errors.push(t("recipients.invalid", { n })); + else if (raw.toLowerCase() === ZERO) errors.push(t("recipients.zero", { n })); else { payout = getAddress(raw); const lc = payout.toLowerCase(); const first = seen.get(lc); - if (first !== undefined) errors.push(`Beneficiary ${n}: same address as beneficiary ${first}. Give it one row with the combined share.`); + if (first !== undefined) errors.push(t("recipients.duplicate", { n, first })); else seen.set(lc, n); } const bps = pctToBps(row.pct); - if (bps === null) errors.push(`Beneficiary ${n}: share must be a percentage between 0.01 and 100, with at most two decimals.`); - else if (bps === 0) errors.push(`Beneficiary ${n}: share must be at least 0.01%.`); + if (bps === null) errors.push(t("recipients.badShare", { n })); + else if (bps === 0) errors.push(t("recipients.zeroShare", { n })); else total += bps; if (payout && bps) recipients.push({ payout, bps }); @@ -83,13 +88,14 @@ export function buildRecipients(rows: RecipientRow[]): SplitResult { const remainingBps = BPS - total; if (rows.length > 0 && remainingBps !== 0) { - errors.push(remainingBps > 0 ? `Beneficiaries: shares add up to ${bpsToPct(total)}%. ${bpsToPct(remainingBps)}% left to assign.` : `Beneficiaries: shares add up to ${bpsToPct(total)}%. Remove ${bpsToPct(-remainingBps)}%.`); + errors.push(remainingBps > 0 ? t("recipients.under", { total: bpsToPct(total), left: bpsToPct(remainingBps) }) : t("recipients.over", { total: bpsToPct(total), over: bpsToPct(-remainingBps) })); } return { recipients: errors.length === 0 ? recipients : [], errors, remainingBps }; } -/** Short human line for the summary: "60% to 0x12…34, 40% burned". */ +/** Short human line for the summary, in the reader's language: "60% to 0x12…34, 40% burned". */ export function describeShares(recipients: Recipient[], short: (a: string) => string): string { - return recipients.map((r) => `${bpsToPct(r.bps)}% ${isBurnAddress(r.payout) ? "burned" : `to ${short(r.payout)}`}`).join(", "); + const t = clientTranslator("launch"); + return recipients.map((r) => (isBurnAddress(r.payout) ? t("recipients.shareBurned", { pct: bpsToPct(r.bps) }) : t("recipients.shareTo", { pct: bpsToPct(r.bps), address: short(r.payout) }))).join(t("recipients.separator")); } diff --git a/app/src/lib/launchpad/twig.test.ts b/app/src/lib/launchpad/twig.test.ts index cf86acdb..2dad969e 100644 --- a/app/src/lib/launchpad/twig.test.ts +++ b/app/src/lib/launchpad/twig.test.ts @@ -106,7 +106,7 @@ test("badge: Twigpine tile in every list, the form, the token page and the share assert.match(src("../../components/launchpad/TrendingStrip.tsx"), /hasQuoteBrandBadge\(row\.quote_key\)/, "TWIG rows show the badge, not the bare symbol"); const form = src("../../components/launchpad/LaunchForm.tsx"); assert.match(form, /const brandQuote = cfg\.quotes\.find\(\(q\) => q\.key === "twig"\) \?\? cfg\.quotes\.find\(\(q\) => q\.key === "gitlawb"\) \?\? null;/, "TWIG is the nudge wherever the form offers it"); - assert.match(form, /Pair with \{brandQuote\.symbol\}, get the badge/); + assert.match(form, /t\.rich\("quote\.pair", \{ symbol: brandQuote\.symbol, badge: \(\) => \}\)/, "the nudge's sentence carries the badge (its English: launch-form.test.ts)"); assert.match(form, //); assert.match(src("../../app/t/[chain]/[token]/page.tsx"), //); assert.equal(en.token.page.pairedWith, "Paired with {symbol}"); diff --git a/app/src/lib/launchpad/xHandle.test.ts b/app/src/lib/launchpad/xHandle.test.ts index 99b1cf38..7bfb204a 100644 --- a/app/src/lib/launchpad/xHandle.test.ts +++ b/app/src/lib/launchpad/xHandle.test.ts @@ -86,10 +86,12 @@ test("the launch form and the edit sheet accept a link in the X field", () => { const launch = readFileSync(new URL("../../components/launchpad/LaunchForm.tsx", import.meta.url), "utf8"); const edit = readFileSync(new URL("../../components/launchpad/EditTokenSheet.tsx", import.meta.url), "utf8"); assert.match(launch, /const xParsed = parseXHandle\(x\);/); - assert.match(launch, /if \(!xParsed\.ok\) errors\.push\("X: enter a handle or an x\.com link\."\);/); - for (const form of [launch, edit]) assert.doesNotMatch(form, /placeholder="@handle"/); - assert.match(launch, /placeholder="@handle or x\.com link"/); + // the launch form's words are messages (their English: launch-form.test.ts) + assert.match(launch, /if \(!xParsed\.ok\) errors\.push\(t\("token\.xInvalid"\)\);/); + assert.match(launch, /placeholder=\{t\("token\.xPlaceholder"\)\}/); // the edit sheet words it in the reader's language; in English it reads as the launch form's assert.match(edit, /placeholder=\{t\("xPlaceholder"\)\}/); - assert.equal(en.token.edit.xPlaceholder, "@handle or x.com link"); + assert.equal(en.token.edit.xPlaceholder, en.launch.form.token.xPlaceholder); + assert.equal(en.launch.form.token.xPlaceholder, "@handle or x.com link"); + assert.doesNotMatch(launch + edit, /placeholder="@handle"/); }); From d3771405311f90a72fd75691faa246567fc9f5f4 Mon Sep 17 00:00:00 2001 From: Vasanth T Date: Wed, 14 Oct 2026 19:38:26 +0530 Subject: [PATCH 16/18] fix(i18n): word wallet and contract errors once, in lib/errors.ts friendlyError already returns its reasons in the reader's language (the errors area). The launch form, the posts UI and the profile sheet also kept their own copies of those reasons, mapped from friendlyError's old English; in Chinese the map never matched, so the copies were dead and could drift. They are removed from the three maps and their messages, and the tests now check that friendlyError's words are not mapped again. --- app/src/components/launchpad/LaunchForm.tsx | 26 ++++--------------- app/src/components/launchpad/Posts.tsx | 8 +++--- .../components/launchpad/launch-form.test.ts | 16 +++++------- .../components/launchpad/posts-i18n.test.ts | 9 ++++--- .../components/profile/profile-i18n.test.ts | 9 +++---- app/src/components/profile/reasons.ts | 7 ++--- app/src/i18n/messages/en/account.json | 4 +-- app/src/i18n/messages/en/launch.json | 16 +----------- app/src/i18n/messages/en/posts.json | 2 -- app/src/i18n/messages/zh-CN/account.json | 4 +-- app/src/i18n/messages/zh-CN/launch.json | 16 +----------- app/src/i18n/messages/zh-CN/posts.json | 2 -- 12 files changed, 31 insertions(+), 88 deletions(-) diff --git a/app/src/components/launchpad/LaunchForm.tsx b/app/src/components/launchpad/LaunchForm.tsx index ce80266d..01a823aa 100644 --- a/app/src/components/launchpad/LaunchForm.tsx +++ b/app/src/components/launchpad/LaunchForm.tsx @@ -82,24 +82,13 @@ const CHAIN_COPY: Record; +type Reason = keyof Messages["launch"]["form"]["reasons"]; /** - * Why a launch or its first buy failed, as messages: the English reasons friendlyError (lib/errors.ts), the metadata - * route and this form's own checks give. Any other text, such as a wallet's or a node's own error, reads as it came. + * Why a launch or its first buy failed, as messages: the English reasons the metadata route and this form's own checks + * give. friendlyError (lib/errors.ts) already words its reasons in the reader's language (the errors area), and any + * other text, such as a wallet's or a node's own error, reads as it came. */ const REASONS = new Map([ - ["You cancelled in your wallet.", "cancelled"], - ["Something went wrong.", "generic"], - ["Not enough of the gas token (ETH, or USDC on Arc) for this transaction plus gas.", "gas"], - ["This salt was already used. Try again (a fresh salt is generated).", "saltUsed"], - ["Fee out of range (max 3%).", "badFee"], - ["Start price out of range.", "badTick"], - ["Supply too large.", "badSupply"], - ["Token address must sort above the quote. Try again with a new salt.", "quoteOrdering"], - ["This chain does not take its native asset as a quote. Price the token in USDC instead.", "nativeQuote"], - ["Supply too small to seed liquidity.", "noLiquidity"], - ["Unknown launch.", "unknownLaunch"], - ["Beneficiary shares must add up to 100%.", "badRecipients"], ["could not save metadata", "metadata"], ["metadata URI changed during the salt search", "metadataChanged"], ["Transaction reverted on-chain.", "txReverted"], @@ -113,12 +102,7 @@ function useReason() { const t = useTranslations("launch.form.reasons"); return useCallback((reason: string) => { const key = REASONS.get(reason); - if (key) return t(key); - const slippage = /^Price moved more than ([\d.]+)%\. Try again\.$/.exec(reason); - if (slippage) return t("slippage", { pct: slippage[1] }); - const reverted = /^Reverted: (\w+)$/.exec(reason); - if (reverted) return t("reverted", { name: reverted[1] }); - return reason; + return key ? t(key) : reason; }, [t]); } diff --git a/app/src/components/launchpad/Posts.tsx b/app/src/components/launchpad/Posts.tsx index 13809969..91f383aa 100644 --- a/app/src/components/launchpad/Posts.tsx +++ b/app/src/components/launchpad/Posts.tsx @@ -33,13 +33,11 @@ function TagChip({ tag }: { tag: PostRow["tag"] }) { type Reason = Exclude; /** - * Why a post, report or mute failed, as messages: the English reasons validateBody (lib/launchpad/posts.ts), - * friendlyError (lib/errors.ts) and the posts API (lib/launchpad/postsServer.ts) give. Any other text, such as a - * wallet's own error, reads as it came. + * Why a post, report or mute failed, as messages: the English reasons validateBody (lib/launchpad/posts.ts) and the + * posts API (lib/launchpad/postsServer.ts) give. friendlyError (lib/errors.ts) already words its reasons in the reader's + * language (the errors area), and any other text, such as a wallet's own error, reads as it came. */ const REASONS = new Map([ - ["You cancelled in your wallet.", "cancelled"], - ["Something went wrong.", "generic"], ["empty post", "empty"], ["no HTML", "noHtml"], ["slow down", "slowDown"], diff --git a/app/src/components/launchpad/launch-form.test.ts b/app/src/components/launchpad/launch-form.test.ts index 4cf796ac..b6e85c02 100644 --- a/app/src/components/launchpad/launch-form.test.ts +++ b/app/src/components/launchpad/launch-form.test.ts @@ -278,22 +278,18 @@ test("the form's words are messages, and the English ones read as the form alway test("every failure reason the form translates is one the code really gives, and its English reads as the reason did", () => { const read = (file: string) => readFileSync(new URL(file, import.meta.url), "utf8").replaceAll("\r\n", "\n"); const form = source.replaceAll("\r\n", "\n"); - // the reasons as the code writes them: friendlyError's, the metadata route's and the form's own throws (not its map) - const given = [read("../../lib/errors.ts"), read("../../app/api/launch/meta/route.ts"), form.replace(/const REASONS = new Map[\s\S]*?\]\);/, "")].join("\n"); + // the reasons as the code writes them: the metadata route's and the form's own throws (not its map). friendlyError + // words its own reasons in the reader's language, so none of them is mapped here. + const given = [read("../../app/api/launch/meta/route.ts"), form.replace(/const REASONS = new Map[\s\S]*?\]\);/, "")].join("\n"); const mapped = new Map([...form.matchAll(/^\s*\["([^"]+)", "(\w+)"\],$/gm)].map((m) => [m[2], m[1]])); for (const [key, english] of Object.entries(copy.reasons)) { - if (key === "slippage" || key === "reverted") continue; // the two templates, read back by pattern below assert.equal(mapped.get(key), english, `REASONS maps "${english}" to reasons.${key}`); assert.ok(given.includes(`"${english}"`), `reasons.${key} ("${english}") is not a reason the code gives`); } - assert.equal(copy.reasons.slippage, "Price moved more than {pct}%. Try again."); - assert.match(given, /`Price moved more than \$\{slippagePct\}%\. Try again\.`/); - assert.ok(source.includes(String.raw`/^Price moved more than ([\d.]+)%\. Try again\.$/.exec(reason)`)); - assert.equal(copy.reasons.reverted, "Reverted: {name}"); - assert.match(given, /`Reverted: \$\{name\}`/); - assert.ok(source.includes(String.raw`/^Reverted: (\w+)$/.exec(reason)`)); + const friendly = [en.errors.cancelled, en.errors.gas, en.errors.unknown, ...Object.values(en.errors.contract)]; + for (const english of friendly) assert.ok(![...mapped.values()].includes(english), `friendlyError's "${english}" is not mapped again`); // both places a reason is shown read it through the map; anything unknown (a wallet's own words) reads as it came assert.match(source, /\{explain\(phase\.message\)\}/); assert.match(source, /reason: explain\(buyProblem\)/); - assert.match(source, /return reason;\s*\}, \[t\]\);/); + assert.match(source, /return key \? t\(key\) : reason;\s*\}, \[t\]\);/); }); diff --git a/app/src/components/launchpad/posts-i18n.test.ts b/app/src/components/launchpad/posts-i18n.test.ts index 02865493..2b6859e6 100644 --- a/app/src/components/launchpad/posts-i18n.test.ts +++ b/app/src/components/launchpad/posts-i18n.test.ts @@ -5,15 +5,16 @@ import { en } from "../../i18n/messages/index.ts"; import { REPORT_REASONS } from "../../lib/launchpad/posts.ts"; /** - * Source contracts for the posts UI in two languages. The posts lib, friendlyError and the posts API give their failure - * reasons in English; Posts.tsx shows each known one as a message (posts.errors.*), so these keep the two in step: every - * translated reason is one the code really gives, and its English message reads exactly as the reason did. + * Source contracts for the posts UI in two languages. The posts lib and the posts API give their failure reasons in + * English; Posts.tsx shows each known one as a message (posts.errors.*), so these keep the two in step: every translated + * reason is one the code really gives, and its English message reads exactly as the reason did. (friendlyError words + * its own reasons in the reader's language.) */ const read = (file: string) => readFileSync(new URL(file, import.meta.url), "utf8").replaceAll("\r\n", "\n"); const posts = read("./Posts.tsx"); // the reasons as the code writes them: the lib's and the API's own strings, and the fallbacks Posts.tsx throws with const given = [ - ...["../../lib/launchpad/posts.ts", "../../lib/launchpad/postsServer.ts", "../../lib/errors.ts", "../../app/api/posts/route.ts", "../../app/api/posts/report/route.ts", "../../app/api/posts/mod/route.ts"].map(read), + ...["../../lib/launchpad/posts.ts", "../../lib/launchpad/postsServer.ts", "../../app/api/posts/route.ts", "../../app/api/posts/report/route.ts", "../../app/api/posts/mod/route.ts"].map(read), ...[...posts.matchAll(/d\.error \?\? ("[^"]+")/g)].map((m) => m[1]), ].join("\n"); const escape = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); diff --git a/app/src/components/profile/profile-i18n.test.ts b/app/src/components/profile/profile-i18n.test.ts index 4a9716fe..3e031878 100644 --- a/app/src/components/profile/profile-i18n.test.ts +++ b/app/src/components/profile/profile-i18n.test.ts @@ -6,10 +6,10 @@ import { en, messagesFor } from "../../i18n/messages/index.ts"; import { reasonOf } from "./reasons.ts"; /** - * The profile sheet in two languages. Validation, the profile API, friendlyError and the sheet's own fallbacks give - * their failure reasons in English; the sheet shows each known one as a message (account.profileSheet.errors). These - * keep the two in step: every message stands for a reason the code really gives, every reason finds its message with - * its values, and the English message reads exactly as the reason did. + * The profile sheet in two languages. Validation, the profile API and the sheet's own fallbacks give their failure + * reasons in English; the sheet shows each known one as a message (account.profileSheet.errors). These keep the two in + * step: every message stands for a reason the code really gives, every reason finds its message with its values, and + * the English message reads exactly as the reason did. (friendlyError words its own reasons in the reader's language.) */ const read = (file: string) => readFileSync(new URL(file, import.meta.url), "utf8").replaceAll("\r\n", "\n"); const sheet = read("./ProfileSheet.tsx"); @@ -21,7 +21,6 @@ const given = [ "../../lib/launchpad/xHandle.ts", "../../lib/profiles/server.ts", "../../lib/profiles/xpost.ts", - "../../lib/errors.ts", "../../app/api/profile/route.ts", "../../app/api/profile/check/route.ts", "../../app/api/profile/delete/route.ts", diff --git a/app/src/components/profile/reasons.ts b/app/src/components/profile/reasons.ts index f33f76e2..e6d9c5f0 100644 --- a/app/src/components/profile/reasons.ts +++ b/app/src/components/profile/reasons.ts @@ -5,9 +5,10 @@ export type Reason = keyof Messages["account"]["profileSheet"]["errors"]; /** * Why a profile save, delete or X check failed, as messages (account.profileSheet.errors). The reasons arrive as * English text from validateProfile (lib/profiles/validate.ts, the X field's from lib/launchpad/xHandle.ts), the profile - * API (lib/profiles/server.ts, xpost.ts and the /api/profile routes), friendlyError and ProfileSheet's own fallbacks. - * Each English message reads exactly as its reason, so the reason finds its message; the libs and the API keep their - * English. Server-safe and pure, so the tests can read every reason back. + * API (lib/profiles/server.ts, xpost.ts and the /api/profile routes) and ProfileSheet's own fallbacks. Each English + * message reads exactly as its reason, so the reason finds its message; the libs and the API keep their English. + * friendlyError (lib/errors.ts) already words its reasons in the reader's language. Server-safe and pure, so the tests + * can read every reason back. */ const EXACT = new Map( Object.entries(en.account.profileSheet.errors) diff --git a/app/src/i18n/messages/en/account.json b/app/src/i18n/messages/en/account.json index a00645a7..05e1ce71 100644 --- a/app/src/i18n/messages/en/account.json +++ b/app/src/i18n/messages/en/account.json @@ -226,9 +226,7 @@ "authorUnknown": "could not read who wrote that post; try again in a minute", "wrongAccount": "that post is from @{handle}, but this code is for @{expected}", "codeMissing": "the code {code} is not in that post", - "xDownWrongLink": "X did not answer just now, and that link is not a post from @{handle}: paste the link to your post from @{handle}, or try again in a minute", - "cancelled": "You cancelled in your wallet.", - "generic": "Something went wrong." + "xDownWrongLink": "X did not answer just now, and that link is not a post from @{handle}: paste the link to your post from @{handle}, or try again in a minute" } }, "points": { diff --git a/app/src/i18n/messages/en/launch.json b/app/src/i18n/messages/en/launch.json index c8c1d467..aa5e41d8 100644 --- a/app/src/i18n/messages/en/launch.json +++ b/app/src/i18n/messages/en/launch.json @@ -208,26 +208,12 @@ "buyFailedSub": "{reason} You can buy on the token page." }, "reasons": { - "cancelled": "You cancelled in your wallet.", - "generic": "Something went wrong.", - "gas": "Not enough of the gas token (ETH, or USDC on Arc) for this transaction plus gas.", - "saltUsed": "This salt was already used. Try again (a fresh salt is generated).", - "badFee": "Fee out of range (max 3%).", - "badTick": "Start price out of range.", - "badSupply": "Supply too large.", - "quoteOrdering": "Token address must sort above the quote. Try again with a new salt.", - "nativeQuote": "This chain does not take its native asset as a quote. Price the token in USDC instead.", - "noLiquidity": "Supply too small to seed liquidity.", - "unknownLaunch": "Unknown launch.", - "badRecipients": "Beneficiary shares must add up to 100%.", "metadata": "could not save metadata", "metadataChanged": "metadata URI changed during the salt search", "txReverted": "Transaction reverted on-chain.", "buyReverted": "The buy reverted on-chain.", "slowDown": "slow down", - "unconfigured": "launchpad unconfigured", - "slippage": "Price moved more than {pct}%. Try again.", - "reverted": "Reverted: {name}" + "unconfigured": "launchpad unconfigured" } }, "fees": { diff --git a/app/src/i18n/messages/en/posts.json b/app/src/i18n/messages/en/posts.json index 5d3655a2..2698b645 100644 --- a/app/src/i18n/messages/en/posts.json +++ b/app/src/i18n/messages/en/posts.json @@ -37,8 +37,6 @@ } }, "errors": { - "cancelled": "You cancelled in your wallet.", - "generic": "Something went wrong.", "empty": "empty post", "tooLong": "max {max} characters", "noHtml": "no HTML", diff --git a/app/src/i18n/messages/zh-CN/account.json b/app/src/i18n/messages/zh-CN/account.json index 22ae44b2..74ded1a0 100644 --- a/app/src/i18n/messages/zh-CN/account.json +++ b/app/src/i18n/messages/zh-CN/account.json @@ -226,9 +226,7 @@ "authorUnknown": "无法读取该帖子的作者;请一分钟后重试", "wrongAccount": "该帖子来自 @{handle},但这个验证码属于 @{expected}", "codeMissing": "该帖子中没有验证码 {code}", - "xDownWrongLink": "X 暂时没有响应,而且该链接不是 @{handle} 的帖子:请粘贴你用 @{handle} 发布的帖子链接,或一分钟后重试", - "cancelled": "你已在钱包中取消。", - "generic": "出了点问题。" + "xDownWrongLink": "X 暂时没有响应,而且该链接不是 @{handle} 的帖子:请粘贴你用 @{handle} 发布的帖子链接,或一分钟后重试" } }, "points": { diff --git a/app/src/i18n/messages/zh-CN/launch.json b/app/src/i18n/messages/zh-CN/launch.json index 2584e3e0..105fb3ce 100644 --- a/app/src/i18n/messages/zh-CN/launch.json +++ b/app/src/i18n/messages/zh-CN/launch.json @@ -208,26 +208,12 @@ "buyFailedSub": "{reason} 你可以在代币页面买入。" }, "reasons": { - "cancelled": "你已在钱包中取消。", - "generic": "出了点问题。", - "gas": "Gas 代币(ETH,或 Arc 上的 USDC)不足以支付这笔交易及其 Gas。", - "saltUsed": "该盐值已被使用。请重试(会生成新的盐值)。", - "badFee": "手续费超出范围(最高 3%)。", - "badTick": "起始价格超出范围。", - "badSupply": "供应量过大。", - "quoteOrdering": "代币地址的排序必须在计价资产之上。请使用新的盐值重试。", - "nativeQuote": "这条链不接受原生资产作为计价资产。请改用 USDC 为代币计价。", - "noLiquidity": "供应量太小,无法注入流动性。", - "unknownLaunch": "未知的发行。", - "badRecipients": "受益人份额合计必须为 100%。", "metadata": "无法保存元数据", "metadataChanged": "盐值搜索期间元数据 URI 发生了变化", "txReverted": "交易在链上被回滚。", "buyReverted": "买入交易在链上被回滚。", "slowDown": "操作太频繁,请稍后再试", - "unconfigured": "发射台未配置", - "slippage": "价格变动超过 {pct}%。请重试。", - "reverted": "交易回滚:{name}" + "unconfigured": "发射台未配置" } }, "fees": { diff --git a/app/src/i18n/messages/zh-CN/posts.json b/app/src/i18n/messages/zh-CN/posts.json index 881a7ab9..fd9f1e0d 100644 --- a/app/src/i18n/messages/zh-CN/posts.json +++ b/app/src/i18n/messages/zh-CN/posts.json @@ -37,8 +37,6 @@ } }, "errors": { - "cancelled": "你已在钱包中取消。", - "generic": "出了点问题。", "empty": "内容不能为空", "tooLong": "最多 {max} 个字符", "noHtml": "不支持 HTML", From 6ae7c2629a2e673d8fe3729186fe6e61590941f6 Mon Sep 17 00:00:00 2001 From: Vasanth T Date: Wed, 14 Oct 2026 19:52:15 +0530 Subject: [PATCH 17/18] fix(i18n): /feed tab title and description in the reader's language --- app/src/app/feed/page.tsx | 6 +++++- app/src/i18n/messages/en/posts.json | 4 ++++ app/src/i18n/messages/zh-CN/posts.json | 4 ++++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/app/src/app/feed/page.tsx b/app/src/app/feed/page.tsx index 98d38d18..c732a110 100644 --- a/app/src/app/feed/page.tsx +++ b/app/src/app/feed/page.tsx @@ -8,7 +8,11 @@ import styles from "@/components/sections/SectionShell.module.css"; import { listFeed } from "@/lib/launchpad/postsServer"; import { getTranslations } from "@/i18n/server"; -export const metadata: Metadata = pageMetadata({ path: "/feed", title: "Posts", description: "What people are saying about tokens launched on openlaunch.lol." }); +export async function generateMetadata(): Promise { + const t = await getTranslations("posts"); + return pageMetadata({ path: "/feed", title: t("page.meta.title"), description: t("page.meta.description") }); +} + export const dynamic = "force-dynamic"; export default async function FeedPage() { diff --git a/app/src/i18n/messages/en/posts.json b/app/src/i18n/messages/en/posts.json index 2698b645..3c1c0414 100644 --- a/app/src/i18n/messages/en/posts.json +++ b/app/src/i18n/messages/en/posts.json @@ -73,6 +73,10 @@ "on": "on {symbol}" }, "page": { + "meta": { + "title": "Posts", + "description": "What people are saying about tokens launched on openlaunch.lol." + }, "eyebrow": "Community posts", "title": "Behind every token.", "description": "Holders, traders and creators, in their own words. Follow the conversation across Base, Robinhood Chain and Arc.", diff --git a/app/src/i18n/messages/zh-CN/posts.json b/app/src/i18n/messages/zh-CN/posts.json index fd9f1e0d..18ec3918 100644 --- a/app/src/i18n/messages/zh-CN/posts.json +++ b/app/src/i18n/messages/zh-CN/posts.json @@ -73,6 +73,10 @@ "on": "关于 {symbol}" }, "page": { + "meta": { + "title": "帖子", + "description": "大家如何评价在 openlaunch.lol 上发行的代币。" + }, "eyebrow": "社区帖子", "title": "每个代币背后的声音。", "description": "听听持有人、交易者和创建者怎么说。关注 Base、Robinhood Chain 和 Arc 上的讨论。", From 0fbaea83642704ce5b417d423fd79ae357ad27dd Mon Sep 17 00:00:00 2001 From: Vasanth T Date: Wed, 14 Oct 2026 20:05:55 +0530 Subject: [PATCH 18/18] feat(i18n): Earn in Simplified Chinese The /me Earn card, its sheet and the confirmation toast read from a new earn message area. lib/earn and useEarn keep their English reasons (the server's terms check sends its reason already worded); EarnPanel shows each one through explainEarn (components/earn/reasons.ts), which maps the English to its message with the values it carried, including the reason inside a paused-deposit sentence. Anything else, such as a wallet's own error, reads as it came. earn-i18n.test.ts checks that every reason message is one the code gives and reads back exactly in English and in Chinese. earn-ui.test.ts now normalises line endings, so its patterns hold on a Windows checkout, and checks the risk copy in both languages. --- app/src/app/ui-review-earn/EarnReview.tsx | 7 +- app/src/components/earn/EarnCard.tsx | 16 +-- app/src/components/earn/EarnDialog.tsx | 13 ++- app/src/components/earn/EarnPanel.tsx | 105 ++++++++++---------- app/src/components/earn/earn-flow.test.ts | 4 + app/src/components/earn/earn-i18n.test.ts | 100 +++++++++++++++++++ app/src/components/earn/earn-ui.test.ts | 28 ++++-- app/src/components/earn/reasons.ts | 41 ++++++++ app/src/components/earn/useEarn.ts | 6 +- app/src/i18n/messages/en.ts | 3 +- app/src/i18n/messages/en/earn.json | 115 ++++++++++++++++++++++ app/src/i18n/messages/zh-CN.ts | 3 +- app/src/i18n/messages/zh-CN/earn.json | 115 ++++++++++++++++++++++ 13 files changed, 481 insertions(+), 75 deletions(-) create mode 100644 app/src/components/earn/earn-i18n.test.ts create mode 100644 app/src/components/earn/reasons.ts create mode 100644 app/src/i18n/messages/en/earn.json create mode 100644 app/src/i18n/messages/zh-CN/earn.json diff --git a/app/src/app/ui-review-earn/EarnReview.tsx b/app/src/app/ui-review-earn/EarnReview.tsx index 5662d814..d2f1426e 100644 --- a/app/src/app/ui-review-earn/EarnReview.tsx +++ b/app/src/app/ui-review-earn/EarnReview.tsx @@ -4,6 +4,7 @@ import { useEffect } from "react"; import { useSearchParams } from "next/navigation"; import { useTheme } from "next-themes"; import { X } from "lucide-react"; +import { useTranslations } from "use-intl"; import { EarnCardView } from "@/components/earn/EarnCard"; import { EarnShell } from "@/components/earn/EarnDialog"; import EarnPanel, { type EarnView } from "@/components/earn/EarnPanel"; @@ -47,10 +48,12 @@ const scenes: { id: string; title: string; view: Partial; snap?: EarnV /** The sheet drawn in place (no modal), with one scene's synthetic state. */ function Panel({ view, snap }: { view: EarnView; snap: EarnVaultSnapshot | null }) { + // the sheet's own words (EarnDialog), so the review reads in either language like the real sheet + const t = useTranslations("earn.dialog"); return (

diff --git a/app/src/components/earn/EarnCard.tsx b/app/src/components/earn/EarnCard.tsx index 3eeb1b61..20c29d9a 100644 --- a/app/src/components/earn/EarnCard.tsx +++ b/app/src/components/earn/EarnCard.tsx @@ -4,6 +4,7 @@ import dynamic from "next/dynamic"; import { useEffect, useRef, useState } from "react"; import { ArrowDownToLine, ArrowUpFromLine, Landmark } from "lucide-react"; import type { Address } from "viem"; +import { useTranslations } from "use-intl"; import { btn } from "@/components/ui"; import { CHAIN_LABELS } from "@/lib/chainPublic"; import { fmtAmount, fmtRate } from "@/lib/earn/math"; @@ -59,6 +60,7 @@ function EarnCardLive({ vault, snapshot }: { vault: EarnVault; snapshot: EarnVau /** The card itself, with no wallet behind it (the review page renders it with fixtures). */ export function EarnCardView({ ref, vault, snapshot, balance, position, onOpen }: { ref?: React.Ref; vault: EarnVault; snapshot: EarnVaultSnapshot | null; balance: bigint | undefined; position: bigint | undefined; onOpen: (tab: EarnTab) => void }) { + const t = useTranslations("earn"); const sym = vault.assetSymbol; const amt = (raw: bigint | undefined) => (raw === undefined ? "…" : fmtAmount(raw, vault.assetDecimals)); const cell = (label: string, value: string, hint: string) => ( @@ -71,18 +73,18 @@ export function EarnCardView({ ref, vault, snapshot, balance, position, onOpen } return (
-

+

{CHAIN_LABELS[vault.chain]} · {vault.name}

- {cell("Last 30 days", fmtRate(snapshot?.rate ?? null), snapshot?.rate != null ? "Annualised. Variable." : "Rate unavailable right now")} - {cell(`${sym} in your wallet`, amt(balance), `On ${CHAIN_LABELS[vault.chain]}`)} - {cell("Your deposit", position === 0n ? "None" : amt(position), position !== undefined && position > 0n ? "What it withdraws for now" : "Nothing deposited yet")} + {cell(t("rate"), fmtRate(snapshot?.rate ?? null), snapshot?.rate != null ? t("rateHint") : t("rateUnavailable"))} + {cell(t("card.inWallet", { symbol: sym }), amt(balance), t("card.onChain", { chain: CHAIN_LABELS[vault.chain] }))} + {cell(t("yourDeposit"), position === 0n ? t("none") : amt(position), position !== undefined && position > 0n ? t("depositHint") : t("card.nothingYet"))}
- - -

Run by {vault.curator} on {vault.protocol}. Can lose money. No openlaunch fee.

+ + +

{t("card.runBy", { curator: vault.curator, protocol: vault.protocol })}

); diff --git a/app/src/components/earn/EarnDialog.tsx b/app/src/components/earn/EarnDialog.tsx index c1342372..22a79dbb 100644 --- a/app/src/components/earn/EarnDialog.tsx +++ b/app/src/components/earn/EarnDialog.tsx @@ -3,6 +3,7 @@ import { useRef, useState } from "react"; import { Dialog } from "@base-ui/react/dialog"; import { ArrowUpRight, X } from "lucide-react"; +import { useTranslations } from "use-intl"; import type { EarnVaultSnapshot } from "@/lib/earn/info"; import type { EarnVault } from "@/lib/earn/vaults"; import WalletPicker from "../WalletPicker"; @@ -14,6 +15,7 @@ import styles from "./EarnDialog.module.css"; * mid-transaction loses nothing: the transaction still confirms, the toast still fires, reopening shows where it is. */ export default function EarnDialog({ open, onOpenChange, earn, snapshot }: { open: boolean; onOpenChange: (open: boolean) => void; earn: EarnView; snapshot: EarnVaultSnapshot | null }) { + const t = useTranslations("earn.dialog"); const popup = useRef(null); const [connecting, setConnecting] = useState(false); const v = earn.vault; @@ -24,9 +26,9 @@ export default function EarnDialog({ open, onOpenChange, earn, snapshot }: { ope Earn on {v.assetSymbol}} - close={} - description={Lend your {v.assetSymbol} through a {v.protocol} vault on Base. Take it out whenever the vault has it free.}> + title={{t("title", { symbol: v.assetSymbol })}} + close={} + description={{t("description", { symbol: v.assetSymbol, protocol: v.protocol })}}> { onOpenChange(false); setConnecting(true); }} /> @@ -39,14 +41,15 @@ export default function EarnDialog({ open, onOpenChange, earn, snapshot }: { ope /** Heading, body and footer of the sheet; the review page renders it without a dialog around it. */ export function EarnShell({ vault, title, description, close, children }: { vault: EarnVault; title: React.ReactNode; description: React.ReactNode; close: React.ReactNode; children: React.ReactNode }) { + const t = useTranslations("earn.dialog"); return ( <>
{title}{close}
{description} {children}
); diff --git a/app/src/components/earn/EarnPanel.tsx b/app/src/components/earn/EarnPanel.tsx index d71971c3..a7bfd3f5 100644 --- a/app/src/components/earn/EarnPanel.tsx +++ b/app/src/components/earn/EarnPanel.tsx @@ -2,11 +2,13 @@ import Image from "next/image"; import { ArrowRight, ArrowUpRight, Check, CircleAlert, Clock3, LoaderCircle, PauseCircle, Wallet } from "lucide-react"; +import { useLocale, useTranslations } from "use-intl"; import { ToggleGroup, ToggleGroupItem } from "@/components/vendor/toggle-group"; import { CHAIN_LABELS, explorerAddress, explorerTx } from "@/lib/chainPublic"; import { fmtAmount, fmtFeePct, fmtRate } from "@/lib/earn/math"; import { depositPause, pausedCopy } from "@/lib/earn/terms"; import type { EarnVaultSnapshot } from "@/lib/earn/info"; +import { explainEarn } from "./reasons"; import type { Earn, EarnTab } from "./useEarn"; import styles from "./EarnDialog.module.css"; @@ -19,13 +21,17 @@ function compactAssets(raw: string, decimals: number): string { return new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 1 }).format(n); } -/** "Sep 14": a read's date for the rate's tooltip, in UTC like the chain. */ -function day(iso: string): string { - return new Date(iso).toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" }); +/** "Sep 14" (9月14日 in Chinese): a read's date for the rate's tooltip, in UTC like the chain. */ +function day(iso: string, locale: string): string { + return new Date(iso).toLocaleDateString(locale, { month: "short", day: "numeric", timeZone: "UTC" }); } /** The sheet's body: figures, Deposit/Withdraw, amount, vault facts, notices, the one action and the risk line. */ export default function EarnPanel({ view: e, snapshot, onConnect }: { view: EarnView; snapshot: EarnVaultSnapshot | null; onConnect: () => void }) { + const t = useTranslations("earn"); + const locale = useLocale(); + // the reasons lib/earn and useEarn give in English, in the reader's language (components/earn/reasons.ts) + const explain = (reason: string) => explainEarn(reason, (key, values) => t(`reasons.${key}`, values)); const v = e.vault; const network = CHAIN_LABELS[v.chain]; const sym = v.assetSymbol; @@ -37,23 +43,22 @@ export default function EarnPanel({ view: e, snapshot, onConnect }: { view: Earn if (e.phase.k === "unconfirmed") { const p = e.phase; - const what = p.tab === "deposit" ? "deposit" : "withdrawal"; return (
-

Sent, not confirmed yet

-

Your wallet sent the {what} of {amt(p.amount)} {sym}, but its confirmation could not be read. It may still go through. Do not send it again until this is settled.

+

{t("unconfirmed.title")}

+

{p.tab === "deposit" ? t("unconfirmed.deposit", { amount: amt(p.amount), symbol: sym }) : t("unconfirmed.withdraw", { amount: amt(p.amount), symbol: sym })}

- {p.sent.kind === "tx" ? View the transaction :

It was sent as one batch from your wallet; your wallet’s activity shows whether it went through.

} - {p.note ?

{p.note}

: null} + {p.sent.kind === "tx" ? {t("viewTx")} :

{t("unconfirmed.batch")}

} + {p.note ?

{explain(p.note)}

: null}
-

Closing this sheet keeps the check. Reloading the page forgets it, so look at your wallet’s activity before sending again.

+

{t("unconfirmed.keep")}

); } @@ -64,26 +69,26 @@ export default function EarnPanel({ view: e, snapshot, onConnect }: { view: Earn
-

{p.tab === "deposit" ? `Deposited ${amt(p.amount)} ${sym}` : `Withdrew ${amt(p.amount)} ${sym}`}

-

{p.tab === "deposit" ? `Your ${sym} is in ${v.name} on ${network}. Its value updates as the vault earns.` : `The ${sym} is back in your wallet on ${network}.`}

+

{p.tab === "deposit" ? t("done.deposited", { amount: amt(p.amount), symbol: sym }) : t("done.withdrew", { amount: amt(p.amount), symbol: sym })}

+

{p.tab === "deposit" ? t("done.depositedBody", { symbol: sym, vault: v.name, chain: network }) : t("done.withdrewBody", { symbol: sym, chain: network })}

- {p.hash ? View the transaction : null} - + {p.hash ? {t("viewTx")} : null} +
); } // a send in flight shows its progress even if the wallet changed network meanwhile: no switch offer until it settles - const label = !e.isConnected ? "Connect wallet" - : e.phase.k === "preparing" ? "Checking the vault…" - : e.phase.k === "approving" ? `Allow exactly ${e.amount !== null ? amt(e.amount) : ""} ${sym} in your wallet…` - : e.phase.k === "signing" ? "Confirm in your wallet…" - : e.phase.k === "sent" ? `Confirming on ${network}…` - : !e.onChain ? (e.switching ? "Confirm the network switch…" : `Switch to ${network}`) - : deposit && pauseReason ? "Deposits paused" - : e.problem ? e.problem - : deposit ? (e.needsApproval ? `Allow and deposit ${amt(e.amount!)} ${sym}` : `Deposit ${amt(e.amount!)} ${sym}`) - : everything ? "Withdraw everything" : `Withdraw ${amt(e.amount!)} ${sym}`; + const label = !e.isConnected ? t("action.connect") + : e.phase.k === "preparing" ? t("action.preparing") + : e.phase.k === "approving" ? t("action.approving", { amount: e.amount !== null ? amt(e.amount) : "", symbol: sym }) + : e.phase.k === "signing" ? t("action.signing") + : e.phase.k === "sent" ? t("action.sent", { chain: network }) + : !e.onChain ? (e.switching ? t("action.switching") : t("action.switch", { chain: network })) + : deposit && pauseReason ? t("action.paused") + : e.problem ? explain(e.problem) + : deposit ? (e.needsApproval ? t("action.allowAndDeposit", { amount: amt(e.amount!), symbol: sym }) : t("action.deposit", { amount: amt(e.amount!), symbol: sym })) + : everything ? t("action.withdrawAll") : t("action.withdraw", { amount: amt(e.amount!), symbol: sym }); const ready = e.isConnected && e.onChain && !e.busy && !e.problem && !(deposit && pauseReason); const clickable = !e.isConnected || (!e.onChain && !e.switching && !e.busy) || ready; const onClick = !e.isConnected ? onConnect : !e.onChain ? e.switchToVaultChain : e.submit; @@ -91,59 +96,57 @@ export default function EarnPanel({ view: e, snapshot, onConnect }: { view: Earn return (
{ event.preventDefault(); if (clickable) void onClick(); }}>
-
- Last 30 days +
+ {t("rate")} {fmtRate(snapshot?.rate ?? null)} - {snapshot?.rate != null ? "Annualised. Variable." : "Rate unavailable right now"} + {snapshot?.rate != null ? t("rateHint") : t("rateUnavailable")}
- Your deposit - {!e.isConnected ? "—" : e.position === undefined ? "…" : e.position === 0n ? "None" : amt(e.position)}{e.isConnected && e.position !== undefined && e.position > 0n ? {sym} : null} - {e.position !== undefined && e.position > 0n ? "What it withdraws for now" : `Nothing in ${v.name}`} + {t("yourDeposit")} + {!e.isConnected ? "—" : e.position === undefined ? "…" : e.position === 0n ? t("none") : amt(e.position)}{e.isConnected && e.position !== undefined && e.position > 0n ? {sym} : null} + {e.position !== undefined && e.position > 0n ? t("depositHint") : t("form.nothingIn", { vault: v.name })}
- { if (t[0] && !e.busy) e.setTab(t[0] as EarnTab); }} className={`${styles.tabs} grid w-full grid-cols-2`}> - {(["deposit", "withdraw"] as EarnTab[]).map((t) => {t === "deposit" ? "Deposit" : "Withdraw"})} + { if (tabs[0] && !e.busy) e.setTab(tabs[0] as EarnTab); }} className={`${styles.tabs} grid w-full grid-cols-2`}> + {(["deposit", "withdraw"] as EarnTab[]).map((tab) => {tab === "deposit" ? t("deposit") : t("withdraw")})}
- +
e.setAmount(event.target.value)} disabled={e.busy} aria-describedby="earn-balance earn-risk" /> {sym}
- {!e.isConnected ? `${sym} on ${network}` : deposit ? (e.balance === undefined ? "Reading your balance…" : `In your wallet: ${amt(e.balance)} ${sym}`) : (e.position === undefined ? "Reading your deposit…" : `Deposited: ${amt(e.position)} ${sym}`)} - {e.isConnected ? : null} + {!e.isConnected ? t("form.onChain", { symbol: sym, chain: network }) : deposit ? (e.balance === undefined ? t("form.readingBalance") : t("form.inWallet", { amount: amt(e.balance), symbol: sym })) : (e.position === undefined ? t("form.readingDeposit") : t("form.deposited", { amount: amt(e.position), symbol: sym }))} + {e.isConnected ? : null}
- - -
Protocol
{v.protocol}
-
Vault fee
{fmtFeePct(vaultFee)} of interest
-
openlaunch fee
None
-
In the vault
{snapshot ? `${compactAssets(snapshot.totalAssets, v.assetDecimals)} ${sym}` : "—"}
-
Network
{network} · gas in ETH
+
{t("facts.vault")}
{v.name}
+
{t("facts.runBy")}
{v.curator}
+
{t("facts.protocol")}
{v.protocol}
+
{t("facts.vaultFee")}
{t("facts.vaultFeeValue", { fee: fmtFeePct(vaultFee) })}
+
{t("facts.openlaunchFee")}
{t("none")}
+
{t("facts.inVault")}
{snapshot ? `${compactAssets(snapshot.totalAssets, v.assetDecimals)} ${sym}` : "—"}
+
{t("facts.network")}
{t("facts.networkValue", { chain: network })}
- {deposit && pauseReason ?

{pausedCopy(pauseReason)}

: null} - {deposit && !pauseReason && e.needsApproval && !e.busy && e.isConnected ?

The first deposit asks your wallet twice: once to allow the vault exactly this amount, then to deposit it. Smart wallets do both in one step.

: null} - {everything && !e.busy ?

Withdraws your whole deposit, with the interest earned up to the moment it confirms.

: null} - {e.isConnected && e.noGas ?

This wallet has no ETH on {network} to pay network gas.

: null} - {e.unreadable ?

Your balances could not be read just now. They refresh on their own.

: null} - {e.phase.k === "error" ?

{e.phase.message}

: null} + {deposit && pauseReason ?

{explain(pausedCopy(pauseReason))}

: null} + {deposit && !pauseReason && e.needsApproval && !e.busy && e.isConnected ?

{t("notice.approval")}

: null} + {everything && !e.busy ?

{t("notice.everything")}

: null} + {e.isConnected && e.noGas ?

{t("notice.noGas", { chain: network })}

: null} + {e.unreadable ?

{t("notice.unreadable")}

: null} + {e.phase.k === "error" ?

{explain(e.phase.message)}

: null} -

- Deposits can lose money. Your {sym} is lent to borrowers through {v.protocol}, in markets chosen by {v.curator}, not by openlaunch. openlaunch never holds your funds, cannot move them and cannot guarantee a withdrawal: the vault pays out from what is not lent at that moment. The rate changes all the time; past rates promise nothing. -

+

{t("disclaimer", { symbol: sym, protocol: v.protocol, curator: v.curator })}

); } diff --git a/app/src/components/earn/earn-flow.test.ts b/app/src/components/earn/earn-flow.test.ts index f6b864c3..8342c85a 100644 --- a/app/src/components/earn/earn-flow.test.ts +++ b/app/src/components/earn/earn-flow.test.ts @@ -3,6 +3,8 @@ import { readFileSync } from "node:fs"; import test from "node:test"; import ts from "typescript"; import { zeroAddress, type Address } from "viem"; +import { createTranslator } from "use-intl/core"; +import { en } from "@/i18n/messages"; import { BUILDER_DATA_SUFFIX } from "@/lib/chainPublic"; import { ERC20_MIN_ABI } from "@/lib/launchpad/abi"; import { EARN_VAULT_ABI } from "@/lib/earn/abi"; @@ -129,6 +131,8 @@ function world(o: { BATCH_FAILED, EARN_BATCH_CAPABILITIES, ERC20_MIN_ABI, EARN_VAULT_ABI, BUILDER_DATA_SUFFIX, NOT_CONFIRMED_YET, revertedCopy, // like React's setter: a value, or an updater given the current phase setPhase: (p: EarnPhase | ((cur: EarnPhase) => EarnPhase)) => phases.push(typeof p === "function" ? p(phases.at(-1) ?? { k: "idle" }) : p), setText: () => {}, setAll: () => {}, toast: () => { toasts++; }, + // the hook's toast words (`tr`), read from the English messages + tr: createTranslator({ locale: "en", messages: en, namespace: "earn.toast", timeZone: "UTC" }), friendlyError: (e: Error) => e.message, }; if (o.tab === "withdraw") Object.assign(bindings, { amount: 1_000_000_000n }); diff --git a/app/src/components/earn/earn-i18n.test.ts b/app/src/components/earn/earn-i18n.test.ts new file mode 100644 index 00000000..dd627ff0 --- /dev/null +++ b/app/src/components/earn/earn-i18n.test.ts @@ -0,0 +1,100 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { zeroAddress } from "viem"; +import { createTranslator } from "use-intl/core"; +import { en, messagesFor } from "@/i18n/messages"; +import { BATCH_FAILED } from "@/lib/earn/calls"; +import { depositProblem, withdrawProblem } from "@/lib/earn/math"; +import { NOT_CONFIRMED_YET, revertedCopy } from "@/lib/earn/phase"; +import { pausedCopy, termsCheck, type LiveTerms } from "@/lib/earn/terms"; +import { EARN_VAULTS } from "@/lib/earn/vaults"; +import { explainEarn } from "./reasons"; + +/** + * Earn in two languages. lib/earn and useEarn give their reasons in English (and the server's terms check sends its + * reason already worded); EarnPanel shows each known one as a message (earn.reasons) through explainEarn. These keep + * the two in step: every message stands for a reason the code really gives, and every reason finds its message, reads + * back exactly in English and reads in Chinese with the values it carried. + */ +const vault = EARN_VAULTS.base!; +const asListed: LiveTerms = { asset: vault.asset, performanceFee: 0n, managementFee: 0n, gates: [zeroAddress, zeroAddress, zeroAddress, zeroAddress] }; +const reasonOf = (check: ReturnType) => (check.ok ? assert.fail("expected a reason") : check.reason); +const gated = [zeroAddress, zeroAddress, zeroAddress, "0x00000000000000000000000000000000000000aa"] as `0x${string}`[]; + +/** Every reason Earn's code gives, produced by the code itself where it can be called. */ +const terms = [ + reasonOf(termsCheck(vault, { ...asListed, asset: "0x0000000000000000000000000000000000000001" })), + reasonOf(termsCheck(vault, { ...asListed, performanceFee: 100_000_000_000_000_000n })), + reasonOf(termsCheck(vault, { ...asListed, managementFee: 10_000_000_000_000_000n / 31_536_000n })), + reasonOf(termsCheck(vault, { ...asListed, gates: gated })), +]; +const hook = readFileSync(new URL("./useEarn.ts", import.meta.url), "utf8"); +// the hook's own stops and throws, as its source writes them +const hookReasons = [...hook.matchAll(/(?:const (?:ACCOUNT_CHANGED|FLOW_STOPPED) = |throw new Error\()"([^"]+)"/g)].map((m) => m[1]); +const given = [ + depositProblem({ amount: null, balance: 1n }), + depositProblem({ amount: 1n, balance: undefined }), + depositProblem({ amount: 2n, balance: 1n }), + withdrawProblem({ amount: 1n, shares: undefined, position: undefined }), + withdrawProblem({ amount: 1n, shares: 0n, position: 0n }), + withdrawProblem({ amount: 2n, shares: 1n, position: 1n }), + NOT_CONFIRMED_YET, + BATCH_FAILED, + revertedCopy("deposit"), + revertedCopy("withdraw"), + ...terms, + ...hookReasons, +].filter((reason): reason is string => typeof reason === "string"); + +const english = createTranslator({ locale: "en", messages: en, namespace: "earn.reasons", timeZone: "UTC" }); +const chinese = createTranslator({ locale: "zh-CN", messages: messagesFor("zh-CN"), namespace: "earn.reasons", timeZone: "UTC" }); +type Translate = Parameters[1]; +const inEnglish: Translate = (key, values) => english(key, values); +const inChinese: Translate = (key, values) => chinese(key, values); + +test("every Earn reason the panel translates is one the code really gives", () => { + assert.equal(hookReasons.length, 6, "the hook's account and unmount stops and its four throws"); + const keys = new Set(); + for (const reason of [...given, pausedCopy(terms[3])]) { + // the outer message is worded last (a paused sentence words its reason first) + let key = ""; + explainEarn(reason, (k, values) => { key = k; return english(k, values); }); + assert.ok(key, `"${reason}" has a message`); + keys.add(key); + } + assert.deepEqual([...keys].sort(), Object.keys(en.earn.reasons).sort(), "every message is a reason the code gives"); +}); + +test("each reason reads back exactly in English, and in Chinese with the values it carried", () => { + for (const reason of given) { + assert.equal(explainEarn(reason, inEnglish), reason); + const zh = explainEarn(reason, inChinese); + assert.notEqual(zh, reason, `"${reason}" reads in Chinese`); + assert.match(zh, /[一-鿿]/); + } + assert.equal(explainEarn(terms[0], inChinese), "该金库不再接受 USDC。"); + assert.equal(explainEarn(terms[1], inChinese), "该金库现在会保留其所赚利息的 10%。"); + assert.equal(explainEarn(terms[2], inChinese), "该金库现在收取 1% 的年度管理费。"); + // a wallet's own words, or anything else, read as they came + assert.equal(explainEarn("User rejected the request.", inChinese), "User rejected the request."); +}); + +test("a paused deposit reads its reason inside the sentence, in either language", () => { + for (const reason of terms) { + const paused = pausedCopy(reason); + assert.equal(explainEarn(paused, inEnglish), paused); + const zh = explainEarn(paused, inChinese); + assert.ok(zh.startsWith("此处已暂停存款:"), zh); + assert.ok(zh.includes(explainEarn(reason, inChinese)), zh); + assert.ok(zh.endsWith("取款仍然开放。"), zh); + } +}); + +test("the panel words every reason it shows through explainEarn", () => { + const panel = readFileSync(new URL("./EarnPanel.tsx", import.meta.url), "utf8"); + assert.match(panel, /\{explain\(p\.note\)\}/); + assert.match(panel, /: e\.problem \? explain\(e\.problem\)/); + assert.match(panel, /\{explain\(pausedCopy\(pauseReason\)\)\}/); + assert.match(panel, /\{explain\(e\.phase\.message\)\}/); +}); diff --git a/app/src/components/earn/earn-ui.test.ts b/app/src/components/earn/earn-ui.test.ts index 8d8be58c..1bfd96b1 100644 --- a/app/src/components/earn/earn-ui.test.ts +++ b/app/src/components/earn/earn-ui.test.ts @@ -1,9 +1,11 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; +import { en, messagesFor } from "@/i18n/messages"; // Source contracts for the Earn components (like bridge-ui.test.ts): they hold the money-moving rules in place. -const read = (path: string) => readFileSync(new URL(path, import.meta.url), "utf8"); +// Line endings are normalised so the patterns hold on a Windows checkout too. +const read = (path: string) => readFileSync(new URL(path, import.meta.url), "utf8").replaceAll("\r\n", "\n"); const hook = read("./useEarn.ts"); const card = read("./EarnCard.tsx"); const dialog = read("./EarnDialog.tsx"); @@ -108,14 +110,26 @@ test("the card shows only while the switch is on, and the sheet loads on first u }); test("the copy states the risk and the absence of any openlaunch fee, and makes no promise", () => { - assert.match(panel, /Deposits can lose money\./); - assert.match(panel, /openlaunch never holds your funds, cannot move them and cannot guarantee a withdrawal/); - assert.match(panel, /
openlaunch fee<\/dt>
None<\/dd>/); - assert.match(dialog, /0 openlaunch fee/); - assert.match(card, /Can lose money\. No openlaunch fee\./); - for (const src of [card, dialog, panel]) { + // the words are messages (earn); the components show them in the reader's language + const words = en.earn; + const zh = messagesFor("zh-CN").earn; + assert.match(panel, /

\{t\("disclaimer", /); + assert.match(words.disclaimer, /^Deposits can lose money\./); + assert.match(words.disclaimer, /openlaunch never holds your funds, cannot move them and cannot guarantee a withdrawal/); + assert.match(zh.disclaimer, /^存款可能亏损。/); + assert.match(zh.disclaimer, /openlaunch 从不持有你的资金,无法转移它们,也无法保证你一定能取出/); + assert.match(panel, /

\{t\("facts\.openlaunchFee"\)\}<\/dt>
\{t\("none"\)\}<\/dd>/); + assert.equal(words.facts.openlaunchFee, "openlaunch fee"); + assert.equal(words.none, "None"); + assert.match(dialog, /\{t\("noFee"\)\}<\/span>/); + assert.equal(words.dialog.noFee, "0 openlaunch fee"); + assert.match(card, /\{t\("card\.runBy", /); + assert.match(words.card.runBy, /Can lose money\. No openlaunch fee\.$/); + assert.match(zh.card.runBy, /可能亏损。openlaunch 不收费。$/); + for (const src of [card, dialog, panel, JSON.stringify(words)]) { assert.doesNotMatch(src, /Base Earn|Coinbase|high[- ]yield|guaranteed (?:return|yield|rate)|risk[- ]free|\bsafe\b/i); } + assert.doesNotMatch(JSON.stringify(zh), /保本|保证收益|稳赚|无风险|零风险|高收益|安全/); }); test("the review page is development-only", () => { diff --git a/app/src/components/earn/reasons.ts b/app/src/components/earn/reasons.ts new file mode 100644 index 00000000..6fca2dfb --- /dev/null +++ b/app/src/components/earn/reasons.ts @@ -0,0 +1,41 @@ +import { en, type Messages } from "@/i18n/messages/en"; + +export type EarnReason = keyof Messages["earn"]["reasons"]; +type Translate = (key: EarnReason, values?: Record) => string; + +/** + * Why Earn cannot go ahead, or what became of a send, as messages (earn.reasons). The reasons arrive as English text + * from lib/earn (depositProblem and withdrawProblem in math.ts, termsCheck and pausedCopy in terms.ts, phase.ts, + * calls.ts), from useEarn's own stops and throws, and from the server's terms check, which the card receives already + * worded. Each English message reads exactly as its reason, so the reason finds its message; the libs keep their + * English. friendlyError's reasons arrive in the reader's language already, and anything else (a wallet's own error) + * reads as it came. Server-safe and pure, so the tests can read every reason back. + */ +const EXACT = new Map( + Object.entries(en.earn.reasons) + .filter(([, text]) => !text.includes("{")) + .map(([key, text]) => [text, key as EarnReason]), +); + +/** The reasons with a value in them, read back from their English wording. */ +const WITH_VALUES: readonly [RegExp, EarnReason, (m: RegExpExecArray) => Record][] = [ + [/^The vault no longer takes (\S+)\.$/, "asset", (m) => ({ symbol: m[1] })], + [/^The vault now keeps (\S+) of the interest it earns\.$/, "performanceFee", (m) => ({ fee: m[1] })], + [/^The vault now charges a (\S+) yearly management fee\.$/, "managementFee", (m) => ({ fee: m[1] })], +]; + +/** pausedCopy's sentence, around the reason it carries. */ +const PAUSED = /^Deposits are paused here: (.+) openlaunch listed this vault without that and has not reviewed the change\. Withdrawals stay open\.$/; + +/** A reason in the reader's language: its message when it is one Earn gives, otherwise the text as it came. */ +export function explainEarn(reason: string, t: Translate): string { + const paused = PAUSED.exec(reason); + if (paused) return t("paused", { reason: explainEarn(paused[1], t) }); + const key = EXACT.get(reason); + if (key) return t(key); + for (const [pattern, withValues, values] of WITH_VALUES) { + const m = pattern.exec(reason); + if (m) return t(withValues, values(m)); + } + return reason; +} diff --git a/app/src/components/earn/useEarn.ts b/app/src/components/earn/useEarn.ts index 6ecfbb3f..94727201 100644 --- a/app/src/components/earn/useEarn.ts +++ b/app/src/components/earn/useEarn.ts @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react"; import { useBalance, useConfig, useReadContract, useReadContracts, useSwitchChain } from "wagmi"; +import { useTranslations } from "use-intl"; import { getAccount, getCallsStatus, getCapabilities, getPublicClient, getWalletClient, sendCalls, waitForCallsStatus } from "wagmi/actions"; import type { Address, Hex, PublicClient } from "viem"; import { useHydratedAccount } from "@/lib/useHydratedAccount"; @@ -60,6 +61,8 @@ export function useEarnPosition(vault: EarnVault, address: Address | undefined) /** Earn's form state, its reads and its two actions for one vault and the connected wallet (see the file comment). */ export default function useEarn(vault: EarnVault, initialTab: EarnTab) { const config = useConfig(); + // the confirmation toast's words; the reasons this hook gives stay English and are worded where shown (EarnPanel) + const tr = useTranslations("earn.toast"); const chainId = CHAINS[vault.chain].id; const { address, isConnected, chainId: walletChain } = useHydratedAccount(); const { switchChainAsync, isPending: switching } = useSwitchChain(); @@ -243,7 +246,8 @@ export default function useEarn(vault: EarnVault, initialTab: EarnTab) { /** A confirmed action: the done state, a toast, a clean form and fresh balances. */ function finish(tab: EarnTab, amount: bigint, hash: Hex | null) { setPhase({ k: "done", hash, tab, amount }); - toast({ kind: "info", title: `${tab === "deposit" ? "Deposited" : "Withdrew"} ${fmtAmount(amount, decimals)} ${vault.assetSymbol}`, sub: `${tab === "deposit" ? "Into" : "From"} ${vault.name}` }); + const words = { amount: fmtAmount(amount, decimals), symbol: vault.assetSymbol }; + toast({ kind: "info", title: tab === "deposit" ? tr("deposited", words) : tr("withdrew", words), sub: tab === "deposit" ? tr("into", { vault: vault.name }) : tr("from", { vault: vault.name }) }); setText(""); setAll(false); void position.refetch(); diff --git a/app/src/i18n/messages/en.ts b/app/src/i18n/messages/en.ts index 24a2e7a3..2cd6dd21 100644 --- a/app/src/i18n/messages/en.ts +++ b/app/src/i18n/messages/en.ts @@ -14,13 +14,14 @@ import info from "./en/info.json"; import account from "./en/account.json"; import bridge from "./en/bridge.json"; import errors from "./en/errors.json"; +import earn from "./en/earn.json"; import footer from "./en/footer.json"; /** * English, the source text: it fixes the keys (and their types) and is the fallback for any key a translation leaves * out. One JSON file per area of the site. It ships with the client code, so English needs nothing sent per page. */ -export const en = { common, header, wallet, search, market, activity, hero, posts, token, trade, launch, info, account, bridge, errors, footer }; +export const en = { common, header, wallet, search, market, activity, hero, posts, token, trade, launch, info, account, bridge, earn, errors, footer }; export type Messages = typeof en; /** A translation: any subset of the English keys. */ diff --git a/app/src/i18n/messages/en/earn.json b/app/src/i18n/messages/en/earn.json new file mode 100644 index 00000000..cf942b47 --- /dev/null +++ b/app/src/i18n/messages/en/earn.json @@ -0,0 +1,115 @@ +{ + "rate": "Last 30 days", + "rateHint": "Annualised. Variable.", + "rateUnavailable": "Rate unavailable right now", + "rateTitle": "Share price growth from {from} to {to}, net of the vault's fees", + "yourDeposit": "Your deposit", + "none": "None", + "depositHint": "What it withdraws for now", + "deposit": "Deposit", + "withdraw": "Withdraw", + "card": { + "title": "Earn on idle {symbol}", + "inWallet": "{symbol} in your wallet", + "onChain": "On {chain}", + "nothingYet": "Nothing deposited yet", + "runBy": "Run by {curator} on {protocol}. Can lose money. No openlaunch fee." + }, + "dialog": { + "title": "Earn on {symbol}", + "close": "Close", + "description": "Lend your {symbol} through a {protocol} vault on Base. Take it out whenever the vault has it free.", + "runBy": "Run by {curator}", + "noFee": "0 openlaunch fee" + }, + "unconfirmed": { + "title": "Sent, not confirmed yet", + "deposit": "Your wallet sent the deposit of {amount} {symbol}, but its confirmation could not be read. It may still go through. Do not send it again until this is settled.", + "withdraw": "Your wallet sent the withdrawal of {amount} {symbol}, but its confirmation could not be read. It may still go through. Do not send it again until this is settled.", + "batch": "It was sent as one batch from your wallet; your wallet’s activity shows whether it went through.", + "checking": "Checking…", + "checkAgain": "Check again", + "keep": "Closing this sheet keeps the check. Reloading the page forgets it, so look at your wallet’s activity before sending again." + }, + "done": { + "deposited": "Deposited {amount} {symbol}", + "withdrew": "Withdrew {amount} {symbol}", + "depositedBody": "Your {symbol} is in {vault} on {chain}. Its value updates as the vault earns.", + "withdrewBody": "The {symbol} is back in your wallet on {chain}.", + "close": "Done" + }, + "viewTx": "View the transaction", + "action": { + "connect": "Connect wallet", + "preparing": "Checking the vault…", + "approving": "Allow exactly {amount} {symbol} in your wallet…", + "signing": "Confirm in your wallet…", + "sent": "Confirming on {chain}…", + "switching": "Confirm the network switch…", + "switch": "Switch to {chain}", + "paused": "Deposits paused", + "allowAndDeposit": "Allow and deposit {amount} {symbol}", + "deposit": "Deposit {amount} {symbol}", + "withdrawAll": "Withdraw everything", + "withdraw": "Withdraw {amount} {symbol}" + }, + "form": { + "nothingIn": "Nothing in {vault}", + "tabs": "Deposit or withdraw", + "youDeposit": "You deposit", + "youWithdraw": "You withdraw", + "onChain": "{symbol} on {chain}", + "readingBalance": "Reading your balance…", + "inWallet": "In your wallet: {amount} {symbol}", + "readingDeposit": "Reading your deposit…", + "deposited": "Deposited: {amount} {symbol}", + "max": "Max" + }, + "facts": { + "vault": "Vault", + "runBy": "Run by", + "protocol": "Protocol", + "vaultFee": "Vault fee", + "vaultFeeValue": "{fee} of interest", + "openlaunchFee": "openlaunch fee", + "inVault": "In the vault", + "network": "Network", + "networkValue": "{chain} · gas in ETH" + }, + "notice": { + "approval": "The first deposit asks your wallet twice: once to allow the vault exactly this amount, then to deposit it. Smart wallets do both in one step.", + "everything": "Withdraws your whole deposit, with the interest earned up to the moment it confirms.", + "noGas": "This wallet has no ETH on {chain} to pay network gas.", + "unreadable": "Your balances could not be read just now. They refresh on their own." + }, + "disclaimer": "Deposits can lose money. Your {symbol} is lent to borrowers through {protocol}, in markets chosen by {curator}, not by openlaunch. openlaunch never holds your funds, cannot move them and cannot guarantee a withdrawal: the vault pays out from what is not lent at that moment. The rate changes all the time; past rates promise nothing.", + "toast": { + "deposited": "Deposited {amount} {symbol}", + "withdrew": "Withdrew {amount} {symbol}", + "into": "Into {vault}", + "from": "From {vault}" + }, + "reasons": { + "enterAmount": "Enter an amount", + "readingBalance": "Reading your balance…", + "moreThanBalance": "More than your USDC", + "readingDeposit": "Reading your deposit…", + "nothingDeposited": "Nothing deposited", + "moreThanDeposit": "More than your deposit", + "moreThanHeld": "That is more USDC than this wallet holds.", + "approvalUnreadable": "The approval's confirmation could not be read. Nothing was deposited; try again (an approval that went through is reused).", + "approvalReverted": "The approval reverted on-chain. Nothing was deposited.", + "nothingInWallet": "Nothing is deposited from this wallet.", + "accountChanged": "Your wallet switched accounts, so this stopped before anything else was sent. Start again from the account you want to use.", + "flowStopped": "Stopped: the Earn card closed before the next step.", + "notConfirmedYet": "Still no confirmation. It can take a while when the network is busy; check again in a moment, or look at your wallet's activity.", + "batchFailed": "Your wallet reports the transaction failed. Nothing moved; check its activity before trying again.", + "depositReverted": "The deposit reverted on-chain. Nothing moved; network gas was still charged.", + "withdrawalReverted": "The withdrawal reverted on-chain. Nothing moved; network gas was still charged.", + "gates": "The vault now restricts who may use it.", + "asset": "The vault no longer takes {symbol}.", + "performanceFee": "The vault now keeps {fee} of the interest it earns.", + "managementFee": "The vault now charges a {fee} yearly management fee.", + "paused": "Deposits are paused here: {reason} openlaunch listed this vault without that and has not reviewed the change. Withdrawals stay open." + } +} diff --git a/app/src/i18n/messages/zh-CN.ts b/app/src/i18n/messages/zh-CN.ts index bb96d6a1..21b29f4c 100644 --- a/app/src/i18n/messages/zh-CN.ts +++ b/app/src/i18n/messages/zh-CN.ts @@ -14,7 +14,8 @@ import info from "./zh-CN/info.json"; import account from "./zh-CN/account.json"; import bridge from "./zh-CN/bridge.json"; import errors from "./zh-CN/errors.json"; +import earn from "./zh-CN/earn.json"; import footer from "./zh-CN/footer.json"; /** Simplified Chinese. Loaded on the server for Chinese pages and in the browser only when someone switches to it. */ -export const zhCN: Translation = { common, header, wallet, search, market, activity, hero, posts, token, trade, launch, info, account, bridge, errors, footer }; +export const zhCN: Translation = { common, header, wallet, search, market, activity, hero, posts, token, trade, launch, info, account, bridge, earn, errors, footer }; diff --git a/app/src/i18n/messages/zh-CN/earn.json b/app/src/i18n/messages/zh-CN/earn.json new file mode 100644 index 00000000..266e11ad --- /dev/null +++ b/app/src/i18n/messages/zh-CN/earn.json @@ -0,0 +1,115 @@ +{ + "rate": "近 30 天", + "rateHint": "年化,浮动。", + "rateUnavailable": "暂时无法获取收益率", + "rateTitle": "{from} 至 {to} 的份额价格增长,已扣除金库费用", + "yourDeposit": "你的存款", + "none": "无", + "depositHint": "当前可取出的金额", + "deposit": "存入", + "withdraw": "取出", + "card": { + "title": "用闲置 {symbol} 赚取收益", + "inWallet": "钱包中的 {symbol}", + "onChain": "在 {chain} 上", + "nothingYet": "尚未存入", + "runBy": "由 {curator} 在 {protocol} 上管理。可能亏损。openlaunch 不收费。" + }, + "dialog": { + "title": "用 {symbol} 赚取收益", + "close": "关闭", + "description": "通过 Base 上的 {protocol} 金库借出你的 {symbol}。只要金库有可用资金,随时可以取出。", + "runBy": "由 {curator} 管理", + "noFee": "openlaunch 费用为 0" + }, + "unconfirmed": { + "title": "已发送,尚未确认", + "deposit": "你的钱包已发送 {amount} {symbol} 的存入,但无法读取其确认结果。它仍可能成功。在结果明确之前,请不要再次发送。", + "withdraw": "你的钱包已发送 {amount} {symbol} 的取出,但无法读取其确认结果。它仍可能成功。在结果明确之前,请不要再次发送。", + "batch": "它是从你的钱包作为一个批次发送的;你钱包的活动记录会显示它是否成功。", + "checking": "正在检查…", + "checkAgain": "再次检查", + "keep": "关闭此面板不会中断检查。重新加载页面会丢失检查状态,所以再次发送前请先查看钱包的活动记录。" + }, + "done": { + "deposited": "已存入 {amount} {symbol}", + "withdrew": "已取出 {amount} {symbol}", + "depositedBody": "你的 {symbol} 已存入 {chain} 上的 {vault}。其价值会随金库收益而更新。", + "withdrewBody": "{symbol} 已回到你在 {chain} 上的钱包。", + "close": "完成" + }, + "viewTx": "查看交易", + "action": { + "connect": "连接钱包", + "preparing": "正在检查金库…", + "approving": "请在钱包中授权恰好 {amount} {symbol}…", + "signing": "请在钱包中确认…", + "sent": "正在 {chain} 上确认…", + "switching": "请确认切换网络…", + "switch": "切换到 {chain}", + "paused": "存款已暂停", + "allowAndDeposit": "授权并存入 {amount} {symbol}", + "deposit": "存入 {amount} {symbol}", + "withdrawAll": "全部取出", + "withdraw": "取出 {amount} {symbol}" + }, + "form": { + "nothingIn": "{vault} 中没有存款", + "tabs": "存入或取出", + "youDeposit": "存入金额", + "youWithdraw": "取出金额", + "onChain": "{chain} 上的 {symbol}", + "readingBalance": "正在读取你的余额…", + "inWallet": "钱包中:{amount} {symbol}", + "readingDeposit": "正在读取你的存款…", + "deposited": "已存入:{amount} {symbol}", + "max": "最大" + }, + "facts": { + "vault": "金库", + "runBy": "管理方", + "protocol": "协议", + "vaultFee": "金库费用", + "vaultFeeValue": "利息的 {fee}", + "openlaunchFee": "openlaunch 费用", + "inVault": "金库总额", + "network": "网络", + "networkValue": "{chain} · 以 ETH 支付 Gas" + }, + "notice": { + "approval": "首次存款需要钱包确认两次:一次授权金库使用恰好这个金额,一次存入。智能钱包会一步完成。", + "everything": "取出你的全部存款,包括截至确认时赚取的利息。", + "noGas": "此钱包在 {chain} 上没有 ETH 支付网络 Gas。", + "unreadable": "暂时无法读取你的余额,稍后会自动刷新。" + }, + "disclaimer": "存款可能亏损。你的 {symbol} 会通过 {protocol} 借给借款人,所用市场由 {curator} 选择,而不是 openlaunch。openlaunch 从不持有你的资金,无法转移它们,也无法保证你一定能取出:金库只能用当时未借出的资金支付。收益率随时变化;过去的收益率不代表未来。", + "toast": { + "deposited": "已存入 {amount} {symbol}", + "withdrew": "已取出 {amount} {symbol}", + "into": "存入 {vault}", + "from": "取自 {vault}" + }, + "reasons": { + "enterAmount": "请输入金额", + "readingBalance": "正在读取你的余额…", + "moreThanBalance": "超过你的 USDC 余额", + "readingDeposit": "正在读取你的存款…", + "nothingDeposited": "没有存款", + "moreThanDeposit": "超过你的存款", + "moreThanHeld": "这超过了此钱包持有的 USDC。", + "approvalUnreadable": "无法读取授权的确认结果。没有存入任何资金;请重试(已成功的授权会被沿用)。", + "approvalReverted": "授权在链上被回滚。没有存入任何资金。", + "nothingInWallet": "此钱包没有任何存款。", + "accountChanged": "你的钱包切换了账户,所以在发送任何其他内容之前已停止。请从你要使用的账户重新开始。", + "flowStopped": "已停止:在下一步之前,赚取收益卡片已关闭。", + "notConfirmedYet": "仍未确认。网络繁忙时可能需要一段时间;请稍后再检查,或查看你钱包的活动记录。", + "batchFailed": "你的钱包报告交易失败。没有资金移动;再次尝试前请先查看其活动记录。", + "depositReverted": "存款在链上被回滚。没有资金移动;网络 Gas 仍已扣除。", + "withdrawalReverted": "取款在链上被回滚。没有资金移动;网络 Gas 仍已扣除。", + "gates": "该金库现在限制了谁可以使用。", + "asset": "该金库不再接受 {symbol}。", + "performanceFee": "该金库现在会保留其所赚利息的 {fee}。", + "managementFee": "该金库现在收取 {fee} 的年度管理费。", + "paused": "此处已暂停存款:{reason}openlaunch 上架此金库时没有这一项,且尚未审核这一变更。取款仍然开放。" + } +}