Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
"@noble/hashes": "^1.8.0",
"@scure/base": "^2.2.0",
"@scure/btc-signer": "^2.2.0",
"@xcp/wallet-sdk": "github:XCP/wallet-sdk#v0.4.1",
"@xcp/wallet-sdk": "github:XCP/wallet-sdk#79f190308d4b60cc3ec9023beada21e0ee776213",
"babel-plugin-react-compiler": "^1.0.0",
"bignumber.js": "^9.3.1",
"next": "16.3.4",
Expand Down
69 changes: 69 additions & 0 deletions apps/web/scripts/i18n-catalog.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import ts from "typescript";

/** Static alternatives at a translation call, including singular/plural
* branches. Reading syntax also decodes escapes and ignores comments. */
function literals(node) {
if (!node) return [];
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return [node.text];
if (ts.isConditionalExpression(node)) return [...literals(node.whenTrue), ...literals(node.whenFalse)];
if (ts.isParenthesizedExpression(node) || ts.isAsExpression(node) || ts.isSatisfiesExpression(node)) {
return literals(node.expression);
}
return [];
}

export function collectMessages(source, filename = "source.tsx") {
const file = ts.createSourceFile(filename, source, ts.ScriptTarget.Latest, true,
filename.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS);
const keys = new Set();
const indirect = [];
function visit(node) {
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) {
const name = node.expression.text;
const args = node.arguments;
const isRich = name === "rich" && args[0] && ts.isIdentifier(args[0]) && args[0].text === "t";
if (name === "t" || name === "msg" || isRich) {
const text = args[isRich ? 1 : 0];
const values = literals(text);
// t(text, "context"), t(text, vars, "context"), or
// rich(t, text, nodes, "context") match the runtime API.
const contexts = isRich ? literals(args[3]) : name === "t"
? (literals(args[1]).length ? literals(args[1]) : literals(args[2])) : [];
for (const value of values) {
if (!value.trim()) continue;
for (const context of contexts.length ? contexts : [undefined]) {
keys.add(context ? `${value}@@${context}` : value);
}
}
if (name === "t" && text && !values.length) indirect.push(text.getText(file));
}
}
ts.forEachChild(node, visit);
}
visit(file);
return { keys: [...keys], indirect };
}

/** A reviewer removes a key from status.machine after checking it. Never
* replace that regional wording, or guess that an untracked edit is a draft.
* Keep regional-only entries too; extraction reports obsolete keys for review. */
export function mergeDerivedMessages(derived, existing = {}, status = { machine: [] }) {
const previousMachine = new Set(status.machine ?? []);
const machine = new Set(previousMachine);
const messages = { ...existing };
for (const [key, value] of Object.entries(derived)) {
if (Object.hasOwn(existing, key) && !machine.has(key)) continue;
messages[key] = value;
machine.add(key);
}
return {
messages: Object.fromEntries(Object.entries(messages).sort(([a], [b]) => a.localeCompare(b))),
status: {
...status,
machine: [
...[...previousMachine].filter((key) => Object.hasOwn(messages, key)),
...[...machine].filter((key) => !previousMachine.has(key)).sort(),
],
},
};
}
19 changes: 13 additions & 6 deletions apps/web/scripts/i18n-derive-zh.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,14 @@
// Derive zh-tw and zh-hk from the Simplified draft with OpenCC's regional
// phrase tables, then apply the glossary's own renderings where OpenCC
// disagrees, plus a few known OpenCC quirks. Writes both locale files and
// their status lists.
import { readFileSync, writeFileSync } from "node:fs";
// their status lists. Reviewed regional wording is kept: a reviewer removes
// its key from status.machine, as in the other translation tools.
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import * as OpenCC from "opencc-js";
// eslint-disable-next-line no-restricted-imports -- Node scripts cannot resolve the app's @/ alias.
import { mergeDerivedMessages } from "./i18n-catalog.mjs";

const read = (path, fallback) => existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) : fallback;

const zh = JSON.parse(readFileSync("src/locales/zh.json", "utf8"));
const glossary = JSON.parse(readFileSync("src/locales/glossary.json", "utf8")).terms;
Expand Down Expand Up @@ -47,8 +52,10 @@ for (const [variant, to] of [["zh-tw", "twp"], ["zh-hk", "hk"]]) {
for (const [from, to2] of [...QUIRKS[variant], ...overrides]) s = s.split(from).join(to2);
out[key] = s;
}
const sorted = Object.fromEntries(Object.entries(out).sort(([a], [b]) => a.localeCompare(b)));
writeFileSync(`src/locales/${variant}.json`, JSON.stringify(sorted, null, 2) + "\n");
writeFileSync(`src/locales/${variant}.status.json`, JSON.stringify({ machine: Object.keys(sorted) }, null, 2) + "\n");
console.log(variant, Object.keys(sorted).length, "entries; glossary overrides:", overrides.map(([a, b]) => `${a}→${b}`).join(" "));
const messagesPath = `src/locales/${variant}.json`;
const statusPath = `src/locales/${variant}.status.json`;
const merged = mergeDerivedMessages(out, read(messagesPath, {}), read(statusPath, { machine: [] }));
writeFileSync(messagesPath, JSON.stringify(merged.messages, null, 2) + "\n");
writeFileSync(statusPath, JSON.stringify(merged.status, null, 2) + "\n");
console.log(variant, Object.keys(merged.messages).length, "entries; glossary overrides:", overrides.map(([a, b]) => `${a}→${b}`).join(" "));
}
2 changes: 2 additions & 0 deletions apps/web/scripts/i18n-draft.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ const LANGUAGE = {
"zh-hk": "Traditional Chinese (Hong Kong)",
es: "Spanish",
fr: "French",
ru: "Russian",
uk: "Ukrainian",
pt: "Brazilian Portuguese",
de: "German",
it: "Italian",
Expand Down
27 changes: 9 additions & 18 deletions apps/web/scripts/i18n-extract.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,13 @@
// node scripts/i18n-extract.mjs --check # exit 1 if a locale has gaps
import { readdirSync, readFileSync, statSync, writeFileSync, existsSync } from "node:fs";
import { join, relative } from "node:path";
// eslint-disable-next-line no-restricted-imports -- Node scripts cannot resolve the app's @/ alias.
import { collectMessages } from "./i18n-catalog.mjs";

const ROOT = new URL("../src", import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, "$1");
const LOCALES_DIR = join(ROOT, "locales");
const check = process.argv.includes("--check");

// t("text"), t('text'), t(`text`) with no ${}, optionally followed by
// `, {vars}` or `, "context"`. Multi-line calls are matched because the
// argument is the first thing after the paren.
const CALL = /\b(?:t\(|msg\(|rich\(\s*t\s*,)\s*(?:"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)'|`([^`$]*)`)\s*(?:,\s*(?:"([^"]*)"|'([^']*)'))?/g;

function* files(dir) {
for (const name of readdirSync(dir)) {
const path = join(dir, name);
Expand All @@ -31,17 +28,17 @@ function* files(dir) {
}

const found = new Map(); // key -> [files]
const indirect = [];
for (const file of files(ROOT)) {
const src = readFileSync(file, "utf8");
for (const m of src.matchAll(CALL)) {
const text = (m[1] ?? m[2] ?? m[3]).replace(/\\(["'`])/g, "$1");
const context = m[4] ?? m[5];
if (!text.trim()) continue;
const key = context ? `${text}@@${context}` : text;
const rel = relative(ROOT, file).replace(/\\/g, "/");
const messages = collectMessages(src, file);
for (const key of messages.keys) {
const list = found.get(key) ?? [];
list.push(relative(ROOT, file).replace(/\\/g, "/"));
list.push(rel);
found.set(key, list);
}
for (const value of messages.indirect) indirect.push(`${rel}: t(${value})`);
}

const keys = [...found.keys()].sort((a, b) => a.localeCompare(b));
Expand All @@ -57,14 +54,8 @@ console.log(`source: ${keys.length} strings (${keys.length - previous >= 0 ? "+"
// A t(x) whose argument is not a literal is translating a string defined
// elsewhere — fine when that definition is wrapped in msg(), invisible to
// this scan when it is not. List them so a bare label array cannot hide.
const INDIRECT = /\bt\(\s*([A-Za-z_$][\w$.]*)\s*\)/g;
const indirect = [];
for (const file of files(ROOT)) {
const src = readFileSync(file, "utf8");
for (const m of src.matchAll(INDIRECT)) indirect.push(`${relative(ROOT, file).replace(/\\/g, "/")}: t(${m[1]})`);
}
if (indirect.length && !check) {
console.log(`indirect: ${indirect.length} t(identifier) calls — their labels must be msg()-marked where defined:`);
console.log(`indirect: ${indirect.length} t(expression) calls — their labels must be msg()-marked where defined:`);
for (const line of indirect) console.log(` ${line}`);
}

Expand Down
12 changes: 7 additions & 5 deletions apps/web/src/app/[lang]/[asset]/_components/launch-stats.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { fetchJson } from "@/lib/client";
import { useDenomination, setDenomination } from "@/lib/denomination";
import { fromSats } from "@/lib/format";
import type { Raw } from "@/lib/numeric";
import { useFiat } from "@/lib/currency";
import { useFiat, useFxRate } from "@/lib/currency";
import { useT } from "@/lib/i18n/client";
import { useNumbers } from "@/lib/i18n/numbers";
import {
Expand All @@ -22,6 +22,7 @@ export function TermsStrip({ xcpUsd }: { xcpUsd: number | null }) {
const num = useNumbers();
const t = useT();
const usd = useFiat();
const { code } = useFxRate();
const denom = useDenomination();
const usdMode = denom === "USD" && !!xcpUsd;
const rate = xcpUsd ?? 0;
Expand Down Expand Up @@ -88,12 +89,12 @@ export function TermsStrip({ xcpUsd }: { xcpUsd: number | null }) {
<button
type="button"
onClick={() => setDenomination(usdMode ? "XCP" : "USD")}
aria-label={usdMode ? t("Show amounts in XCP") : t("Show amounts in US dollars")}
aria-label={usdMode ? t("Show amounts in XCP") : t("Show amounts in {currency}", { currency: code })}
className={`relative shrink-0 rounded-md border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 px-1.5 py-0.5 text-[7px] font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400 transition-colors after:absolute after:-inset-x-2 after:-inset-y-3 after:content-[''] hover:border-purple-400 dark:hover:border-purple-500 hover:text-purple-600 dark:hover:text-purple-400 active:scale-95 ${FOCUS} ${
i === 1 ? "md:hidden" : "hidden md:block"
}`}
>
{usdMode ? "XCP" : "USD"}
{usdMode ? "XCP" : code}
</button>
)}
</div>
Expand Down Expand Up @@ -174,18 +175,19 @@ export function ParticipantsStat({ participants }: { participants: number }) {
* their own rate's availability); this component only handles placement. */
export function DenomToggle({ visibleOn }: { visibleOn: "mobile" | "desktop" }) {
const t = useT();
const { code } = useFxRate();
const denom = useDenomination();
const usdMode = denom === "USD";
return (
<button
type="button"
onClick={() => setDenomination(usdMode ? "XCP" : "USD")}
aria-label={usdMode ? t("Show amounts in XCP") : t("Show amounts in US dollars")}
aria-label={usdMode ? t("Show amounts in XCP") : t("Show amounts in {currency}", { currency: code })}
className={`relative shrink-0 rounded-md border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 px-1.5 py-0.5 text-[7px] font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400 transition-colors after:absolute after:-inset-x-2 after:-inset-y-3 after:content-[''] hover:border-purple-400 dark:hover:border-purple-500 hover:text-purple-600 dark:hover:text-purple-400 active:scale-95 ${FOCUS} ${
visibleOn === "mobile" ? "sm:hidden" : "hidden sm:block"
}`}
>
{usdMode ? "XCP" : "USD"}
{usdMode ? "XCP" : code}
</button>
);
}
Expand Down
81 changes: 41 additions & 40 deletions apps/web/src/app/[lang]/[asset]/_components/mint-panel.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
"use client";

import { ComposeError } from "@/components/compose-error";

import { AmountInput } from "@/components/amount-input";
import { parseBoundedSetting } from "@/lib/amount-draft";

import { LazyLink } from "@/components/lazy-link";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import useSWR from "swr";
import { AssetChip } from "@/components/asset-chip";
import { ConnectButton } from "@/components/connect-button";
Expand Down Expand Up @@ -52,6 +57,10 @@ export function MintPanel({
const usdFmt = useFiat();
const { address, status: walletStatus } = useWallet();
const compose = useCompose();
const submittedMint = useRef<{
pending: Omit<Parameters<typeof registerPending>[0], "txid">;
usd: number | null;
} | null>(null);
const [tokens, setTokens] = useState("10000");

// The conforming fairminter for this ticker, read once and shared by both
Expand Down Expand Up @@ -128,7 +137,8 @@ export function MintPanel({
const addressCapped =
alreadyMintedRaw !== undefined && alreadyMintedRaw !== null && addressLots === 0;

const typedTokens = parseFloat(tokens) || 0;
const tokenDraft = parseBoundedSetting(tokens, maxLots * TOKENS_PER_LOT, 0, 0);
const typedTokens = tokenDraft.value ?? 0;
const lots = Math.max(0, Math.min(maxLots, Math.floor(typedTokens / TOKENS_PER_LOT)));
const mintTokens = lots * TOKENS_PER_LOT;
const adjusted = typedTokens > 0 && mintTokens !== typedTokens;
Expand All @@ -155,35 +165,20 @@ export function MintPanel({
const busy = isBusy(compose.status);

useEffect(() => {
if (compose.status === "confirmed") {
registerPending({
txid: compose.txid,
kind: "mint",
label: t("Mint {amount} {asset}", { amount: num.commas(mintTokens), asset }),
address: address ?? undefined,
spends: [{ asset: "XCP", raw: costRaw.toString() }],
});
// The XCP escrowed, valued at the rate the panel just quoted.
trackTx(compose.txid, "mint", xcpUsd ? costXcp * xcpUsd : null);
if (compose.status === "error") submittedMint.current = null;
if (compose.status === "confirmed" && submittedMint.current) {
const submitted = submittedMint.current;
submittedMint.current = null;
registerPending({ ...submitted.pending, txid: compose.txid });
trackTx(compose.txid, "mint", submitted.usd);
}
}, [
compose.status,
compose.txid,
mintTokens,
asset,
address,
costRaw,
costXcp,
xcpUsd,
t,
num,
]);
}, [compose.status, compose.txid]);

// A balance that could not be read does not block the mint — see
// useSpendableBalance's balanceUnavailable. Only a read still in flight
// holds the button, and only briefly.
const balanceSettled = xcpBalance !== undefined || balanceUnavailable;
const ready = balanceSettled && lots > 0 && !busy && !insufficient;
const ready = tokenDraft.valid && !tokenDraft.empty && !adjusted && balanceSettled && lots > 0 && !busy && !insufficient;
const buttonLabel = busy
? compose.status === "composing"
? t("Composing…")
Expand Down Expand Up @@ -249,18 +244,14 @@ export function MintPanel({
</>
}
>
{/* Integer-only, so the input can carry real digit grouping — in the
page's own separator, since every non-digit is stripped straight
back out on the way into state. Decimal AmountInputs cannot do
this: there a comma has to stay available as a decimal point. */}
<input
type="text"
inputMode="numeric"
autoComplete="off"
value={typedTokens > 0 ? num.commas(typedTokens) : tokens}
onChange={(e) => setTokens(e.target.value.replace(/[^0-9]/g, ""))}
<AmountInput
decimals={0}
max={maxLots * TOKENS_PER_LOT}
value={tokens}
onChange={setTokens}
error={adjusted ? t("Enter a multiple of {n}.", { n: num.commas(TOKENS_PER_LOT) }) : undefined}
placeholder="0"
aria-label={t("{asset} to mint", { asset })}
ariaLabel={t("{asset} to mint", { asset })}
className="w-full min-w-0 bg-transparent text-[2rem] font-semibold leading-tight text-gray-900 dark:text-gray-100 outline-none placeholder:text-gray-300 dark:placeholder:text-gray-600"
/>
</Well>
Expand Down Expand Up @@ -348,19 +339,29 @@ export function MintPanel({

<div className="px-0.5 pb-0.5 pt-3">
{compose.status === "error" && (
<ErrorBanner className="mb-2" onDismiss={compose.reset}>{compose.error}</ErrorBanner>
<ErrorBanner className="mb-2" onDismiss={compose.reset}><ComposeError {...compose} /></ErrorBanner>
)}
{walletStatus !== "connected" ? (
<ConnectButton />
) : (
<CTA
disabled={!ready}
onClick={() =>
onClick={() => {
if (!ready || submittedMint.current) return;
submittedMint.current = {
pending: {
kind: "mint",
label: t("Mint {amount} {asset}", { amount: num.commas(mintTokens), asset }),
address: address ?? undefined,
spends: [{ asset: "XCP", raw: costRaw.toString() }],
},
usd: xcpUsd ? costXcp * xcpUsd : null,
};
compose.composeFairmint({
asset,
quantity: lots * XCP69.QUANTITY_BY_PRICE,
})
}
});
}}
>
{buttonLabel}
</CTA>
Expand Down
11 changes: 7 additions & 4 deletions apps/web/src/app/[lang]/[asset]/_components/price-chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import useSWR from "swr";
import type { ChartCandle } from "@/lib/api/launchpad-api";
import type { ChartResolution } from "@/lib/candles";
import { monthDay } from "@/lib/chain-time";
import { fromSats } from "@/lib/format";
import { useFiat, useFxRate } from "@/lib/currency";
import { fromSats, fiat } from "@/lib/format";
import { useNumberLocale } from "@/lib/number-preference";
import { useT } from "@/lib/i18n/client";
import { type Numbers, useNumbers } from "@/lib/i18n/numbers";
import { big } from "@/lib/numeric";
Expand Down Expand Up @@ -122,8 +122,11 @@ export function PriceChart({
}) {
const num = useNumbers();
const t = useT();
const usd = useFiat();
const { code } = useFxRate();
// Historical rates are USD. Today's FX preference cannot rewrite that
// history; only separators follow the independent number preference.
const numberLocale = useNumberLocale();
const usd = (value: number) => fiat(value, "USD", numberLocale);
const code = "USD";
const svgRef = useRef<SVGSVGElement>(null);
const [hover, setHover] = useState<Plotted | null>(null);
const [range, setRange] = useState<ChartRange>(() => defaultRange(candles));
Expand Down
Loading
Loading