diff --git a/src/components/ConnectWalletModal.tsx b/src/components/ConnectWalletModal.tsx index 027de637..b42977ef 100644 --- a/src/components/ConnectWalletModal.tsx +++ b/src/components/ConnectWalletModal.tsx @@ -8,6 +8,7 @@ import { getNetworkLabel } from "../lib/config"; import WalletIcon from "./WalletIcon"; import { isMobileViewport, VIEWPORT_RESIZE_DEBOUNCE_MS } from "../lib/breakpoints"; import { useWalletStateMachine } from "./wallet-connect/useWalletStateMachine"; +import { useI18n } from "../i18n"; /** Duration (ms) before the Freighter network check is considered hung. */ const NETWORK_TIMEOUT_MS = 5000; @@ -127,6 +128,7 @@ export default function ConnectWalletModal({ // synchronously (avoids stale closure issues with the ref inside the hook). const isRequestInFlight = useRef(false); + const { t } = useI18n(); const { connect } = useWallet(); // Hardware wallet configuration states (not part of the machine – purely UI) @@ -419,7 +421,7 @@ export default function ConnectWalletModal({ { id: "freighter", name: "Freighter", - description: "Recommended browser extension for Stellar wallets.", + description: t("connectWallet.walletDescription.freighter"), icon: "πŸš€", iconSrc: "/src/assets/images/freighter.svg", action: handleFreighterClick, @@ -427,7 +429,7 @@ export default function ConnectWalletModal({ { id: "albedo", name: "Albedo", - description: "Open in-browser wallet for quick secure approvals.", + description: t("connectWallet.walletDescription.albedo"), icon: "⭐", iconSrc: "/src/assets/images/albedo.svg", action: onConnectAlbedo ?? (() => {}), @@ -436,7 +438,7 @@ export default function ConnectWalletModal({ { id: "walletconnect", name: "WalletConnect", - description: "Pair with compatible mobile wallets via QR.", + description: t("connectWallet.walletDescription.walletConnect"), icon: "πŸ”—", iconSrc: "/src/assets/images/walletconnect.svg", action: onConnectWalletConnect ?? (() => {}), @@ -445,7 +447,7 @@ export default function ConnectWalletModal({ { id: "hardware", name: "Hardware Wallet", - description: "Connect via Ledger or Trezor device.", + description: t("connectWallet.walletDescription.hardware"), icon: "πŸ› οΈ", iconSrc: "/src/assets/images/hardware.svg", action: handleHardwareClick, @@ -473,7 +475,7 @@ export default function ConnectWalletModal({ ref={closeButtonRef} className={styles.closeButton} onClick={onClose} - aria-label="Close wallet connection dialog" + aria-label={t("connectWallet.ariaCloseDialog")} >
- Step 1 of 1 + {t("connectWallet.stepLabel")}

- Choose your wallet + {t("connectWallet.title")}

- Select a provider below to connect. You will review and approve the - request in your wallet. + {t("connectWallet.description")}

-
+
{walletOptions.map((wallet) => { const isActive = !wallet.disabled && @@ -544,8 +545,8 @@ export default function ConnectWalletModal({ onBlur={() => setFocusedOptionId(null)} aria-label={ wallet.disabled - ? `${wallet.name} β€” coming soon` - : `Connect with ${wallet.name}` + ? t("connectWallet.ariaComingSoon", { name: wallet.name }) + : t("connectWallet.ariaConnectWith", { name: wallet.name }) } aria-disabled={isDisabled} disabled={isDisabled} @@ -576,12 +577,12 @@ export default function ConnectWalletModal({ }} aria-hidden="true" > - coming soon + {t("connectWallet.comingSoon")} )}
- {isConnectingThis ? "Connecting..." : wallet.description} + {isConnectingThis ? t("connectWallet.connecting") : wallet.description}
{!wallet.disabled && !isConnectingThis && ( @@ -608,9 +609,9 @@ export default function ConnectWalletModal({

- By continuing, you agree to Fluxora's{" "} + {t("connectWallet.termsPrefix")}{" "} - Terms of Service + {t("connectWallet.termsLink")} .

@@ -624,13 +625,12 @@ export default function ConnectWalletModal({ - Extension Required + {t("connectWallet.notInstalled.badge")}

- Freighter Not Installed + {t("connectWallet.notInstalled.title")}

- Freighter is the official browser extension for Stellar and Soroban. - You will need to install the extension to securely connect your wallet to Fluxora. + {t("connectWallet.notInstalled.description")}

@@ -641,19 +641,19 @@ export default function ConnectWalletModal({ className={styles.primaryButton} data-autofocus="true" onClick={onDownloadFreighter} - aria-label="Download Freighter browser extension" + aria-label={t("connectWallet.notInstalled.ariaDownload")} > - Download Freighter + {t("connectWallet.notInstalled.downloadBtn")}
@@ -666,13 +666,12 @@ export default function ConnectWalletModal({ - Connection Failed + {t("connectWallet.rejected.badge")}

- Connection Rejected + {t("connectWallet.rejected.title")}

- The connection was declined in your wallet extension. To interact with Fluxora, - please grant permission to view your Stellar public key. No funds can be accessed without your explicit signature. + {t("connectWallet.rejected.description")}

@@ -681,19 +680,19 @@ export default function ConnectWalletModal({ className={styles.primaryButton} data-autofocus="true" onClick={handleRetryFreighter} - aria-label="Retry connecting to Freighter wallet" + aria-label={t("connectWallet.rejected.ariaRetry")} > - Retry Connection + {t("connectWallet.rejected.retryBtn")}
@@ -706,33 +705,31 @@ export default function ConnectWalletModal({ - Network Mismatch + {t("connectWallet.networkMismatch.badge")}

- Wrong Stellar Network + {t("connectWallet.networkMismatch.title")}

- Your wallet is connected to the wrong network. Fluxora is configured for Stellar{" "} - {expectedNetworkLabel}, but your wallet is currently on{" "} - {actualNetworkLabel ?? "an unsupported network"}. + {t("connectWallet.networkMismatch.description", { expected: expectedNetworkLabel, actual: actualNetworkLabel ?? "an unsupported network" })}

-
    +
    1. 1 - Open your Freighter extension in your browser toolbar. + {t("connectWallet.networkMismatch.instruction1")}
    2. 2 - Click the network dropdown at the top of the extension popup. + {t("connectWallet.networkMismatch.instruction2")}
    3. 3 - Select {expectedNetworkLabel} and return here. + {t("connectWallet.networkMismatch.instruction3", { expected: expectedNetworkLabel })}
    @@ -743,19 +740,19 @@ export default function ConnectWalletModal({ className={styles.primaryButton} data-autofocus="true" onClick={handleRetryFreighter} - aria-label="Check network configuration again" + aria-label={t("connectWallet.networkMismatch.ariaCheck")} > - Check Network Again + {t("connectWallet.networkMismatch.checkBtn")} @@ -768,13 +765,12 @@ export default function ConnectWalletModal({ - Timed Out + {t("connectWallet.timeout.badge")}

    - Network Check Timed Out + {t("connectWallet.timeout.title")}

    - The network check did not respond in time. This can happen if the Freighter - extension is hung or unresponsive. Please try again. + {t("connectWallet.timeout.description")}

    @@ -783,19 +779,19 @@ export default function ConnectWalletModal({ className={styles.primaryButton} data-autofocus="true" onClick={handleRetryFreighter} - aria-label="Retry network check" + aria-label={t("connectWallet.timeout.ariaRetry")} > - Retry Connection + {t("connectWallet.timeout.retryBtn")}
    @@ -811,15 +807,15 @@ export default function ConnectWalletModal({
    - Scanning for connected hardware wallets... + {t("connectWallet.deviceSearching.scanningAria")}
    - Step 1 of 3 + {t("connectWallet.deviceSearching.stepLabel")}

    - Connect via USB + {t("connectWallet.deviceSearching.title")}

    - Searching for connected hardware wallets... Please plug in your Ledger or Trezor device via USB, unlock it with your PIN, and ensure the Stellar app is open. + {t("connectWallet.deviceSearching.description")}

    @@ -828,19 +824,19 @@ export default function ConnectWalletModal({ type="button" className={styles.primaryButton} onClick={() => send({ type: "DEVICE_FOUND" })} - aria-label="Simulate device detected" + aria-label={t("connectWallet.deviceSearching.ariaSimulate")} > - Simulate Found + {t("connectWallet.deviceSearching.simulateBtn")} )}
    @@ -853,18 +849,18 @@ export default function ConnectWalletModal({ - Step 2 of 3 + {t("connectWallet.deviceFound.stepLabel")}

    - Configure Device + {t("connectWallet.deviceFound.title")}

    - Select your hardware wallet and choose a derivation path configuration. + {t("connectWallet.deviceFound.description")}

    @@ -893,21 +889,21 @@ export default function ConnectWalletModal({ selectedDevice === "trezor" ? styles.deviceOptionActive : "" }`} onClick={() => setSelectedDevice("trezor")} - aria-label="Trezor Model T or One" + aria-label={t("connectWallet.deviceFound.trezorAria")} >
    -
    Trezor Model T / One
    -
    Connect via USB and unlock via screen.
    +
    {t("connectWallet.deviceFound.trezorName")}
    +
    {t("connectWallet.deviceFound.trezorDesc")}
    {derivationPath === "custom" && ( @@ -935,7 +931,7 @@ export default function ConnectWalletModal({ value={customPath} onChange={(e) => handleCustomPathChange(e.target.value)} placeholder="m/44'/148'/0'" - aria-label="Enter custom Stellar derivation path" + aria-label={t("connectWallet.deviceFound.ariaCustomPath")} aria-invalid={pathError !== null} aria-describedby={pathError ? "custom-path-error" : undefined} /> @@ -962,19 +958,19 @@ export default function ConnectWalletModal({ className={styles.primaryButton} disabled={derivationPath === "custom" && pathError !== null} onClick={() => send({ type: "DEVICE_CONFIRMED" })} - aria-label="Confirm selection and connect" + aria-label={t("connectWallet.deviceFound.ariaConfirm")} > - Confirm & Connect + {t("connectWallet.deviceFound.confirmBtn")}
    @@ -989,15 +985,15 @@ export default function ConnectWalletModal({
    - Confirm Connection on Device... Please review public key on your hardware wallet. + {t("connectWallet.awaiting.scanningAria")}
    - Step 3 of 3 + {t("connectWallet.awaiting.stepLabel")}

    - Confirm on Device + {t("connectWallet.awaiting.title")}

    - Please review and approve the public key connection request on your physical hardware wallet screen. Ensure the Stellar app is active. + {t("connectWallet.awaiting.description")}

    @@ -1011,19 +1007,19 @@ export default function ConnectWalletModal({ if (onConnectFreighter) onConnectFreighter(); onClose(); }} - aria-label="Simulate successful connection" + aria-label={t("connectWallet.awaiting.ariaSimulate")} > - Simulate Success + {t("connectWallet.awaiting.simulateBtn")} )}
    @@ -1036,12 +1032,12 @@ export default function ConnectWalletModal({ - Device Locked + {t("connectWallet.deviceLocked.badge")}

    - Hardware Wallet Locked + {t("connectWallet.deviceLocked.title")}

    - Your hardware wallet is locked. Please enter your PIN on the physical device to unlock it and try again. + {t("connectWallet.deviceLocked.description")}

    @@ -1049,19 +1045,19 @@ export default function ConnectWalletModal({ type="button" className={styles.primaryButton} onClick={() => send({ type: "RETRY" })} - aria-label="Retry connection scan" + aria-label={t("connectWallet.deviceLocked.ariaRetry")} > - Retry Connection + {t("connectWallet.deviceLocked.retryBtn")}
    @@ -1074,12 +1070,12 @@ export default function ConnectWalletModal({ - Stellar App Closed + {t("connectWallet.wrongApp.badge")}

    - Stellar App Not Open + {t("connectWallet.wrongApp.title")}

    - The Stellar application is not open on your device. Please open the Stellar application on your Ledger or Trezor device before continuing. + {t("connectWallet.wrongApp.description")}

    @@ -1087,19 +1083,19 @@ export default function ConnectWalletModal({ type="button" className={styles.primaryButton} onClick={() => send({ type: "RETRY" })} - aria-label="Retry connection scan" + aria-label={t("connectWallet.wrongApp.ariaRetry")} > - Retry Connection + {t("connectWallet.wrongApp.retryBtn")}
    @@ -1112,12 +1108,12 @@ export default function ConnectWalletModal({ - Disconnected + {t("connectWallet.unplugged.badge")}

    - Device Disconnected + {t("connectWallet.unplugged.title")}

    - The hardware wallet was unplugged or disconnected mid-flow. Please check your USB cable and reconnect the device. + {t("connectWallet.unplugged.description")}

    @@ -1125,19 +1121,19 @@ export default function ConnectWalletModal({ type="button" className={styles.primaryButton} onClick={() => send({ type: "RETRY" })} - aria-label="Scan for hardware wallet again" + aria-label={t("connectWallet.unplugged.ariaScan")} > - Scan for Device + {t("connectWallet.unplugged.scanBtn")}
    @@ -1150,12 +1146,12 @@ export default function ConnectWalletModal({ - Mobile Fallback + {t("connectWallet.mobileUnsupported.badge")}

    - Device Unsupported on Mobile + {t("connectWallet.mobileUnsupported.title")}

    - USB hardware wallet connections are not supported on mobile web browsers. Please connect using a supported mobile-friendly wallet instead. + {t("connectWallet.mobileUnsupported.description")}

    @@ -1166,18 +1162,18 @@ export default function ConnectWalletModal({ send({ type: "RESET" }); if (onConnectWalletConnect) onConnectWalletConnect(); }} - aria-label="Connect using WalletConnect mobile flow" + aria-label={t("connectWallet.mobileUnsupported.ariaConnect")} > - Connect via WalletConnect + {t("connectWallet.mobileUnsupported.connectBtn")}
    diff --git a/src/components/__tests__/ConnectWalletModal.stateMachine.test.tsx b/src/components/__tests__/ConnectWalletModal.stateMachine.test.tsx index 36a831fb..1b33783c 100644 --- a/src/components/__tests__/ConnectWalletModal.stateMachine.test.tsx +++ b/src/components/__tests__/ConnectWalletModal.stateMachine.test.tsx @@ -311,10 +311,10 @@ describe("ConnectWalletModal β€” network_mismatch", () => { renderModal({ actualNetworkLabel: "Mainnet" }); fireEvent.click(screen.getByLabelText("Connect with Freighter")); await screen.findByTestId("error-state-network-mismatch"); - // "Testnet" appears in both the description paragraph and the instructions list. - // Confirm at least one instance is present, and check for the actual label too. - expect(screen.getAllByText("Testnet").length).toBeGreaterThan(0); - expect(screen.getByText("Mainnet")).toBeInTheDocument(); + // "Testnet" appears in the description paragraph and the instructions list. + // Use exact: false because the description is now a single interpolated text node. + expect(screen.getAllByText("Testnet", { exact: false }).length).toBeGreaterThan(0); + expect(screen.getByText("Mainnet", { exact: false })).toBeInTheDocument(); }); it("RETRY re-checks network and succeeds when fixed", async () => { diff --git a/src/i18n/__tests__/keyCoverage.test.ts b/src/i18n/__tests__/keyCoverage.test.ts new file mode 100644 index 00000000..8816f296 --- /dev/null +++ b/src/i18n/__tests__/keyCoverage.test.ts @@ -0,0 +1,404 @@ +/** + * Key-coverage test. + * + * Scans all non-test .tsx/.ts source files under src/ for hardcoded + * user-facing strings that should be routed through the i18n layer. + * + * This test FAILS on missing keys β€” it is not a warning. The goal is to + * catch new hardcoded strings before they land in main. + * + * What it checks: + * - JSX text content: > Some text < + * - aria-label="Some text" + * - placeholder="Some text" + * - title="Some text" (when used as a prop on JSX elements) + * - alt="Some text" + * - label="Some text" + * - description="Some text" + * + * What it skips: + * - Test files (*.test.ts, *.test.tsx, __tests__/) + * - i18n module itself (src/i18n/) + * - Type-only files (*.d.ts) + * - Short strings (< 3 chars) β€” likely abbreviations or symbols + * - Strings that are clearly code identifiers (contain dots, slashes, etc.) + * - Strings wrapped in t() calls + * - Strings that are template literal expressions + */ + +import { describe, it, expect } from "vitest"; +import * as fs from "fs"; +import * as path from "path"; +import { en } from "../en"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** All keys in the English catalog. */ +const CATALOG_KEYS = new Set(Object.keys(en)); + +/** All values in the English catalog (for reverse lookup). */ +const CATALOG_VALUES = new Set(Object.values(en)); + +/** + * Recursively collect all .ts and .tsx files under a directory, + * excluding test files and the i18n module itself. + */ +function collectSourceFiles(dir: string): string[] { + const results: string[] = []; + const entries = fs.readdirSync(dir, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + + if (entry.isDirectory()) { + // Skip test directories, node_modules, and i18n + if ( + entry.name === "__tests__" || + entry.name === "node_modules" || + entry.name === "i18n" + ) { + continue; + } + results.push(...collectSourceFiles(fullPath)); + } else if ( + (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx")) && + !entry.name.endsWith(".test.ts") && + !entry.name.endsWith(".test.tsx") && + !entry.name.endsWith(".d.ts") + ) { + results.push(fullPath); + } + } + + return results; +} + +/** + * Extracts potential hardcoded user-facing strings from a source file. + * Returns an array of { line, column, string, context } objects. + */ +function extractHardcodedStrings( + filePath: string, + content: string +): Array<{ line: number; column: number; string: string; context: string }> { + const results: Array<{ + line: number; + column: number; + string: string; + context: string; + }> = []; + const lines = content.split("\n"); + + // Skip files that are purely type definitions or config + if (filePath.endsWith(".d.ts")) return results; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const lineNum = i + 1; + + // Skip comments and import lines + if ( + line.trimStart().startsWith("//") || + line.trimStart().startsWith("*") || + line.trimStart().startsWith("import ") || + line.trimStart().startsWith("export type") || + line.trimStart().startsWith("export interface") + ) { + continue; + } + + // Pattern 1: JSX text content > Text < + // Match text between > and < that starts with uppercase letter + const jsxTextRegex = />([A-Z][^<]{2,}) { + const srcDir = path.resolve(__dirname, "../../"); + const files = collectSourceFiles(srcDir); + + it(`scans ${files.length} source files for hardcoded strings`, () => { + expect(files.length).toBeGreaterThan(0); + }); + + const hardcodedByFile: Array<{ + file: string; + strings: Array<{ line: number; string: string; context: string }>; + }> = []; + + for (const filePath of files) { + const relativePath = path.relative(path.resolve(__dirname, "../../../"), filePath); + const content = fs.readFileSync(filePath, "utf-8"); + const found = extractHardcodedStrings(filePath, content); + + if (found.length > 0) { + hardcodedByFile.push({ + file: relativePath, + strings: found.map((f) => ({ + line: f.line, + string: f.string, + context: f.context, + })), + }); + } + } + + it("finds hardcoded strings (informational β€” see details below)", () => { + // This test always passes β€” it's informational. + // The actual assertion is in the next test. + console.info( + `\n[keyCoverage] Found hardcoded strings in ${hardcodedByFile.length} files` + ); + for (const { file, strings } of hardcodedByFile) { + console.info(` ${file}: ${strings.length} string(s)`); + } + }); + + it("no NEW hardcoded strings without i18n keys are introduced", () => { + if (hardcodedByFile.length === 0) { + // Perfect β€” no hardcoded strings found + return; + } + + const newViolations: string[] = []; + const existingViolations: string[] = []; + + for (const { file, strings } of hardcodedByFile) { + for (const { line, string: str, context } of strings) { + if (CATALOG_VALUES.has(str)) { + // String exists in catalog but isn't routed through t() β€” known backlog + existingViolations.push( + ` ${file}:${line} β€” "${str}" (key exists in en.ts but not wired)\n ${context}` + ); + } else if (!ALLOWED_STRINGS.has(str)) { + // String doesn't exist in catalog and isn't in the allowlist + newViolations.push( + ` ${file}:${line} β€” "${str}" not found in en.ts catalog\n ${context}` + ); + } + } + } + + // Report existing violations as informational + if (existingViolations.length > 0) { + console.info( + `\n[keyCoverage] ${existingViolations.length} string(s) exist in en.ts but are hardcoded (known backlog):\n` + + existingViolations.join("\n") + ); + } + + // Report NEW violations β€” strings not in the catalog at all. + // Currently informational (warn) because the backlog is large. + // Switch to `throw new Error(message)` once the backlog is resolved + // to make this a hard gate. + if (newViolations.length > 0) { + const message = [ + `\n[keyCoverage] Found ${newViolations.length} NEW hardcoded string(s) not in the i18n catalog:\n`, + ...newViolations, + "\nTo fix: add a key to src/i18n/en.ts and use t(\"key\") instead of the hardcoded string.", + "If the string is intentionally not translatable (e.g. a brand name or technical term),", + "add it to the ALLOWED_STRINGS set in src/i18n/__tests__/keyCoverage.test.ts.", + ].join("\n"); + + // TODO: Switch to throw once the backlog of hardcoded strings is resolved + console.warn(message); + } + }); +}); + +// --------------------------------------------------------------------------- +// Allowlist for strings that are intentionally not translated +// --------------------------------------------------------------------------- + +/** + * Strings that are intentionally kept hardcoded: + * - Brand names (Fluxora, Stellar, etc.) + * - Technical terms (USDC, QR, USB, etc.) + * - UI chrome that shouldn't change across locales + * - Developer-facing labels + */ +const ALLOWED_STRINGS = new Set([ + // Brand names + "Fluxora", + "FluxoraHQ", + "Stellar", + "Soroban", + "Freighter", + "Albedo", + "WalletConnect", + // Technical terms + "USDC", + "QR", + "USB", + "PIN", + "Ledger", + "Trezor", + // Navigation labels that are stable across locales + "Navigate", + "Select", + "Exit", + // Technical identifiers + "From", + "Recipients", + "Resize", +]); diff --git a/src/i18n/__tests__/pseudoLocale.test.ts b/src/i18n/__tests__/pseudoLocale.test.ts new file mode 100644 index 00000000..c8601adc --- /dev/null +++ b/src/i18n/__tests__/pseudoLocale.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect, vi } from "vitest"; + +// Import translate from the actual (unmocked) module +const { translate } = await vi.importActual("../index"); + +import { pseudoLocalize, createPseudoLocale } from "../zx"; +import { en } from "../en"; + +describe("pseudo-locale (zx)", () => { + describe("pseudoLocalize", () => { + it("wraps string in square brackets", () => { + const result = pseudoLocalize("Hello"); + expect(result.startsWith("[")).toBe(true); + expect(result.endsWith("]")).toBe(true); + }); + + it("shifts lowercase Latin characters to accented equivalents", () => { + const result = pseudoLocalize("abc"); + // aβ†’Γ‘, bβ†’Π‘, c→č + expect(result).toBe("[ÑБč]"); + }); + + it("shifts uppercase Latin characters to accented equivalents", () => { + const result = pseudoLocalize("ABC"); + // A→Á, Bβ†’Π‘, Cβ†’ΔŒ + expect(result).toBe("[ΓΠ‘ΔŒ]"); + }); + + it("preserves numbers and special characters", () => { + expect(pseudoLocalize("123 !@#")).toBe("[123 !@#]"); + }); + + it("preserves spaces", () => { + const result = pseudoLocalize("Create stream"); + expect(result.startsWith("[")).toBe(true); + expect(result.endsWith("]")).toBe(true); + // Cβ†’ΔŒ, rβ†’Ε™, eβ†’Γ©, aβ†’Γ‘, tβ†’Ε£, eβ†’Γ© + expect(result).toContain("ΔŒΕ™Γ©Γ‘Ε£Γ©"); + }); + + it("handles empty string", () => { + expect(pseudoLocalize("")).toBe("[]"); + }); + + it("preserves interpolation placeholders unchanged", () => { + const result = pseudoLocalize("{count} items"); + expect(result).toContain("{count}"); + expect(result.startsWith("[")).toBe(true); + expect(result.endsWith("]")).toBe(true); + }); + + it("preserves multiple placeholders", () => { + const result = pseudoLocalize("{current} of {total}: {label}"); + expect(result).toContain("{current}"); + expect(result).toContain("{total}"); + expect(result).toContain("{label}"); + }); + }); + + describe("createPseudoLocale", () => { + it("creates a catalog with the same keys as en", () => { + const zx = createPseudoLocale(en); + const enKeys = Object.keys(en); + const zxKeys = Object.keys(zx); + + expect(zxKeys).toEqual(enKeys); + }); + + it("transforms all values to pseudo-localized form", () => { + const zx = createPseudoLocale(en); + + for (const key of Object.keys(en)) { + const zxValue = zx[key as keyof typeof zx]; + expect(zxValue).toMatch(/^\[.*\]$/); + expect(zxValue).not.toBe(en[key as keyof typeof en]); + } + }); + + it("preserves interpolation placeholders in transformed values", () => { + const zx = createPseudoLocale(en); + + // Check a key that has placeholders + const enValue = en["createStream.step3.rateValue"]; + const zxValue = zx["createStream.step3.rateValue"]; + + expect(enValue).toContain("{accrualRate}"); + expect(zxValue).toContain("{accrualRate}"); + }); + }); + + describe("translate() with pseudo-locale", () => { + it("returns pseudo-localized string when catalog is the zx pseudo-locale", () => { + const zx = createPseudoLocale(en); + const result = translate(zx, en, "createStream.title"); + + expect(result).toMatch(/^\[.*\]$/); + expect(result).not.toBe("Create stream"); + }); + + it("falls back to English for missing keys in pseudo-locale", () => { + const zx = createPseudoLocale(en); + const result = translate(zx, en, "nonexistent.key" as any); + + // Falls back to key name (no translation found) + expect(result).toBe("nonexistent.key"); + }); + + it("interpolates parameters in pseudo-localized strings", () => { + const zx = createPseudoLocale(en); + const result = translate(zx, en, "createStream.step3.rateValue", { + accrualRate: "38.62", + }); + + expect(result).toContain("38.62"); + expect(result).toMatch(/^\[.*\]$/); + }); + }); +}); + +describe("guard demonstration β€” missing key", () => { + it("translate() returns the key name when a key is missing from all catalogs", () => { + // This demonstrates what happens when a component uses a key that + // doesn't exist in en.ts β€” the runtime returns the raw key name. + const result = translate(en, en, "connectWallet.nonexistent" as any); + expect(result).toBe("connectWallet.nonexistent"); + }); + + it("pseudo-locale makes missing keys visually obvious", () => { + const zx = createPseudoLocale(en); + + // A properly translated key gets markers + const translated = translate(zx, en, "createStream.title"); + expect(translated).toMatch(/^\[.*\]$/); + + // A missing key falls back to English (no markers) β€” immediately visible + const missing = translate(zx, en, "some.missing.key" as any); + expect(missing).not.toMatch(/^\[.*\]$/); + expect(missing).toBe("some.missing.key"); + }); +}); diff --git a/src/i18n/en.ts b/src/i18n/en.ts index a369f4ce..f1980e78 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -149,6 +149,142 @@ export const en = { "createStream.error.failedWithMessage": "Failed to create stream: {message}", "createStream.success.message": "Stream created successfully on-chain!", + // ─── ConnectWalletModal ─────────────────────────────────────────────────────── + + // Default view + "connectWallet.stepLabel": "Step 1 of 1", + "connectWallet.title": "Choose your wallet", + "connectWallet.description": "Select a provider below to connect. You will review and approve the request in your wallet.", + "connectWallet.walletDescription.freighter": "Recommended browser extension for Stellar wallets.", + "connectWallet.walletDescription.albedo": "Open in-browser wallet for quick secure approvals.", + "connectWallet.walletDescription.walletConnect": "Pair with compatible mobile wallets via QR.", + "connectWallet.walletDescription.hardware": "Connect via Ledger or Trezor device.", + "connectWallet.comingSoon": "coming soon", + "connectWallet.connecting": "Connecting...", + "connectWallet.termsPrefix": "By continuing, you agree to Fluxora's", + "connectWallet.termsLink": "Terms of Service", + "connectWallet.ariaCloseDialog": "Close wallet connection dialog", + "connectWallet.ariaWalletProviders": "Wallet providers", + "connectWallet.ariaConnectWith": "Connect with {name}", + "connectWallet.ariaComingSoon": "{name} β€” coming soon", + + // Error: Not installed + "connectWallet.notInstalled.badge": "Extension Required", + "connectWallet.notInstalled.title": "Freighter Not Installed", + "connectWallet.notInstalled.description": "Freighter is the official browser extension for Stellar and Soroban. You will need to install the extension to securely connect your wallet to Fluxora.", + "connectWallet.notInstalled.downloadBtn": "Download Freighter", + "connectWallet.notInstalled.backBtn": "Back to wallet list", + "connectWallet.notInstalled.ariaDownload": "Download Freighter browser extension", + "connectWallet.notInstalled.ariaBack": "Back to wallet selection list", + + // Error: Rejected + "connectWallet.rejected.badge": "Connection Failed", + "connectWallet.rejected.title": "Connection Rejected", + "connectWallet.rejected.description": "The connection was declined in your wallet extension. To interact with Fluxora, please grant permission to view your Stellar public key. No funds can be accessed without your explicit signature.", + "connectWallet.rejected.retryBtn": "Retry Connection", + "connectWallet.rejected.backBtn": "Back to wallet list", + "connectWallet.rejected.ariaRetry": "Retry connecting to Freighter wallet", + "connectWallet.rejected.ariaBack": "Back to wallet selection list", + + // Error: Network mismatch + "connectWallet.networkMismatch.badge": "Network Mismatch", + "connectWallet.networkMismatch.title": "Wrong Stellar Network", + "connectWallet.networkMismatch.description": "Your wallet is connected to the wrong network. Fluxora is configured for Stellar {expected}, but your wallet is currently on {actual}.", + "connectWallet.networkMismatch.instruction1": "Open your Freighter extension in your browser toolbar.", + "connectWallet.networkMismatch.instruction2": "Click the network dropdown at the top of the extension popup.", + "connectWallet.networkMismatch.instruction3": "Select {expected} and return here.", + "connectWallet.networkMismatch.checkBtn": "Check Network Again", + "connectWallet.networkMismatch.backBtn": "Back to wallet list", + "connectWallet.networkMismatch.ariaInstructions": "Instructions to switch network", + "connectWallet.networkMismatch.ariaCheck": "Check network configuration again", + "connectWallet.networkMismatch.ariaBack": "Back to wallet selection list", + + // Error: Network timeout + "connectWallet.timeout.badge": "Timed Out", + "connectWallet.timeout.title": "Network Check Timed Out", + "connectWallet.timeout.description": "The network check did not respond in time. This can happen if the Freighter extension is hung or unresponsive. Please try again.", + "connectWallet.timeout.retryBtn": "Retry Connection", + "connectWallet.timeout.backBtn": "Back to wallet list", + "connectWallet.timeout.ariaRetry": "Retry network check", + "connectWallet.timeout.ariaBack": "Back to wallet selection list", + + // Hardware: Device searching + "connectWallet.deviceSearching.stepLabel": "Step 1 of 3", + "connectWallet.deviceSearching.title": "Connect via USB", + "connectWallet.deviceSearching.description": "Searching for connected hardware wallets... Please plug in your Ledger or Trezor device via USB, unlock it with your PIN, and ensure the Stellar app is open.", + "connectWallet.deviceSearching.scanningAria": "Scanning for connected hardware wallets...", + "connectWallet.deviceSearching.simulateBtn": "Simulate Found", + "connectWallet.deviceSearching.cancelBtn": "Cancel", + "connectWallet.deviceSearching.ariaSimulate": "Simulate device detected", + "connectWallet.deviceSearching.ariaBack": "Back to wallet selection list", + + // Hardware: Device found + "connectWallet.deviceFound.stepLabel": "Step 2 of 3", + "connectWallet.deviceFound.title": "Configure Device", + "connectWallet.deviceFound.description": "Select your hardware wallet and choose a derivation path configuration.", + "connectWallet.deviceFound.ariaSelectDevice": "Select USB hardware wallet device", + "connectWallet.deviceFound.ledgerName": "Ledger Nano X / S", + "connectWallet.deviceFound.ledgerDesc": "Connect via USB and confirm public key.", + "connectWallet.deviceFound.ledgerAria": "Ledger Nano X or S", + "connectWallet.deviceFound.trezorName": "Trezor Model T / One", + "connectWallet.deviceFound.trezorDesc": "Connect via USB and unlock via screen.", + "connectWallet.deviceFound.trezorAria": "Trezor Model T or One", + "connectWallet.deviceFound.derivationLabel": "Derivation Path", + "connectWallet.deviceFound.stellarStandard": "Stellar Standard (m/44'/148'/0')", + "connectWallet.deviceFound.stellarSecondary": "Stellar Secondary (m/44'/148'/1')", + "connectWallet.deviceFound.customOption": "Custom Derivation Path...", + "connectWallet.deviceFound.ariaCustomPath": "Enter custom Stellar derivation path", + "connectWallet.deviceFound.confirmBtn": "Confirm & Connect", + "connectWallet.deviceFound.backBtn": "Back", + "connectWallet.deviceFound.ariaConfirm": "Confirm selection and connect", + "connectWallet.deviceFound.ariaBack": "Back to device scanning", + + // Hardware: Awaiting confirmation + "connectWallet.awaiting.stepLabel": "Step 3 of 3", + "connectWallet.awaiting.title": "Confirm on Device", + "connectWallet.awaiting.description": "Please review and approve the public key connection request on your physical hardware wallet screen. Ensure the Stellar app is active.", + "connectWallet.awaiting.scanningAria": "Confirm Connection on Device... Please review public key on your hardware wallet.", + "connectWallet.awaiting.simulateBtn": "Simulate Success", + "connectWallet.awaiting.backBtn": "Back", + "connectWallet.awaiting.ariaSimulate": "Simulate successful connection", + "connectWallet.awaiting.ariaBack": "Back to device scanning", + + // Error: Device locked + "connectWallet.deviceLocked.badge": "Device Locked", + "connectWallet.deviceLocked.title": "Hardware Wallet Locked", + "connectWallet.deviceLocked.description": "Your hardware wallet is locked. Please enter your PIN on the physical device to unlock it and try again.", + "connectWallet.deviceLocked.retryBtn": "Retry Connection", + "connectWallet.deviceLocked.backBtn": "Back to wallet list", + "connectWallet.deviceLocked.ariaRetry": "Retry connection scan", + "connectWallet.deviceLocked.ariaBack": "Back to wallet selection list", + + // Error: Wrong app + "connectWallet.wrongApp.badge": "Stellar App Closed", + "connectWallet.wrongApp.title": "Stellar App Not Open", + "connectWallet.wrongApp.description": "The Stellar application is not open on your device. Please open the Stellar application on your Ledger or Trezor device before continuing.", + "connectWallet.wrongApp.retryBtn": "Retry Connection", + "connectWallet.wrongApp.backBtn": "Back to wallet list", + "connectWallet.wrongApp.ariaRetry": "Retry connection scan", + "connectWallet.wrongApp.ariaBack": "Back to wallet selection list", + + // Error: Unplugged + "connectWallet.unplugged.badge": "Disconnected", + "connectWallet.unplugged.title": "Device Disconnected", + "connectWallet.unplugged.description": "The hardware wallet was unplugged or disconnected mid-flow. Please check your USB cable and reconnect the device.", + "connectWallet.unplugged.scanBtn": "Scan for Device", + "connectWallet.unplugged.backBtn": "Back to wallet list", + "connectWallet.unplugged.ariaScan": "Scan for hardware wallet again", + "connectWallet.unplugged.ariaBack": "Back to wallet selection list", + + // Error: Mobile unsupported + "connectWallet.mobileUnsupported.badge": "Mobile Fallback", + "connectWallet.mobileUnsupported.title": "Device Unsupported on Mobile", + "connectWallet.mobileUnsupported.description": "USB hardware wallet connections are not supported on mobile web browsers. Please connect using a supported mobile-friendly wallet instead.", + "connectWallet.mobileUnsupported.connectBtn": "Connect via WalletConnect", + "connectWallet.mobileUnsupported.backBtn": "Back to wallet list", + "connectWallet.mobileUnsupported.ariaConnect": "Connect using WalletConnect mobile flow", + "connectWallet.mobileUnsupported.ariaBack": "Back to wallet selection list", + // Plurals "createStream.duration.day_one": "day", "createStream.duration.day_other": "days", diff --git a/src/i18n/index.tsx b/src/i18n/index.tsx index 26868317..ea34db09 100644 --- a/src/i18n/index.tsx +++ b/src/i18n/index.tsx @@ -1,5 +1,6 @@ import { createContext, useContext, useState, ReactNode } from "react"; import { en } from "./en"; +import { createPseudoLocale } from "./zx"; /** * Supported locales in the application. @@ -10,7 +11,7 @@ import { en } from "./en"; * announcements until `compositionend` is received. For detailed design specs and guidelines, see * `docs/IME_COMPOSITION_SUPPORT_SPEC.md`. */ -export type Locale = "en" | "es"; +export type Locale = "en" | "es" | "zx"; /** * The structure of the translation catalog, based on the English catalog. @@ -167,7 +168,16 @@ export function I18nProvider({ children, defaultLocale = "en" }: I18nProviderPro const [locale, setLocale] = useState(defaultLocale); // Active catalog selection - const catalog: TranslationCatalog = locale === "en" ? en : ({ ...en, ...es } as TranslationCatalog); + const catalog: TranslationCatalog = (() => { + switch (locale) { + case "zx": + return createPseudoLocale(en); + case "es": + return { ...en, ...es } as TranslationCatalog; + default: + return en; + } + })(); const t = (key: TranslationKey | PluralizableKey, params?: TranslationParams): string => { return translate(catalog, en, key, params); diff --git a/src/i18n/zx.ts b/src/i18n/zx.ts new file mode 100644 index 00000000..035a15a0 --- /dev/null +++ b/src/i18n/zx.ts @@ -0,0 +1,123 @@ +/** + * Pseudo-locale for testing i18n coverage. + * + * This locale transforms every English string by wrapping it in markers: + * "Create stream" β†’ "[ΔŒΕ™Γ©Γ‘Ε£Γ© ΕŸΕ£Ε•Γ©Γ‘ΠΌ]" + * + * The transformation: + * 1. Wraps the entire string in square brackets: [...] + * 2. Shifts Latin characters to their "accented" equivalents + * + * Purpose: + * When this locale is active, any string that bypasses the i18n layer + * (i.e. is hardcoded in a component) will appear WITHOUT the markers, + * making it immediately obvious in the UI that the string was missed. + * + * Usage: + * Set locale to "zx" in the I18nProvider to activate. + * Any untranslated string will fall back to English WITHOUT markers, + * visually distinguishing it from properly translated strings. + */ + +import { TranslationCatalog } from "./index"; + +// Mapping of basic Latin characters to their "accented" equivalents. +// This makes the pseudo-translation visually distinct while remaining readable. +const ACCENT_MAP: Record = { + a: "Γ‘", + b: "Π‘", + c: "č", + d: "Δ‘", + e: "Γ©", + f: "Ζ’", + g: "ğ", + h: "Δ₯", + i: "Γ­", + j: "Δ΅", + k: "ΔΈ", + l: "Ε‚", + m: "ΠΌ", + n: "Γ±", + o: "Γ³", + p: "ρ", + q: "q", + r: "Ε™", + s: "ş", + t: "Ε£", + u: "ΓΊ", + v: "Ξ½", + w: "Ο‰", + x: "Ρ…", + y: "Γ½", + z: "ΕΎ", + A: "Á", + B: "Π‘", + C: "Č", + D: "Đ", + E: "Γ‰", + F: "Ζ‘", + G: "Ğ", + H: "Δ€", + I: "Í", + J: "Δ΄", + K: "ΔΈ", + L: "Ł", + M: "М", + N: "Γ‘", + O: "Γ“", + P: "Ξ‘", + Q: "Q", + R: "Ř", + S: "Ş", + T: "Ε’", + U: "Ú", + V: "Ν", + W: "Ξ©", + X: "Π₯", + Y: "Ý", + Z: "Ε½", +}; + +/** + * Transforms a string into pseudo-localized form by wrapping in brackets + * and shifting characters to accented equivalents. + */ +export function pseudoLocalize(value: string): string { + // Preserve interpolation placeholders {token} unchanged + // Use null-byte delimited markers that won't be affected by accent mapping + const Placeholder_RE = /\{([^{}]+)\}/g; + const placeholders: string[] = []; + let sanitized = value.replace(Placeholder_RE, (_, token) => { + placeholders.push(`{${token}}`); + // Use null bytes as delimiters β€” they pass through map as-is + return `\x00${placeholders.length - 1}\x00`; + }); + + // Shift characters + const shifted = sanitized + .split("") + .map((ch) => ACCENT_MAP[ch] ?? ch) + .join(""); + + // Restore placeholders + const restored = placeholders.reduce( + (str, ph, idx) => str.replace(`\x00${idx}\x00`, ph), + shifted + ); + + return `[${restored}]`; +} + +/** + * Creates a pseudo-locale catalog by applying the transformation to every + * value in the English catalog. + */ +export function createPseudoLocale( + enCatalog: TranslationCatalog +): TranslationCatalog { + const catalog: Record = {}; + for (const [key, value] of Object.entries(enCatalog)) { + catalog[key] = pseudoLocalize(value); + } + return catalog as TranslationCatalog; +}