From ff04649d2ea1e4f94fd78373d2b2868c63d118a3 Mon Sep 17 00:00:00 2001 From: oxwel77 Date: Wed, 15 Jul 2026 13:49:27 +0100 Subject: [PATCH 01/44] fix(native): disable CapacitorHttp proxy, move native auth to Authorization header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CapacitorHttp's Android GET proxy (_capacitor_http_interceptor_) stalls under load, timing out every in-flight request after 10s (PEANUT-UI-R44): ~400 timeouts/user on Android vs ~3 on web/iOS over 14d, GETs only, in correlated bursts. Requests now go direct from the webview, same path as web. - auth: token moves from the CapacitorHttp cookie jar to @capacitor/preferences (survives webview storage eviction, unlike localStorage — PEANUT-UI-QTQ), hydrated into an in-memory cache and sent as Authorization. authReady() gates callApi and direct fetchWithSentry call sites against cold-start races. - login: the ZeroDev SDK consumes /passkeys/*/verify responses internally, so a window.fetch wrapper captures the body token on native. /users/me sliding refresh keeps it current via the existing setAuthToken path. - old binaries keep working: server still accepts the jwt-token cookie and hasNativeSession falls back to the legacy jar. Existing native sessions are not migrated — testers log in once after updating. - fetchWithSentry: idempotent requests (GET/HEAD) get one silent retry on timeout before surfacing. - canary: one-shot startup probe reports direct-fetch viability to Sentry (message:"direct-fetch canary", tags outcome/transport) so the transport switch is validated fleet-wide via OTA before the binary rolls out. --- android/app/capacitor.build.gradle | 2 + android/capacitor.settings.gradle | 6 + capacitor.config.ts | 7 +- ios/App/CapApp-SPM/Package.swift | 6 + package.json | 1 + pnpm-lock.yaml | 167 +++++++++-------- .../__tests__/api-headers-extended.test.ts | 1 + src/app/actions/__tests__/api-headers.test.ts | 1 + .../LandingPage/LandingPageCapacitorGate.tsx | 9 +- src/config/peanut.config.tsx | 13 +- src/hooks/useLimits.ts | 7 +- src/services/charges.ts | 3 +- src/services/rhino-bridge.ts | 4 +- src/services/rhino-sda.ts | 3 +- src/services/sendLinks.ts | 3 +- src/utils/__tests__/api-fetch.test.ts | 1 + src/utils/__tests__/auth-token.test.ts | 172 ++++++++++++++---- src/utils/api-fetch.ts | 8 +- src/utils/auth-token.ts | 92 +++++++--- src/utils/native-auth-capture.ts | 35 ++++ src/utils/native-canary.ts | 53 ++++++ src/utils/sentry.utils.ts | 36 +++- 22 files changed, 466 insertions(+), 164 deletions(-) create mode 100644 src/utils/native-auth-capture.ts create mode 100644 src/utils/native-canary.ts diff --git a/android/app/capacitor.build.gradle b/android/app/capacitor.build.gradle index d023f8ac12..de320937e6 100644 --- a/android/app/capacitor.build.gradle +++ b/android/app/capacitor.build.gradle @@ -11,9 +11,11 @@ apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle" dependencies { implementation project(':capacitor-app') implementation project(':capacitor-browser') + implementation project(':capacitor-camera') implementation project(':capacitor-clipboard') implementation project(':capacitor-haptics') implementation project(':capacitor-keyboard') + implementation project(':capacitor-preferences') implementation project(':capacitor-splash-screen') implementation project(':capacitor-status-bar') implementation project(':capgo-capacitor-crisp') diff --git a/android/capacitor.settings.gradle b/android/capacitor.settings.gradle index 9d8d764e58..93bbf1c9ed 100644 --- a/android/capacitor.settings.gradle +++ b/android/capacitor.settings.gradle @@ -8,6 +8,9 @@ project(':capacitor-app').projectDir = new File('../node_modules/.pnpm/@capacito include ':capacitor-browser' project(':capacitor-browser').projectDir = new File('../node_modules/.pnpm/@capacitor+browser@8.0.3_@capacitor+core@8.2.0/node_modules/@capacitor/browser/android') +include ':capacitor-camera' +project(':capacitor-camera').projectDir = new File('../node_modules/.pnpm/@capacitor+camera@8.2.0_@capacitor+core@8.2.0/node_modules/@capacitor/camera/android') + include ':capacitor-clipboard' project(':capacitor-clipboard').projectDir = new File('../node_modules/.pnpm/@capacitor+clipboard@8.0.1_@capacitor+core@8.2.0/node_modules/@capacitor/clipboard/android') @@ -17,6 +20,9 @@ project(':capacitor-haptics').projectDir = new File('../node_modules/.pnpm/@capa include ':capacitor-keyboard' project(':capacitor-keyboard').projectDir = new File('../node_modules/.pnpm/@capacitor+keyboard@8.0.3_@capacitor+core@8.2.0/node_modules/@capacitor/keyboard/android') +include ':capacitor-preferences' +project(':capacitor-preferences').projectDir = new File('../node_modules/.pnpm/@capacitor+preferences@8.0.1_@capacitor+core@8.2.0/node_modules/@capacitor/preferences/android') + include ':capacitor-splash-screen' project(':capacitor-splash-screen').projectDir = new File('../node_modules/.pnpm/@capacitor+splash-screen@8.0.1_@capacitor+core@8.2.0/node_modules/@capacitor/splash-screen/android') diff --git a/capacitor.config.ts b/capacitor.config.ts index 91a84133bf..6786e8f4f9 100644 --- a/capacitor.config.ts +++ b/capacitor.config.ts @@ -39,8 +39,13 @@ const config: CapacitorConfig = { publicKey: '-----BEGIN RSA PUBLIC KEY-----\nMIIBCgKCAQEAr0HzEca/1vuvWcJ8/xYB6tx0j4uJMzw/kT34GnjyMlRmLLUIO9sj\nroXaUGaNoqlOCx73b7Qgp10TLPOAVxmoHV9ZJ4BS9cMCl5mvzB4qIdl6FZLcl3g5\nk5Nkj4w22nskqbBqL7eqMXpk4DD9oWRclnaZC/lCpok1n2AWy4EMZrshemBQ6iXr\ncppo+WByPbqmh/GbHvJyRvkx4Rgt2LSBJBI3laP3eEDkujCq1ZH9qgcIE4MXO5xq\n7c6LsLjN5wkQiNPSPI81zAbqBThhqodKzwav0FwIE1pyiJeGk1nV5Ji5kUgpFNwY\nY78iDVq4OP2jPfWO4jXnnJtnGN7aeKDMEQIDAQAB\n-----END RSA PUBLIC KEY-----\n', }, + // CapacitorHttp OFF: its Android GET proxy (_capacitor_http_interceptor_) + // stalls under load, timing out every in-flight request (PEANUT-UI-R44). + // Requests go direct from the webview — CORS allows https://localhost and + // capacitor://, and auth rides the Authorization header from native + // Preferences (src/utils/auth-token.ts) instead of the plugin's cookie jar. CapacitorHttp: { - enabled: true, + enabled: false, }, CapacitorPasskey: { // shim patches navigator.credentials.create/get so browser WebAuthn code works natively. diff --git a/ios/App/CapApp-SPM/Package.swift b/ios/App/CapApp-SPM/Package.swift index 24539268d7..6e0923aef3 100644 --- a/ios/App/CapApp-SPM/Package.swift +++ b/ios/App/CapApp-SPM/Package.swift @@ -14,8 +14,11 @@ let package = Package( .package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.2.0"), .package(name: "CapacitorApp", path: "../../../node_modules/.pnpm/@capacitor+app@8.1.0_@capacitor+core@8.2.0/node_modules/@capacitor/app"), .package(name: "CapacitorBrowser", path: "../../../node_modules/.pnpm/@capacitor+browser@8.0.3_@capacitor+core@8.2.0/node_modules/@capacitor/browser"), + .package(name: "CapacitorCamera", path: "../../../node_modules/.pnpm/@capacitor+camera@8.2.0_@capacitor+core@8.2.0/node_modules/@capacitor/camera"), + .package(name: "CapacitorClipboard", path: "../../../node_modules/.pnpm/@capacitor+clipboard@8.0.1_@capacitor+core@8.2.0/node_modules/@capacitor/clipboard"), .package(name: "CapacitorHaptics", path: "../../../node_modules/.pnpm/@capacitor+haptics@8.0.2_@capacitor+core@8.2.0/node_modules/@capacitor/haptics"), .package(name: "CapacitorKeyboard", path: "../../../node_modules/.pnpm/@capacitor+keyboard@8.0.3_@capacitor+core@8.2.0/node_modules/@capacitor/keyboard"), + .package(name: "CapacitorPreferences", path: "../../../node_modules/.pnpm/@capacitor+preferences@8.0.1_@capacitor+core@8.2.0/node_modules/@capacitor/preferences"), .package(name: "CapacitorSplashScreen", path: "../../../node_modules/.pnpm/@capacitor+splash-screen@8.0.1_@capacitor+core@8.2.0/node_modules/@capacitor/splash-screen"), .package(name: "CapacitorStatusBar", path: "../../../node_modules/.pnpm/@capacitor+status-bar@8.0.2_@capacitor+core@8.2.0/node_modules/@capacitor/status-bar"), .package(name: "CapgoCapacitorCrisp", path: "../../../node_modules/.pnpm/@capgo+capacitor-crisp@8.0.27_@capacitor+core@8.2.0/node_modules/@capgo/capacitor-crisp"), @@ -32,8 +35,11 @@ let package = Package( .product(name: "Cordova", package: "capacitor-swift-pm"), .product(name: "CapacitorApp", package: "CapacitorApp"), .product(name: "CapacitorBrowser", package: "CapacitorBrowser"), + .product(name: "CapacitorCamera", package: "CapacitorCamera"), + .product(name: "CapacitorClipboard", package: "CapacitorClipboard"), .product(name: "CapacitorHaptics", package: "CapacitorHaptics"), .product(name: "CapacitorKeyboard", package: "CapacitorKeyboard"), + .product(name: "CapacitorPreferences", package: "CapacitorPreferences"), .product(name: "CapacitorSplashScreen", package: "CapacitorSplashScreen"), .product(name: "CapacitorStatusBar", package: "CapacitorStatusBar"), .product(name: "CapgoCapacitorCrisp", package: "CapgoCapacitorCrisp"), diff --git a/package.json b/package.json index 7c3a95aa28..b28f5f71ed 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "@capacitor/haptics": "^8.0.2", "@capacitor/ios": "8.2.0", "@capacitor/keyboard": "^8.0.3", + "@capacitor/preferences": "^8.0.1", "@capacitor/splash-screen": "^8.0.1", "@capacitor/status-bar": "^8.0.2", "@capgo/capacitor-crisp": "^8.0.27", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 790756190f..03fc3a3a9c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -57,6 +57,9 @@ importers: '@capacitor/keyboard': specifier: ^8.0.3 version: 8.0.3(@capacitor/core@8.2.0) + '@capacitor/preferences': + specifier: ^8.0.1 + version: 8.0.1(@capacitor/core@8.2.0) '@capacitor/splash-screen': specifier: ^8.0.1 version: 8.0.1(@capacitor/core@8.2.0) @@ -80,7 +83,7 @@ importers: version: 0.2.2(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3)) '@justaname.id/react': specifier: 0.3.180 - version: 0.3.180(@tanstack/react-query@5.8.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(ethers@5.7.2(bufferutil@4.1.0)(utf-8-validate@5.0.10))(react@19.2.4)(siwe@2.3.2(ethers@5.7.2(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(typescript@5.9.3)(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6))(wagmi@2.16.3(@tanstack/query-core@5.8.3)(@tanstack/react-query@5.8.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@types/react@18.3.27)(bufferutil@4.1.0)(immer@11.1.3)(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6))(zod@4.3.6))(zod@4.3.6) + version: 0.3.180(@tanstack/react-query@5.8.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(ethers@5.7.2(bufferutil@4.1.0)(utf-8-validate@5.0.10))(react@19.2.4)(siwe@2.3.2(ethers@5.7.2(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(typescript@5.9.3)(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6))(wagmi@2.16.3(@capacitor/preferences@8.0.1(@capacitor/core@8.2.0))(@tanstack/query-core@5.8.3)(@tanstack/react-query@5.8.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@types/react@18.3.27)(bufferutil@4.1.0)(immer@11.1.3)(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6))(zod@4.3.6))(zod@4.3.6) '@justaname.id/sdk': specifier: 0.2.177 version: 0.2.177(ethers@5.7.2(bufferutil@4.1.0)(utf-8-validate@5.0.10))(siwe@2.3.2(ethers@5.7.2(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)) @@ -269,7 +272,7 @@ importers: version: 2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) wagmi: specifier: 2.16.3 - version: 2.16.3(@tanstack/query-core@5.8.3)(@tanstack/react-query@5.8.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@types/react@18.3.27)(bufferutil@4.1.0)(immer@11.1.3)(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6))(zod@4.3.6) + version: 2.16.3(@capacitor/preferences@8.0.1(@capacitor/core@8.2.0))(@tanstack/query-core@5.8.3)(@tanstack/react-query@5.8.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@types/react@18.3.27)(bufferutil@4.1.0)(immer@11.1.3)(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6))(zod@4.3.6) devDependencies: '@next/bundle-analyzer': specifier: ^16.1.1 @@ -624,6 +627,11 @@ packages: peerDependencies: '@capacitor/core': '>=8.0.0' + '@capacitor/preferences@8.0.1': + resolution: {integrity: sha512-T6no3ebi79XJCk91U3Jp/liJUwgBdvHR+s6vhvPkPxSuch7z3zx5Rv1bdWM6sWruNx+pViuEGqZvbfCdyBvcHQ==} + peerDependencies: + '@capacitor/core': '>=8.0.0' + '@capacitor/splash-screen@8.0.1': resolution: {integrity: sha512-c/ew/Z3eA7z8l06WoRAtzVF16VwYYrExmHmfGq1Cg675pVzaC/yuucB8/1xG1vhEfnW4fZ1KhSf/kzR1RiVYgg==} peerDependencies: @@ -8389,6 +8397,10 @@ snapshots: dependencies: '@capacitor/core': 8.2.0 + '@capacitor/preferences@8.0.1(@capacitor/core@8.2.0)': + dependencies: + '@capacitor/core': 8.2.0 + '@capacitor/splash-screen@8.0.1(@capacitor/core@8.2.0)': dependencies: '@capacitor/core': 8.2.0 @@ -9363,7 +9375,7 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@justaname.id/react@0.3.180(@tanstack/react-query@5.8.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(ethers@5.7.2(bufferutil@4.1.0)(utf-8-validate@5.0.10))(react@19.2.4)(siwe@2.3.2(ethers@5.7.2(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(typescript@5.9.3)(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6))(wagmi@2.16.3(@tanstack/query-core@5.8.3)(@tanstack/react-query@5.8.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@types/react@18.3.27)(bufferutil@4.1.0)(immer@11.1.3)(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6))(zod@4.3.6))(zod@4.3.6)': + '@justaname.id/react@0.3.180(@tanstack/react-query@5.8.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(ethers@5.7.2(bufferutil@4.1.0)(utf-8-validate@5.0.10))(react@19.2.4)(siwe@2.3.2(ethers@5.7.2(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(typescript@5.9.3)(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6))(wagmi@2.16.3(@capacitor/preferences@8.0.1(@capacitor/core@8.2.0))(@tanstack/query-core@5.8.3)(@tanstack/react-query@5.8.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@types/react@18.3.27)(bufferutil@4.1.0)(immer@11.1.3)(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6))(zod@4.3.6))(zod@4.3.6)': dependencies: '@ensdomains/ensjs': 4.0.2(typescript@5.9.3)(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6))(zod@4.3.6) '@justaname.id/sdk': 0.2.177(ethers@5.7.2(bufferutil@4.1.0)(utf-8-validate@5.0.10))(siwe@2.3.2(ethers@5.7.2(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)) @@ -9373,7 +9385,7 @@ snapshots: qs: 6.14.1 react: 19.2.4 viem: 2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) - wagmi: 2.16.3(@tanstack/query-core@5.8.3)(@tanstack/react-query@5.8.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@types/react@18.3.27)(bufferutil@4.1.0)(immer@11.1.3)(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6))(zod@4.3.6) + wagmi: 2.16.3(@capacitor/preferences@8.0.1(@capacitor/core@8.2.0))(@tanstack/query-core@5.8.3)(@tanstack/react-query@5.8.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@types/react@18.3.27)(bufferutil@4.1.0)(immer@11.1.3)(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6))(zod@4.3.6) transitivePeerDependencies: - debug - encoding @@ -10654,11 +10666,11 @@ snapshots: - utf-8-validate - zod - '@reown/appkit-controllers@1.7.8(@types/react@18.3.27)(bufferutil@4.1.0)(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)': + '@reown/appkit-controllers@1.7.8(@capacitor/preferences@8.0.1(@capacitor/core@8.2.0))(@types/react@18.3.27)(bufferutil@4.1.0)(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)': dependencies: '@reown/appkit-common': 1.7.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) '@reown/appkit-wallet': 1.7.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) - '@walletconnect/universal-provider': 2.21.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) + '@walletconnect/universal-provider': 2.21.0(@capacitor/preferences@8.0.1(@capacitor/core@8.2.0))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) valtio: 1.13.2(@types/react@18.3.27)(react@19.2.4) viem: 2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) transitivePeerDependencies: @@ -10689,12 +10701,12 @@ snapshots: - utf-8-validate - zod - '@reown/appkit-pay@1.7.8(@types/react@18.3.27)(bufferutil@4.1.0)(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)': + '@reown/appkit-pay@1.7.8(@capacitor/preferences@8.0.1(@capacitor/core@8.2.0))(@types/react@18.3.27)(bufferutil@4.1.0)(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)': dependencies: '@reown/appkit-common': 1.7.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) - '@reown/appkit-controllers': 1.7.8(@types/react@18.3.27)(bufferutil@4.1.0)(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) - '@reown/appkit-ui': 1.7.8(@types/react@18.3.27)(bufferutil@4.1.0)(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) - '@reown/appkit-utils': 1.7.8(@types/react@18.3.27)(bufferutil@4.1.0)(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)(valtio@1.13.2(@types/react@18.3.27)(react@19.2.4))(zod@4.3.6) + '@reown/appkit-controllers': 1.7.8(@capacitor/preferences@8.0.1(@capacitor/core@8.2.0))(@types/react@18.3.27)(bufferutil@4.1.0)(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) + '@reown/appkit-ui': 1.7.8(@capacitor/preferences@8.0.1(@capacitor/core@8.2.0))(@types/react@18.3.27)(bufferutil@4.1.0)(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) + '@reown/appkit-utils': 1.7.8(@capacitor/preferences@8.0.1(@capacitor/core@8.2.0))(@types/react@18.3.27)(bufferutil@4.1.0)(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)(valtio@1.13.2(@types/react@18.3.27)(react@19.2.4))(zod@4.3.6) lit: 3.3.0 valtio: 1.13.2(@types/react@18.3.27)(react@19.2.4) transitivePeerDependencies: @@ -10729,12 +10741,12 @@ snapshots: dependencies: buffer: 6.0.3 - '@reown/appkit-scaffold-ui@1.7.8(@types/react@18.3.27)(bufferutil@4.1.0)(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)(valtio@1.13.2(@types/react@18.3.27)(react@19.2.4))(zod@4.3.6)': + '@reown/appkit-scaffold-ui@1.7.8(@capacitor/preferences@8.0.1(@capacitor/core@8.2.0))(@types/react@18.3.27)(bufferutil@4.1.0)(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)(valtio@1.13.2(@types/react@18.3.27)(react@19.2.4))(zod@4.3.6)': dependencies: '@reown/appkit-common': 1.7.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) - '@reown/appkit-controllers': 1.7.8(@types/react@18.3.27)(bufferutil@4.1.0)(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) - '@reown/appkit-ui': 1.7.8(@types/react@18.3.27)(bufferutil@4.1.0)(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) - '@reown/appkit-utils': 1.7.8(@types/react@18.3.27)(bufferutil@4.1.0)(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)(valtio@1.13.2(@types/react@18.3.27)(react@19.2.4))(zod@4.3.6) + '@reown/appkit-controllers': 1.7.8(@capacitor/preferences@8.0.1(@capacitor/core@8.2.0))(@types/react@18.3.27)(bufferutil@4.1.0)(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) + '@reown/appkit-ui': 1.7.8(@capacitor/preferences@8.0.1(@capacitor/core@8.2.0))(@types/react@18.3.27)(bufferutil@4.1.0)(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) + '@reown/appkit-utils': 1.7.8(@capacitor/preferences@8.0.1(@capacitor/core@8.2.0))(@types/react@18.3.27)(bufferutil@4.1.0)(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)(valtio@1.13.2(@types/react@18.3.27)(react@19.2.4))(zod@4.3.6) '@reown/appkit-wallet': 1.7.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) lit: 3.3.0 transitivePeerDependencies: @@ -10766,10 +10778,10 @@ snapshots: - valtio - zod - '@reown/appkit-ui@1.7.8(@types/react@18.3.27)(bufferutil@4.1.0)(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)': + '@reown/appkit-ui@1.7.8(@capacitor/preferences@8.0.1(@capacitor/core@8.2.0))(@types/react@18.3.27)(bufferutil@4.1.0)(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)': dependencies: '@reown/appkit-common': 1.7.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) - '@reown/appkit-controllers': 1.7.8(@types/react@18.3.27)(bufferutil@4.1.0)(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) + '@reown/appkit-controllers': 1.7.8(@capacitor/preferences@8.0.1(@capacitor/core@8.2.0))(@types/react@18.3.27)(bufferutil@4.1.0)(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) '@reown/appkit-wallet': 1.7.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) lit: 3.3.0 qrcode: 1.5.3 @@ -10801,14 +10813,14 @@ snapshots: - utf-8-validate - zod - '@reown/appkit-utils@1.7.8(@types/react@18.3.27)(bufferutil@4.1.0)(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)(valtio@1.13.2(@types/react@18.3.27)(react@19.2.4))(zod@4.3.6)': + '@reown/appkit-utils@1.7.8(@capacitor/preferences@8.0.1(@capacitor/core@8.2.0))(@types/react@18.3.27)(bufferutil@4.1.0)(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)(valtio@1.13.2(@types/react@18.3.27)(react@19.2.4))(zod@4.3.6)': dependencies: '@reown/appkit-common': 1.7.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) - '@reown/appkit-controllers': 1.7.8(@types/react@18.3.27)(bufferutil@4.1.0)(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) + '@reown/appkit-controllers': 1.7.8(@capacitor/preferences@8.0.1(@capacitor/core@8.2.0))(@types/react@18.3.27)(bufferutil@4.1.0)(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) '@reown/appkit-polyfills': 1.7.8 '@reown/appkit-wallet': 1.7.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) '@walletconnect/logger': 2.1.2 - '@walletconnect/universal-provider': 2.21.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) + '@walletconnect/universal-provider': 2.21.0(@capacitor/preferences@8.0.1(@capacitor/core@8.2.0))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) valtio: 1.13.2(@types/react@18.3.27)(react@19.2.4) viem: 2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) transitivePeerDependencies: @@ -10850,18 +10862,18 @@ snapshots: - typescript - utf-8-validate - '@reown/appkit@1.7.8(@types/react@18.3.27)(bufferutil@4.1.0)(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)': + '@reown/appkit@1.7.8(@capacitor/preferences@8.0.1(@capacitor/core@8.2.0))(@types/react@18.3.27)(bufferutil@4.1.0)(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)': dependencies: '@reown/appkit-common': 1.7.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) - '@reown/appkit-controllers': 1.7.8(@types/react@18.3.27)(bufferutil@4.1.0)(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) - '@reown/appkit-pay': 1.7.8(@types/react@18.3.27)(bufferutil@4.1.0)(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) + '@reown/appkit-controllers': 1.7.8(@capacitor/preferences@8.0.1(@capacitor/core@8.2.0))(@types/react@18.3.27)(bufferutil@4.1.0)(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) + '@reown/appkit-pay': 1.7.8(@capacitor/preferences@8.0.1(@capacitor/core@8.2.0))(@types/react@18.3.27)(bufferutil@4.1.0)(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) '@reown/appkit-polyfills': 1.7.8 - '@reown/appkit-scaffold-ui': 1.7.8(@types/react@18.3.27)(bufferutil@4.1.0)(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)(valtio@1.13.2(@types/react@18.3.27)(react@19.2.4))(zod@4.3.6) - '@reown/appkit-ui': 1.7.8(@types/react@18.3.27)(bufferutil@4.1.0)(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) - '@reown/appkit-utils': 1.7.8(@types/react@18.3.27)(bufferutil@4.1.0)(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)(valtio@1.13.2(@types/react@18.3.27)(react@19.2.4))(zod@4.3.6) + '@reown/appkit-scaffold-ui': 1.7.8(@capacitor/preferences@8.0.1(@capacitor/core@8.2.0))(@types/react@18.3.27)(bufferutil@4.1.0)(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)(valtio@1.13.2(@types/react@18.3.27)(react@19.2.4))(zod@4.3.6) + '@reown/appkit-ui': 1.7.8(@capacitor/preferences@8.0.1(@capacitor/core@8.2.0))(@types/react@18.3.27)(bufferutil@4.1.0)(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) + '@reown/appkit-utils': 1.7.8(@capacitor/preferences@8.0.1(@capacitor/core@8.2.0))(@types/react@18.3.27)(bufferutil@4.1.0)(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)(valtio@1.13.2(@types/react@18.3.27)(react@19.2.4))(zod@4.3.6) '@reown/appkit-wallet': 1.7.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) - '@walletconnect/types': 2.21.0 - '@walletconnect/universal-provider': 2.21.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) + '@walletconnect/types': 2.21.0(@capacitor/preferences@8.0.1(@capacitor/core@8.2.0)) + '@walletconnect/universal-provider': 2.21.0(@capacitor/preferences@8.0.1(@capacitor/core@8.2.0))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) bs58: 6.0.0 valtio: 1.13.2(@types/react@18.3.27)(react@19.2.4) viem: 2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) @@ -11720,7 +11732,7 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true - '@wagmi/connectors@5.9.3(@types/react@18.3.27)(@wagmi/core@2.19.0(@tanstack/query-core@5.8.3)(@types/react@18.3.27)(immer@11.1.3)(react@19.2.4)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.4))(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)))(bufferutil@4.1.0)(immer@11.1.3)(react@19.2.4)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.4))(utf-8-validate@5.0.10)(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6))(zod@4.3.6)': + '@wagmi/connectors@5.9.3(@capacitor/preferences@8.0.1(@capacitor/core@8.2.0))(@types/react@18.3.27)(@wagmi/core@2.19.0(@tanstack/query-core@5.8.3)(@types/react@18.3.27)(immer@11.1.3)(react@19.2.4)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.4))(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)))(bufferutil@4.1.0)(immer@11.1.3)(react@19.2.4)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.4))(utf-8-validate@5.0.10)(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6))(zod@4.3.6)': dependencies: '@base-org/account': 1.1.1(@types/react@18.3.27)(bufferutil@4.1.0)(immer@11.1.3)(react@19.2.4)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.4))(utf-8-validate@5.0.10)(zod@4.3.6) '@coinbase/wallet-sdk': 4.3.6(@types/react@18.3.27)(bufferutil@4.1.0)(immer@11.1.3)(react@19.2.4)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.4))(utf-8-validate@5.0.10)(zod@4.3.6) @@ -11729,7 +11741,7 @@ snapshots: '@safe-global/safe-apps-provider': 0.18.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) '@safe-global/safe-apps-sdk': 9.1.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) '@wagmi/core': 2.19.0(@tanstack/query-core@5.8.3)(@types/react@18.3.27)(immer@11.1.3)(react@19.2.4)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.4))(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)) - '@walletconnect/ethereum-provider': 2.21.1(@types/react@18.3.27)(bufferutil@4.1.0)(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) + '@walletconnect/ethereum-provider': 2.21.1(@capacitor/preferences@8.0.1(@capacitor/core@8.2.0))(@types/react@18.3.27)(bufferutil@4.1.0)(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) cbw-sdk: '@coinbase/wallet-sdk@3.9.3' viem: 2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) optionalDependencies: @@ -11779,21 +11791,21 @@ snapshots: - react - use-sync-external-store - '@walletconnect/core@2.21.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)': + '@walletconnect/core@2.21.0(@capacitor/preferences@8.0.1(@capacitor/core@8.2.0))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)': dependencies: '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-provider': 1.0.14 '@walletconnect/jsonrpc-types': 1.0.4 '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/jsonrpc-ws-connection': 1.0.16(bufferutil@4.1.0)(utf-8-validate@5.0.10) - '@walletconnect/keyvaluestorage': 1.1.1 + '@walletconnect/keyvaluestorage': 1.1.1(@capacitor/preferences@8.0.1(@capacitor/core@8.2.0)) '@walletconnect/logger': 2.1.2 '@walletconnect/relay-api': 1.0.11 '@walletconnect/relay-auth': 1.1.0 '@walletconnect/safe-json': 1.0.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.21.0 - '@walletconnect/utils': 2.21.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) + '@walletconnect/types': 2.21.0(@capacitor/preferences@8.0.1(@capacitor/core@8.2.0)) + '@walletconnect/utils': 2.21.0(@capacitor/preferences@8.0.1(@capacitor/core@8.2.0))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) '@walletconnect/window-getters': 1.0.1 es-toolkit: 1.33.0 events: 3.3.0 @@ -11823,21 +11835,21 @@ snapshots: - utf-8-validate - zod - '@walletconnect/core@2.21.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)': + '@walletconnect/core@2.21.1(@capacitor/preferences@8.0.1(@capacitor/core@8.2.0))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)': dependencies: '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-provider': 1.0.14 '@walletconnect/jsonrpc-types': 1.0.4 '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/jsonrpc-ws-connection': 1.0.16(bufferutil@4.1.0)(utf-8-validate@5.0.10) - '@walletconnect/keyvaluestorage': 1.1.1 + '@walletconnect/keyvaluestorage': 1.1.1(@capacitor/preferences@8.0.1(@capacitor/core@8.2.0)) '@walletconnect/logger': 2.1.2 '@walletconnect/relay-api': 1.0.11 '@walletconnect/relay-auth': 1.1.0 '@walletconnect/safe-json': 1.0.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.21.1 - '@walletconnect/utils': 2.21.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) + '@walletconnect/types': 2.21.1(@capacitor/preferences@8.0.1(@capacitor/core@8.2.0)) + '@walletconnect/utils': 2.21.1(@capacitor/preferences@8.0.1(@capacitor/core@8.2.0))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) '@walletconnect/window-getters': 1.0.1 es-toolkit: 1.33.0 events: 3.3.0 @@ -11871,18 +11883,18 @@ snapshots: dependencies: tslib: 1.14.1 - '@walletconnect/ethereum-provider@2.21.1(@types/react@18.3.27)(bufferutil@4.1.0)(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)': + '@walletconnect/ethereum-provider@2.21.1(@capacitor/preferences@8.0.1(@capacitor/core@8.2.0))(@types/react@18.3.27)(bufferutil@4.1.0)(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)': dependencies: - '@reown/appkit': 1.7.8(@types/react@18.3.27)(bufferutil@4.1.0)(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) + '@reown/appkit': 1.7.8(@capacitor/preferences@8.0.1(@capacitor/core@8.2.0))(@types/react@18.3.27)(bufferutil@4.1.0)(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) '@walletconnect/jsonrpc-http-connection': 1.0.8 '@walletconnect/jsonrpc-provider': 1.0.14 '@walletconnect/jsonrpc-types': 1.0.4 '@walletconnect/jsonrpc-utils': 1.0.8 - '@walletconnect/keyvaluestorage': 1.1.1 - '@walletconnect/sign-client': 2.21.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) - '@walletconnect/types': 2.21.1 - '@walletconnect/universal-provider': 2.21.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) - '@walletconnect/utils': 2.21.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) + '@walletconnect/keyvaluestorage': 1.1.1(@capacitor/preferences@8.0.1(@capacitor/core@8.2.0)) + '@walletconnect/sign-client': 2.21.1(@capacitor/preferences@8.0.1(@capacitor/core@8.2.0))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) + '@walletconnect/types': 2.21.1(@capacitor/preferences@8.0.1(@capacitor/core@8.2.0)) + '@walletconnect/universal-provider': 2.21.1(@capacitor/preferences@8.0.1(@capacitor/core@8.2.0))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) + '@walletconnect/utils': 2.21.1(@capacitor/preferences@8.0.1(@capacitor/core@8.2.0))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) events: 3.3.0 transitivePeerDependencies: - '@azure/app-configuration' @@ -11959,11 +11971,11 @@ snapshots: - bufferutil - utf-8-validate - '@walletconnect/keyvaluestorage@1.1.1': + '@walletconnect/keyvaluestorage@1.1.1(@capacitor/preferences@8.0.1(@capacitor/core@8.2.0))': dependencies: '@walletconnect/safe-json': 1.0.2 idb-keyval: 6.2.2 - unstorage: 1.17.4(idb-keyval@6.2.2) + unstorage: 1.17.4(@capacitor/preferences@8.0.1(@capacitor/core@8.2.0))(idb-keyval@6.2.2) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -12005,16 +12017,16 @@ snapshots: dependencies: tslib: 1.14.1 - '@walletconnect/sign-client@2.21.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)': + '@walletconnect/sign-client@2.21.0(@capacitor/preferences@8.0.1(@capacitor/core@8.2.0))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)': dependencies: - '@walletconnect/core': 2.21.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) + '@walletconnect/core': 2.21.0(@capacitor/preferences@8.0.1(@capacitor/core@8.2.0))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) '@walletconnect/events': 1.0.1 '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/logger': 2.1.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.21.0 - '@walletconnect/utils': 2.21.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) + '@walletconnect/types': 2.21.0(@capacitor/preferences@8.0.1(@capacitor/core@8.2.0)) + '@walletconnect/utils': 2.21.0(@capacitor/preferences@8.0.1(@capacitor/core@8.2.0))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) events: 3.3.0 transitivePeerDependencies: - '@azure/app-configuration' @@ -12041,16 +12053,16 @@ snapshots: - utf-8-validate - zod - '@walletconnect/sign-client@2.21.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)': + '@walletconnect/sign-client@2.21.1(@capacitor/preferences@8.0.1(@capacitor/core@8.2.0))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)': dependencies: - '@walletconnect/core': 2.21.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) + '@walletconnect/core': 2.21.1(@capacitor/preferences@8.0.1(@capacitor/core@8.2.0))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) '@walletconnect/events': 1.0.1 '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/logger': 2.1.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.21.1 - '@walletconnect/utils': 2.21.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) + '@walletconnect/types': 2.21.1(@capacitor/preferences@8.0.1(@capacitor/core@8.2.0)) + '@walletconnect/utils': 2.21.1(@capacitor/preferences@8.0.1(@capacitor/core@8.2.0))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) events: 3.3.0 transitivePeerDependencies: - '@azure/app-configuration' @@ -12081,12 +12093,12 @@ snapshots: dependencies: tslib: 1.14.1 - '@walletconnect/types@2.21.0': + '@walletconnect/types@2.21.0(@capacitor/preferences@8.0.1(@capacitor/core@8.2.0))': dependencies: '@walletconnect/events': 1.0.1 '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-types': 1.0.4 - '@walletconnect/keyvaluestorage': 1.1.1 + '@walletconnect/keyvaluestorage': 1.1.1(@capacitor/preferences@8.0.1(@capacitor/core@8.2.0)) '@walletconnect/logger': 2.1.2 events: 3.3.0 transitivePeerDependencies: @@ -12110,12 +12122,12 @@ snapshots: - ioredis - uploadthing - '@walletconnect/types@2.21.1': + '@walletconnect/types@2.21.1(@capacitor/preferences@8.0.1(@capacitor/core@8.2.0))': dependencies: '@walletconnect/events': 1.0.1 '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-types': 1.0.4 - '@walletconnect/keyvaluestorage': 1.1.1 + '@walletconnect/keyvaluestorage': 1.1.1(@capacitor/preferences@8.0.1(@capacitor/core@8.2.0)) '@walletconnect/logger': 2.1.2 events: 3.3.0 transitivePeerDependencies: @@ -12139,18 +12151,18 @@ snapshots: - ioredis - uploadthing - '@walletconnect/universal-provider@2.21.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)': + '@walletconnect/universal-provider@2.21.0(@capacitor/preferences@8.0.1(@capacitor/core@8.2.0))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)': dependencies: '@walletconnect/events': 1.0.1 '@walletconnect/jsonrpc-http-connection': 1.0.8 '@walletconnect/jsonrpc-provider': 1.0.14 '@walletconnect/jsonrpc-types': 1.0.4 '@walletconnect/jsonrpc-utils': 1.0.8 - '@walletconnect/keyvaluestorage': 1.1.1 + '@walletconnect/keyvaluestorage': 1.1.1(@capacitor/preferences@8.0.1(@capacitor/core@8.2.0)) '@walletconnect/logger': 2.1.2 - '@walletconnect/sign-client': 2.21.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) - '@walletconnect/types': 2.21.0 - '@walletconnect/utils': 2.21.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) + '@walletconnect/sign-client': 2.21.0(@capacitor/preferences@8.0.1(@capacitor/core@8.2.0))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) + '@walletconnect/types': 2.21.0(@capacitor/preferences@8.0.1(@capacitor/core@8.2.0)) + '@walletconnect/utils': 2.21.0(@capacitor/preferences@8.0.1(@capacitor/core@8.2.0))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) es-toolkit: 1.33.0 events: 3.3.0 transitivePeerDependencies: @@ -12179,18 +12191,18 @@ snapshots: - utf-8-validate - zod - '@walletconnect/universal-provider@2.21.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)': + '@walletconnect/universal-provider@2.21.1(@capacitor/preferences@8.0.1(@capacitor/core@8.2.0))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)': dependencies: '@walletconnect/events': 1.0.1 '@walletconnect/jsonrpc-http-connection': 1.0.8 '@walletconnect/jsonrpc-provider': 1.0.14 '@walletconnect/jsonrpc-types': 1.0.4 '@walletconnect/jsonrpc-utils': 1.0.8 - '@walletconnect/keyvaluestorage': 1.1.1 + '@walletconnect/keyvaluestorage': 1.1.1(@capacitor/preferences@8.0.1(@capacitor/core@8.2.0)) '@walletconnect/logger': 2.1.2 - '@walletconnect/sign-client': 2.21.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) - '@walletconnect/types': 2.21.1 - '@walletconnect/utils': 2.21.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) + '@walletconnect/sign-client': 2.21.1(@capacitor/preferences@8.0.1(@capacitor/core@8.2.0))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) + '@walletconnect/types': 2.21.1(@capacitor/preferences@8.0.1(@capacitor/core@8.2.0)) + '@walletconnect/utils': 2.21.1(@capacitor/preferences@8.0.1(@capacitor/core@8.2.0))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) es-toolkit: 1.33.0 events: 3.3.0 transitivePeerDependencies: @@ -12219,18 +12231,18 @@ snapshots: - utf-8-validate - zod - '@walletconnect/utils@2.21.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)': + '@walletconnect/utils@2.21.0(@capacitor/preferences@8.0.1(@capacitor/core@8.2.0))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)': dependencies: '@noble/ciphers': 1.2.1 '@noble/curves': 1.8.1 '@noble/hashes': 1.7.1 '@walletconnect/jsonrpc-utils': 1.0.8 - '@walletconnect/keyvaluestorage': 1.1.1 + '@walletconnect/keyvaluestorage': 1.1.1(@capacitor/preferences@8.0.1(@capacitor/core@8.2.0)) '@walletconnect/relay-api': 1.0.11 '@walletconnect/relay-auth': 1.1.0 '@walletconnect/safe-json': 1.0.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.21.0 + '@walletconnect/types': 2.21.0(@capacitor/preferences@8.0.1(@capacitor/core@8.2.0)) '@walletconnect/window-getters': 1.0.1 '@walletconnect/window-metadata': 1.0.1 bs58: 6.0.0 @@ -12263,18 +12275,18 @@ snapshots: - utf-8-validate - zod - '@walletconnect/utils@2.21.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)': + '@walletconnect/utils@2.21.1(@capacitor/preferences@8.0.1(@capacitor/core@8.2.0))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)': dependencies: '@noble/ciphers': 1.2.1 '@noble/curves': 1.8.1 '@noble/hashes': 1.7.1 '@walletconnect/jsonrpc-utils': 1.0.8 - '@walletconnect/keyvaluestorage': 1.1.1 + '@walletconnect/keyvaluestorage': 1.1.1(@capacitor/preferences@8.0.1(@capacitor/core@8.2.0)) '@walletconnect/relay-api': 1.0.11 '@walletconnect/relay-auth': 1.1.0 '@walletconnect/safe-json': 1.0.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.21.1 + '@walletconnect/types': 2.21.1(@capacitor/preferences@8.0.1(@capacitor/core@8.2.0)) '@walletconnect/window-getters': 1.0.1 '@walletconnect/window-metadata': 1.0.1 bs58: 6.0.0 @@ -17635,7 +17647,7 @@ snapshots: '@unrs/resolver-binding-win32-ia32-msvc': 1.11.1 '@unrs/resolver-binding-win32-x64-msvc': 1.11.1 - unstorage@1.17.4(idb-keyval@6.2.2): + unstorage@1.17.4(@capacitor/preferences@8.0.1(@capacitor/core@8.2.0))(idb-keyval@6.2.2): dependencies: anymatch: 3.1.3 chokidar: 5.0.0 @@ -17646,6 +17658,7 @@ snapshots: ofetch: 1.5.1 ufo: 1.6.3 optionalDependencies: + '@capacitor/preferences': 8.0.1(@capacitor/core@8.2.0) idb-keyval: 6.2.2 untildify@4.0.0: {} @@ -17800,10 +17813,10 @@ snapshots: dependencies: xml-name-validator: 4.0.0 - wagmi@2.16.3(@tanstack/query-core@5.8.3)(@tanstack/react-query@5.8.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@types/react@18.3.27)(bufferutil@4.1.0)(immer@11.1.3)(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6))(zod@4.3.6): + wagmi@2.16.3(@capacitor/preferences@8.0.1(@capacitor/core@8.2.0))(@tanstack/query-core@5.8.3)(@tanstack/react-query@5.8.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@types/react@18.3.27)(bufferutil@4.1.0)(immer@11.1.3)(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6))(zod@4.3.6): dependencies: '@tanstack/react-query': 5.8.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@wagmi/connectors': 5.9.3(@types/react@18.3.27)(@wagmi/core@2.19.0(@tanstack/query-core@5.8.3)(@types/react@18.3.27)(immer@11.1.3)(react@19.2.4)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.4))(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)))(bufferutil@4.1.0)(immer@11.1.3)(react@19.2.4)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.4))(utf-8-validate@5.0.10)(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6))(zod@4.3.6) + '@wagmi/connectors': 5.9.3(@capacitor/preferences@8.0.1(@capacitor/core@8.2.0))(@types/react@18.3.27)(@wagmi/core@2.19.0(@tanstack/query-core@5.8.3)(@types/react@18.3.27)(immer@11.1.3)(react@19.2.4)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.4))(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)))(bufferutil@4.1.0)(immer@11.1.3)(react@19.2.4)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.4))(utf-8-validate@5.0.10)(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6))(zod@4.3.6) '@wagmi/core': 2.19.0(@tanstack/query-core@5.8.3)(@types/react@18.3.27)(immer@11.1.3)(react@19.2.4)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.4))(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)) react: 19.2.4 use-sync-external-store: 1.4.0(react@19.2.4) diff --git a/src/app/actions/__tests__/api-headers-extended.test.ts b/src/app/actions/__tests__/api-headers-extended.test.ts index d61da6056a..fa392bf1d0 100644 --- a/src/app/actions/__tests__/api-headers-extended.test.ts +++ b/src/app/actions/__tests__/api-headers-extended.test.ts @@ -14,6 +14,7 @@ jest.mock('@/utils/sentry.utils', () => ({ })) jest.mock('@/utils/auth-token', () => ({ + authReady: jest.fn(() => Promise.resolve()), getAuthHeaders: jest.fn((extra?: Record) => ({ Authorization: 'Bearer test-token', ...extra, diff --git a/src/app/actions/__tests__/api-headers.test.ts b/src/app/actions/__tests__/api-headers.test.ts index a764dd76b5..7bc9ebc8ab 100644 --- a/src/app/actions/__tests__/api-headers.test.ts +++ b/src/app/actions/__tests__/api-headers.test.ts @@ -14,6 +14,7 @@ jest.mock('@/utils/sentry.utils', () => ({ })) jest.mock('@/utils/auth-token', () => ({ + authReady: jest.fn(() => Promise.resolve()), getAuthHeaders: jest.fn((extra?: Record) => ({ Authorization: 'Bearer test-token', ...extra, diff --git a/src/components/LandingPage/LandingPageCapacitorGate.tsx b/src/components/LandingPage/LandingPageCapacitorGate.tsx index 3956218458..ec74a064ed 100644 --- a/src/components/LandingPage/LandingPageCapacitorGate.tsx +++ b/src/components/LandingPage/LandingPageCapacitorGate.tsx @@ -9,7 +9,7 @@ import { useEffect, type ReactNode } from 'react' import { useRouter } from 'next/navigation' -import { hasNativeSessionCookie } from '@/utils/auth-token' +import { hasNativeSession } from '@/utils/auth-token' import { isCapacitor } from '@/utils/capacitor' import { isDemoMode } from '@/utils/demo' @@ -23,9 +23,10 @@ export function LandingPageCapacitorGate({ children }: { children: ReactNode }) router.replace('/home') return } - // The session lives in the native cookie jar (async read). /home's - // layout still validates via /users/me and bounces dead sessions. - hasNativeSessionCookie().then((hasSession) => { + // The session lives in native Preferences (async read, with a + // legacy cookie-jar fallback). /home's layout still validates via + // /users/me and bounces dead sessions. + hasNativeSession().then((hasSession) => { router.replace(hasSession ? '/home' : '/setup') }) } diff --git a/src/config/peanut.config.tsx b/src/config/peanut.config.tsx index e674491735..6a680dabff 100644 --- a/src/config/peanut.config.tsx +++ b/src/config/peanut.config.tsx @@ -8,6 +8,9 @@ import { Provider as ReduxProvider } from 'react-redux' import store from '@/redux/store' import 'react-tooltip/dist/react-tooltip.css' import { isCapacitor, getNativeRpId } from '@/utils/capacitor' +import { authReady } from '@/utils/auth-token' +import { installNativeAuthCapture } from '@/utils/native-auth-capture' +import { scheduleDirectFetchCanary } from '@/utils/native-canary' import { CRISP_WEBSITE_ID } from '@/constants/crisp' // Note: Sentry configs are auto-loaded by @sentry/nextjs via next.config.js // DO NOT import them here - it bundles server/edge configs into client code @@ -20,10 +23,14 @@ export function PeanutProvider({ children }: { children: React.ReactNode }) { // in capacitor, install the passkey shim so navigator.credentials.create/get // routes through native APIs instead of the browser (which doesn't work in webview). - // Session auth on native is cookie-based: the verify endpoints' Set-Cookie lands - // in CapacitorHttp's native jar and authenticates every api request — no JS - // token capture needed (see src/utils/auth-token.ts). + // Session auth on native is header-based: the verify endpoints return the token + // in the response body, the fetch wrapper below captures it into native + // Preferences, and every api request sends it as Authorization + // (see src/utils/auth-token.ts). if (isCapacitor()) { + void authReady() // start Preferences hydration before any API call needs it + installNativeAuthCapture() + scheduleDirectFetchCanary() import('@capgo/capacitor-passkey').then(({ CapacitorPasskey }) => { const nativeRpId = getNativeRpId() diff --git a/src/hooks/useLimits.ts b/src/hooks/useLimits.ts index 7169a52aad..84cdbd69f4 100644 --- a/src/hooks/useLimits.ts +++ b/src/hooks/useLimits.ts @@ -1,6 +1,6 @@ 'use client' -import { getAuthToken } from '@/utils/auth-token' +import { getAuthToken, authReady } from '@/utils/auth-token' import { isCapacitor } from '@/utils/capacitor' import { useQuery } from '@tanstack/react-query' import type { UserLimitsResponse } from '@/interfaces' @@ -22,8 +22,9 @@ export function useLimits(options: UseLimitsOptions = {}) { const fetchLimits = async (): Promise => { // Web-only short-circuit: skip the request when logged out. On native - // the session is in the cookie jar (not readable synchronously), and in - // demo the request routes to the demo interceptor — both just fetch. + // the token may still be hydrating at cold start, and in demo the + // request routes to the demo interceptor — both just fetch. + await authReady() const token = getAuthToken() if (!isCapacitor() && !token && !isDemoMode()) { return { manteca: null, bridge: null } diff --git a/src/services/charges.ts b/src/services/charges.ts index dff1a540ab..c9c7ff7ddc 100644 --- a/src/services/charges.ts +++ b/src/services/charges.ts @@ -7,7 +7,7 @@ import { type CreateChargeRequest, } from './services.types' import { PEANUT_API_URL } from '@/constants/general.consts' -import { getAuthToken } from '@/utils/auth-token' +import { getAuthToken, authReady } from '@/utils/auth-token' import { apiFetch, serverFetch } from '@/utils/api-fetch' import { isDemoMode } from '@/utils/demo' @@ -35,6 +35,7 @@ export const chargesApi = { } }) + await authReady() const headers: Record = {} const token = getAuthToken() if (token) headers['Authorization'] = `Bearer ${token}` diff --git a/src/services/rhino-bridge.ts b/src/services/rhino-bridge.ts index cf02cef760..7b3292beb5 100644 --- a/src/services/rhino-bridge.ts +++ b/src/services/rhino-bridge.ts @@ -13,7 +13,7 @@ import { PEANUT_API_URL } from '@/constants/general.consts' import { fetchWithSentry } from '@/utils/sentry.utils' -import { getAuthHeaders } from '@/utils/auth-token' +import { getAuthHeaders, authReady } from '@/utils/auth-token' export interface BridgeQuoteParams { amount: string @@ -70,6 +70,7 @@ export interface BridgeChainConfig { } async function postJson(path: string, body: TReq, errorLabel: string): Promise { + await authReady() const response = await fetchWithSentry(`${PEANUT_API_URL}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...getAuthHeaders() }, @@ -83,6 +84,7 @@ async function postJson(path: string, body: TReq, errorLabel: string } async function getJson(path: string, errorLabel: string): Promise { + await authReady() const response = await fetchWithSentry(`${PEANUT_API_URL}${path}`, { method: 'GET', headers: getAuthHeaders(), diff --git a/src/services/rhino-sda.ts b/src/services/rhino-sda.ts index ec16f486b9..d540a3e27e 100644 --- a/src/services/rhino-sda.ts +++ b/src/services/rhino-sda.ts @@ -12,7 +12,7 @@ import { PEANUT_API_URL } from '@/constants/general.consts' import { fetchWithSentry } from '@/utils/sentry.utils' -import { getAuthHeaders } from '@/utils/auth-token' +import { getAuthHeaders, authReady } from '@/utils/auth-token' import type { Address } from 'viem' export type RhinoTransferContext = 'withdraw' | 'pay-request' | 'claim-xchain' @@ -73,6 +73,7 @@ export interface SdaPreviewResult { } async function postRhino(path: string, body: TReq, errorLabel: string): Promise { + await authReady() const response = await fetchWithSentry(`${PEANUT_API_URL}${path}`, { method: 'POST', headers: { diff --git a/src/services/sendLinks.ts b/src/services/sendLinks.ts index 8a92c62ff7..0fca717df6 100644 --- a/src/services/sendLinks.ts +++ b/src/services/sendLinks.ts @@ -2,7 +2,7 @@ import { jsonParse, jsonStringify, getFromLocalStorage, saveToLocalStorage } fro import { generateKeysFromString, getParamsFromLink } from '@/utils/peanut-link.utils' import type { SendLink } from '@/services/services.types' import { serverFetch } from '@/utils/api-fetch' -import { getAuthHeaders } from '@/utils/auth-token' +import { getAuthHeaders, authReady } from '@/utils/auth-token' import { fetchWithSentry } from '@/utils/sentry.utils' import { PEANUT_API_URL } from '@/constants/general.consts' import { isDemoMode } from '@/utils/demo' @@ -123,6 +123,7 @@ export const sendLinksApi = { headers['Content-Type'] = 'application/json' } + await authReady() Object.assign(headers, getAuthHeaders()) const response = await fetchWithSentry(`${PEANUT_API_URL}/send-links`, { method: 'POST', diff --git a/src/utils/__tests__/api-fetch.test.ts b/src/utils/__tests__/api-fetch.test.ts index 24ea746f69..a28164be7c 100644 --- a/src/utils/__tests__/api-fetch.test.ts +++ b/src/utils/__tests__/api-fetch.test.ts @@ -12,6 +12,7 @@ jest.mock('@/utils/sentry.utils', () => ({ })) jest.mock('@/utils/auth-token', () => ({ + authReady: jest.fn(() => Promise.resolve()), getAuthHeaders: jest.fn((extra?: Record) => ({ Authorization: 'Bearer test-token', ...extra, diff --git a/src/utils/__tests__/auth-token.test.ts b/src/utils/__tests__/auth-token.test.ts index 575bcac4bd..eb78a4f88d 100644 --- a/src/utils/__tests__/auth-token.test.ts +++ b/src/utils/__tests__/auth-token.test.ts @@ -3,6 +3,7 @@ import Cookies from 'js-cookie' import { isCapacitor } from '@/utils/capacitor' import { CapacitorCookies } from '@capacitor/core' +import { Preferences } from '@capacitor/preferences' jest.mock('@/utils/capacitor', () => ({ isCapacitor: jest.fn(), @@ -21,6 +22,14 @@ jest.mock('@capacitor/core', () => ({ }, })) +jest.mock('@capacitor/preferences', () => ({ + Preferences: { + get: jest.fn(), + set: jest.fn(), + remove: jest.fn(), + }, +})) + const mockIsCapacitor = isCapacitor as jest.MockedFunction // Cookies.get has overloaded signatures (one-arg returns string|undefined, no-arg // returns { [key: string]: string }). jest.Mocked picks the no-arg @@ -34,8 +43,23 @@ const mockCapCookies = CapacitorCookies as unknown as { getCookies: jest.Mock clearCookies: jest.Mock } +const mockPreferences = Preferences as unknown as { + get: jest.Mock + set: jest.Mock + remove: jest.Mock +} -import { getAuthToken, setAuthToken, clearAuthToken, getAuthHeaders, hasNativeSessionCookie } from '../auth-token' +// the module caches the native token and hydration promise, so each test gets +// a fresh copy via resetModules + require +type AuthTokenModule = typeof import('../auth-token') +let auth: AuthTokenModule + +function loadModule(): void { + jest.isolateModules(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports -- isolateModules is sync-only + auth = require('../auth-token') + }) +} describe('auth-token', () => { beforeEach(() => { @@ -43,8 +67,12 @@ describe('auth-token', () => { localStorage.clear() mockCapCookies.clearCookies.mockResolvedValue(undefined) mockCapCookies.getCookies.mockResolvedValue({}) + mockPreferences.get.mockResolvedValue({ value: null }) + mockPreferences.set.mockResolvedValue(undefined) + mockPreferences.remove.mockResolvedValue(undefined) // reset document.cookie document.cookie = 'jwt-token=; path=/; expires=Thu, 01 Jan 1970 00:00:01 GMT;' + loadModule() }) afterEach(() => { @@ -57,10 +85,32 @@ describe('auth-token', () => { mockIsCapacitor.mockReturnValue(true) }) - it('is always null — the native cookie jar is the credential, not JS', () => { - localStorage.setItem('jwt-token', 'stale-local-token') - mockCookies.get.mockReturnValue('stale-cookie-token') - expect(getAuthToken()).toBeNull() + it('returns the token hydrated from Preferences after authReady', async () => { + mockPreferences.get.mockResolvedValue({ value: 'stored-native-token' }) + await auth.authReady() + expect(auth.getAuthToken()).toBe('stored-native-token') + expect(mockPreferences.get).toHaveBeenCalledWith({ key: 'jwt-token' }) + }) + + it('returns null when Preferences holds no token', async () => { + await auth.authReady() + expect(auth.getAuthToken()).toBeNull() + }) + + it('returns null when the Preferences plugin is unavailable (older binary)', async () => { + mockPreferences.get.mockRejectedValue(new Error('not implemented')) + await auth.authReady() + expect(auth.getAuthToken()).toBeNull() + }) + + it('does not let a stale stored value overwrite a token set during hydration', async () => { + let resolveGet: (v: { value: string }) => void + mockPreferences.get.mockReturnValue(new Promise((resolve) => (resolveGet = resolve))) + const ready = auth.authReady() + auth.setAuthToken('fresh-login-token') + resolveGet!({ value: 'stale-stored-token' }) + await ready + expect(auth.getAuthToken()).toBe('fresh-login-token') }) }) @@ -71,13 +121,13 @@ describe('auth-token', () => { it('should read from cookies', () => { mockCookies.get.mockReturnValue('web-token-456') - expect(getAuthToken()).toBe('web-token-456') + expect(auth.getAuthToken()).toBe('web-token-456') expect(mockCookies.get).toHaveBeenCalledWith('jwt-token') }) it('should return null when cookie is not set', () => { mockCookies.get.mockReturnValue(undefined) - expect(getAuthToken()).toBeNull() + expect(auth.getAuthToken()).toBeNull() }) }) }) @@ -88,11 +138,27 @@ describe('auth-token', () => { mockIsCapacitor.mockReturnValue(true) }) - it('is a no-op — the server Set-Cookie already updated the native jar', () => { - setAuthToken('new-cap-token') + it('caches in memory and persists to Preferences', async () => { + auth.setAuthToken('new-cap-token') + expect(auth.getAuthToken()).toBe('new-cap-token') + await Promise.resolve() // flush the fire-and-forget dynamic import chain + await Promise.resolve() + expect(mockPreferences.set).toHaveBeenCalledWith({ key: 'jwt-token', value: 'new-cap-token' }) + }) + + it('does not write cookies or localStorage', () => { + auth.setAuthToken('new-cap-token') expect(localStorage.getItem('jwt-token')).toBeNull() expect(mockCookies.set).not.toHaveBeenCalled() }) + + it('keeps the in-memory token even when Preferences persistence fails', async () => { + mockPreferences.set.mockRejectedValue(new Error('not implemented')) + auth.setAuthToken('new-cap-token') + await Promise.resolve() + await Promise.resolve() + expect(auth.getAuthToken()).toBe('new-cap-token') + }) }) describe('web mode', () => { @@ -101,7 +167,7 @@ describe('auth-token', () => { }) it('should save to cookie via js-cookie', () => { - setAuthToken('new-web-token') + auth.setAuthToken('new-web-token') expect(mockCookies.set).toHaveBeenCalledWith('jwt-token', 'new-web-token', { expires: 30, path: '/', @@ -109,35 +175,44 @@ describe('auth-token', () => { }) it('should not save to localStorage', () => { - setAuthToken('new-web-token') + auth.setAuthToken('new-web-token') expect(localStorage.getItem('jwt-token')).toBeNull() }) }) }) - describe('hasNativeSessionCookie', () => { - it('returns false on web without touching the native jar', async () => { + describe('hasNativeSession', () => { + it('returns false on web without touching native storage', async () => { mockIsCapacitor.mockReturnValue(false) - await expect(hasNativeSessionCookie()).resolves.toBe(false) + await expect(auth.hasNativeSession()).resolves.toBe(false) + expect(mockPreferences.get).not.toHaveBeenCalled() + expect(mockCapCookies.getCookies).not.toHaveBeenCalled() + }) + + it('returns true when Preferences holds a token', async () => { + mockIsCapacitor.mockReturnValue(true) + mockPreferences.get.mockResolvedValue({ value: 'stored-token' }) + await expect(auth.hasNativeSession()).resolves.toBe(true) expect(mockCapCookies.getCookies).not.toHaveBeenCalled() }) - it('returns true when the jar holds a jwt-token cookie', async () => { + it('falls back to the legacy cookie jar when Preferences is empty', async () => { mockIsCapacitor.mockReturnValue(true) mockCapCookies.getCookies.mockResolvedValue({ 'jwt-token': 'jar-token' }) - await expect(hasNativeSessionCookie()).resolves.toBe(true) + await expect(auth.hasNativeSession()).resolves.toBe(true) }) - it('returns false when the jar has no jwt-token cookie', async () => { + it('returns false when neither store has a session', async () => { mockIsCapacitor.mockReturnValue(true) mockCapCookies.getCookies.mockResolvedValue({ other: 'cookie' }) - await expect(hasNativeSessionCookie()).resolves.toBe(false) + await expect(auth.hasNativeSession()).resolves.toBe(false) }) - it('returns false when the native read fails', async () => { + it('returns false when both native reads fail', async () => { mockIsCapacitor.mockReturnValue(true) + mockPreferences.get.mockRejectedValue(new Error('bridge down')) mockCapCookies.getCookies.mockRejectedValue(new Error('bridge down')) - await expect(hasNativeSessionCookie()).resolves.toBe(false) + await expect(auth.hasNativeSession()).resolves.toBe(false) }) }) @@ -147,27 +222,39 @@ describe('auth-token', () => { mockIsCapacitor.mockReturnValue(true) }) - it('should clear the native cookie jar (the actual credential)', async () => { - await clearAuthToken() + it('clears the in-memory token immediately', async () => { + auth.setAuthToken('to-clear') + await auth.clearAuthToken() + expect(auth.getAuthToken()).toBeNull() + }) + + it('removes the token from Preferences', async () => { + await auth.clearAuthToken() + expect(mockPreferences.remove).toHaveBeenCalledWith({ key: 'jwt-token' }) + }) + + it('clears the legacy native cookie jar', async () => { + await auth.clearAuthToken() expect(mockCapCookies.clearCookies).toHaveBeenCalledWith({ url: expect.stringContaining('http'), }) }) - it('should remove any localStorage remnant from older builds', async () => { + it('removes any localStorage remnant from older builds', async () => { localStorage.setItem('jwt-token', 'to-clear') - await clearAuthToken() + await auth.clearAuthToken() expect(localStorage.getItem('jwt-token')).toBeNull() }) - it('should also clear cookie defensively', async () => { - await clearAuthToken() + it('also clears the cookie defensively', async () => { + await auth.clearAuthToken() expect(mockCookies.remove).toHaveBeenCalledWith('jwt-token', { path: '/' }) }) - it('should resolve even when the native clear fails', async () => { + it('resolves even when the native clears fail', async () => { + mockPreferences.remove.mockRejectedValue(new Error('bridge down')) mockCapCookies.clearCookies.mockRejectedValue(new Error('bridge down')) - await expect(clearAuthToken()).resolves.toBeUndefined() + await expect(auth.clearAuthToken()).resolves.toBeUndefined() }) }) @@ -177,21 +264,32 @@ describe('auth-token', () => { }) it('should remove cookie via js-cookie', () => { - clearAuthToken() + auth.clearAuthToken() expect(mockCookies.remove).toHaveBeenCalledWith('jwt-token', { path: '/' }) }) - it('should not touch the native jar', async () => { - await clearAuthToken() + it('should not touch native storage', async () => { + await auth.clearAuthToken() + expect(mockPreferences.remove).not.toHaveBeenCalled() expect(mockCapCookies.clearCookies).not.toHaveBeenCalled() }) }) }) describe('getAuthHeaders', () => { - it('returns no Authorization on capacitor — the cookie jar authenticates', () => { + it('returns Authorization on capacitor once a token is set', () => { + mockIsCapacitor.mockReturnValue(true) + auth.setAuthToken('native-token') + const headers = auth.getAuthHeaders({ 'Content-Type': 'application/json' }) + expect(headers).toEqual({ + Authorization: 'Bearer native-token', + 'Content-Type': 'application/json', + }) + }) + + it('returns no Authorization on capacitor before any token exists', () => { mockIsCapacitor.mockReturnValue(true) - const headers = getAuthHeaders({ 'Content-Type': 'application/json' }) + const headers = auth.getAuthHeaders({ 'Content-Type': 'application/json' }) expect(headers).toEqual({ 'Content-Type': 'application/json' }) }) @@ -202,19 +300,19 @@ describe('auth-token', () => { it('should return Authorization header when token exists', () => { mockCookies.get.mockReturnValue('my-token') - const headers = getAuthHeaders() + const headers = auth.getAuthHeaders() expect(headers).toEqual({ Authorization: 'Bearer my-token' }) }) it('should return empty object when no token exists', () => { mockCookies.get.mockReturnValue(undefined) - const headers = getAuthHeaders() + const headers = auth.getAuthHeaders() expect(headers).toEqual({}) }) it('should merge extra headers when provided', () => { mockCookies.get.mockReturnValue('my-token') - const headers = getAuthHeaders({ 'Content-Type': 'application/json', 'X-Custom': 'value' }) + const headers = auth.getAuthHeaders({ 'Content-Type': 'application/json', 'X-Custom': 'value' }) expect(headers).toEqual({ Authorization: 'Bearer my-token', 'Content-Type': 'application/json', @@ -224,7 +322,7 @@ describe('auth-token', () => { it('should return only extra headers when no token', () => { mockCookies.get.mockReturnValue(undefined) - const headers = getAuthHeaders({ 'Content-Type': 'application/json' }) + const headers = auth.getAuthHeaders({ 'Content-Type': 'application/json' }) expect(headers).toEqual({ 'Content-Type': 'application/json' }) }) }) diff --git a/src/utils/api-fetch.ts b/src/utils/api-fetch.ts index 0a2c8d3ceb..73d925b219 100644 --- a/src/utils/api-fetch.ts +++ b/src/utils/api-fetch.ts @@ -2,20 +2,24 @@ // was retired once peanut-api-ts dropped its api-key requirement (eb616b8c) // and CORS already allowed *.peanut.me + capacitor:// + vercel previews. -import { getAuthHeaders } from './auth-token' +import { getAuthHeaders, authReady } from './auth-token' import { fetchWithSentry } from './sentry.utils' import { PEANUT_API_URL } from '@/constants/general.consts' import { isDemoMode } from './demo' type FetchOptions = RequestInit & { timeoutMs?: number } -function callApi(path: string, options?: FetchOptions): Promise { +async function callApi(path: string, options?: FetchOptions): Promise { // Native-only demo mode: route to synthetic data before any header/network // work. isDemoMode() is false on web, so this is unreachable for real users. // Lazy import keeps the demo module (and its viem-using fixtures) out of the // web bundle and out of every api-fetch importer's module graph. if (isDemoMode()) return import('./demo-api').then((m) => m.demoRespond(path, options)) + // Native: token hydrates async from Preferences; gate here so a cold-start + // request can't go out unauthenticated. Instant on web. + await authReady() + const { timeoutMs, ...fetchOptions } = options ?? {} const callerHeaders = (fetchOptions.headers as Record) ?? {} diff --git a/src/utils/auth-token.ts b/src/utils/auth-token.ts index e8369c4892..d023e7bbc3 100644 --- a/src/utils/auth-token.ts +++ b/src/utils/auth-token.ts @@ -1,13 +1,14 @@ // client-side jwt token management. // web: the token lives in a readable cookie; we mirror it into the // Authorization header (existing behavior, 40+ call sites). -// capacitor: JS never holds the token. The server's Set-Cookie lands in -// CapacitorHttp's NATIVE cookie jar, which attaches it to every -// api.peanut.me request — durable across the passkey (Face ID) prompt -// and webview storage eviction, which made localStorage-based header -// auth fail (PEANUT-UI-QTQ). getAuthToken is therefore null on native -// by design; auth state checks belong on the user query or the jar -// (see hasNativeSessionCookie). +// capacitor: the token lives in native Preferences (SharedPreferences / +// UserDefaults), hydrated into an in-memory cache at startup and sent +// as an Authorization header — same as web. Native storage survives +// webview data eviction, which is what broke localStorage-based header +// auth (PEANUT-UI-QTQ). The CapacitorHttp cookie jar is no longer the +// credential store (its Android GET proxy stalls, PEANUT-UI-R44), but +// older cookie-auth binaries keep working: the server still accepts the +// jwt-token cookie, and hasNativeSession falls back to the jar. import Cookies from 'js-cookie' import { isCapacitor } from './capacitor' @@ -16,33 +17,70 @@ import { PEANUT_API_URL } from '@/constants/general.consts' const JWT_COOKIE_KEY = 'jwt-token' const JWT_STORAGE_KEY = 'jwt-token' +let nativeToken: string | null = null +let hydration: Promise | null = null + +async function hydrateFromPreferences(): Promise { + try { + const { Preferences } = await import('@capacitor/preferences') + const { value } = await Preferences.get({ key: JWT_STORAGE_KEY }) + // a login that raced hydration is fresher than the stored value + if (value && nativeToken === null) nativeToken = value + } catch { + // plugin missing (older binary running OTA'd JS) — those builds still + // authenticate via the CapacitorHttp cookie jar, so this is benign. + } +} + +/** + * resolves once the native token cache is hydrated from Preferences. + * Instant on web. Await this before building auth headers on native so a + * cold start can't race the async Preferences read. + */ +export function authReady(): Promise { + if (!isCapacitor()) return Promise.resolve() + if (!hydration) hydration = hydrateFromPreferences() + return hydration +} + /** * reads the jwt token for Authorization-header auth. - * web: from cookie via js-cookie. capacitor: always null — the native cookie - * jar authenticates requests; JS is not a token custodian. + * web: from cookie via js-cookie. capacitor: from the in-memory cache + * (callers that can run at cold start must await authReady() first). */ export function getAuthToken(): string | null { - if (isCapacitor()) return null + if (isCapacitor()) return nativeToken return Cookies.get(JWT_COOKIE_KEY) ?? null } /** - * stores the jwt token (web only). On capacitor this is a no-op: the server's - * Set-Cookie already updated the native jar, which is the source of truth. + * stores the jwt token. web: readable cookie. capacitor: in-memory cache + + * native Preferences (fire-and-forget; the cache is authoritative for this + * session). Fed by the login-verify capture and the /users/me sliding + * refresh. */ export function setAuthToken(token: string): void { - if (isCapacitor()) return + if (isCapacitor()) { + nativeToken = token + import('@capacitor/preferences') + .then(({ Preferences }) => Preferences.set({ key: JWT_STORAGE_KEY, value: token })) + .catch(() => {}) + return + } Cookies.set(JWT_COOKIE_KEY, token, { expires: 30, path: '/' }) } /** - * capacitor-only: does the native cookie jar hold a session cookie for the - * API? Async because the jar lives on the native side. Used for cheap - * routing hints (e.g. cold-start home-vs-setup) — the /users/me query - * remains the authority on whether the session is actually valid. + * capacitor-only: is there a stored session? Awaits hydration, then falls + * back to the legacy CapacitorHttp cookie jar so sessions created by older + * cookie-auth binaries still route to home. Used for cheap routing hints + * (e.g. cold-start home-vs-setup) — the /users/me query remains the + * authority on whether the session is actually valid. */ -export async function hasNativeSessionCookie(): Promise { +export async function hasNativeSession(): Promise { if (!isCapacitor()) return false + await authReady() + if (nativeToken) return true try { const { CapacitorCookies } = await import('@capacitor/core') const cookies = await CapacitorCookies.getCookies({ url: PEANUT_API_URL }) @@ -54,19 +92,24 @@ export async function hasNativeSessionCookie(): Promise { /** * clears the session. - * capacitor: clears the native cookie jar (the actual credential) plus any - * localStorage remnant from older builds. + * capacitor: clears the in-memory cache, native Preferences, the legacy + * cookie jar, and any localStorage remnant from older builds. * web: removes the cookie. - * returns a promise for the native jar clear so logout can await it before + * returns a promise for the native clears so logout can await it before * reloading; other callers may safely ignore it. */ export function clearAuthToken(): Promise { let nativeClear: Promise = Promise.resolve() if (isCapacitor()) { + nativeToken = null localStorage.removeItem(JWT_STORAGE_KEY) - nativeClear = import('@capacitor/core') + const prefsClear = import('@capacitor/preferences') + .then(({ Preferences }) => Preferences.remove({ key: JWT_STORAGE_KEY })) + .catch(() => {}) + const jarClear = import('@capacitor/core') .then(({ CapacitorCookies }) => CapacitorCookies.clearCookies({ url: PEANUT_API_URL })) .catch(() => {}) + nativeClear = Promise.all([prefsClear, jarClear]).then(() => undefined) } // always clear cookie too in case it was set by backend Set-Cookie header Cookies.remove(JWT_COOKIE_KEY, { path: '/' }) @@ -75,9 +118,8 @@ export function clearAuthToken(): Promise { } /** - * builds headers for authenticated api calls. - * web: includes Authorization bearer token. capacitor: no Authorization — - * the native cookie jar authenticates the request. + * builds headers for authenticated api calls: Authorization bearer token on + * both web and capacitor when a token is available. */ export function getAuthHeaders(extraHeaders?: Record): Record { const headers: Record = {} diff --git a/src/utils/native-auth-capture.ts b/src/utils/native-auth-capture.ts new file mode 100644 index 0000000000..19fbafc7e6 --- /dev/null +++ b/src/utils/native-auth-capture.ts @@ -0,0 +1,35 @@ +// Captures the session JWT from passkey verify responses on native. +// The ZeroDev SDK performs the /passkeys/{login,register}/verify fetch +// internally and discards the response body, so the token the API ships for +// cookie-less clients would otherwise be lost. Wrapping window.fetch here +// works with CapacitorHttp both on (bridge-patched fetch) and off (plain +// WebView fetch), so the same JS runs on old and new binaries. + +import { isCapacitor } from './capacitor' +import { setAuthToken } from './auth-token' + +const VERIFY_URL_PATTERN = /\/passkeys\/(login|register)\/verify/ + +let installed = false + +export function installNativeAuthCapture(): void { + if (!isCapacitor() || installed || typeof window === 'undefined') return + installed = true + + const originalFetch = window.fetch.bind(window) + window.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const response = await originalFetch(input, init) + try { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url + if (response.ok && VERIFY_URL_PATTERN.test(url)) { + const body = await response.clone().json() + if (body && typeof body.token === 'string' && body.token) { + setAuthToken(body.token) + } + } + } catch { + // capture is best-effort; never break the original request + } + return response + } +} diff --git a/src/utils/native-canary.ts b/src/utils/native-canary.ts new file mode 100644 index 0000000000..269045cb6e --- /dev/null +++ b/src/utils/native-canary.ts @@ -0,0 +1,53 @@ +// One-shot startup probe for the direct (un-proxied) WebView fetch path the +// app relies on once CapacitorHttp is disabled (PEANUT-UI-R44). Reports the +// outcome to Sentry so we can watch CORS + credential behavior across real +// tester devices and networks before and after the transport switch. +// Query: message:"direct-fetch canary" — tags: outcome, transport. + +import * as Sentry from '@sentry/nextjs' +import { PEANUT_API_URL } from '@/constants/general.consts' +import { isCapacitor } from './capacitor' + +const CANARY_TIMEOUT_MS = 10_000 + +let scheduled = false + +export function scheduleDirectFetchCanary(delayMs: number = 4_000): void { + if (!isCapacitor() || scheduled || typeof window === 'undefined') return + scheduled = true + setTimeout(() => { + void runCanary() + }, delayMs) +} + +async function runCanary(): Promise { + // CapacitorWebFetch is the original WebView fetch the bridge saves before + // patching; present only while CapacitorHttp is enabled. Its absence means + // window.fetch is already direct. + const capWebFetch = (window as unknown as { CapacitorWebFetch?: typeof fetch }).CapacitorWebFetch + const transport = capWebFetch ? 'cap-web-fetch' : 'window-fetch' + const doFetch = (capWebFetch ?? window.fetch).bind(window) + + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), CANARY_TIMEOUT_MS) + const startedAt = Date.now() + let outcome: string + try { + const response = await doFetch(`${PEANUT_API_URL}/users/me`, { + method: 'GET', + credentials: 'include', + signal: controller.signal, + }) + outcome = `http-${response.status}` + } catch (error) { + outcome = error instanceof Error && error.name === 'AbortError' ? 'timeout' : 'network-error' + } finally { + clearTimeout(timeoutId) + } + + Sentry.captureMessage('direct-fetch canary', { + level: 'info', + tags: { canary: 'direct-fetch', outcome, transport }, + extra: { durationMs: Date.now() - startedAt }, + }) +} diff --git a/src/utils/sentry.utils.ts b/src/utils/sentry.utils.ts index 198428986d..9ce8a9e229 100644 --- a/src/utils/sentry.utils.ts +++ b/src/utils/sentry.utils.ts @@ -354,16 +354,37 @@ export const fetchWithSentry = async ( ) } - const controller = new AbortController() - const timeoutId = setTimeout(() => controller.abort(), timeoutMs) + // Idempotent requests get one silent retry on timeout: stalled-transport + // failures (Android webview, flaky mobile networks) usually clear on a + // fresh attempt (PEANUT-UI-R44). + const method = (options.method || 'GET').toUpperCase() + const maxAttempts = method === 'GET' || method === 'HEAD' ? 2 : 1 + + const attemptFetch = async (): Promise => { + for (let attempt = 1; ; attempt++) { + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), timeoutMs) + try { + return await fetch(url, { + ...options, + signal: controller.signal, + }) + } catch (error) { + if (attempt < maxAttempts && error instanceof Error && error.name === 'AbortError') { + console.warn(`Request to ${String(url).replace(/[\r\n]/g, '')} timed out — retrying`) + await new Promise((resolve) => setTimeout(resolve, 300)) + continue + } + throw error + } finally { + clearTimeout(timeoutId) + } + } + } try { - const response = await fetch(url, { - ...options, - signal: controller.signal, - }) + const response = await attemptFetch() - clearTimeout(timeoutId) // A response came back — the backend is reachable, clear any failure streak. reportNetworkOk() @@ -405,7 +426,6 @@ export const fetchWithSentry = async ( return response } catch (error: unknown) { - clearTimeout(timeoutId) // fetch rejected (timeout / DNS / connection refused) — the request never // reached the backend, so flag a connectivity failure. reportNetworkError() From 200fcf9e4be6eb296d0d6d5c596bd0f5494426d4 Mon Sep 17 00:00:00 2001 From: oxwel77 Date: Thu, 16 Jul 2026 10:32:43 +0100 Subject: [PATCH 02/44] chore(deps): add next-intl and @capacitor/device @capacitor/device requires the next native binary release; JS falls back to navigator.language on older binaries. --- ios/App/CapApp-SPM/Package.swift | 2 + package.json | 2 + pnpm-lock.yaml | 425 ++++++++++++++++++++++++++++++- 3 files changed, 427 insertions(+), 2 deletions(-) diff --git a/ios/App/CapApp-SPM/Package.swift b/ios/App/CapApp-SPM/Package.swift index 6e0923aef3..4a054a9e19 100644 --- a/ios/App/CapApp-SPM/Package.swift +++ b/ios/App/CapApp-SPM/Package.swift @@ -16,6 +16,7 @@ let package = Package( .package(name: "CapacitorBrowser", path: "../../../node_modules/.pnpm/@capacitor+browser@8.0.3_@capacitor+core@8.2.0/node_modules/@capacitor/browser"), .package(name: "CapacitorCamera", path: "../../../node_modules/.pnpm/@capacitor+camera@8.2.0_@capacitor+core@8.2.0/node_modules/@capacitor/camera"), .package(name: "CapacitorClipboard", path: "../../../node_modules/.pnpm/@capacitor+clipboard@8.0.1_@capacitor+core@8.2.0/node_modules/@capacitor/clipboard"), + .package(name: "CapacitorDevice", path: "../../../node_modules/.pnpm/@capacitor+device@8.0.2_@capacitor+core@8.2.0/node_modules/@capacitor/device"), .package(name: "CapacitorHaptics", path: "../../../node_modules/.pnpm/@capacitor+haptics@8.0.2_@capacitor+core@8.2.0/node_modules/@capacitor/haptics"), .package(name: "CapacitorKeyboard", path: "../../../node_modules/.pnpm/@capacitor+keyboard@8.0.3_@capacitor+core@8.2.0/node_modules/@capacitor/keyboard"), .package(name: "CapacitorPreferences", path: "../../../node_modules/.pnpm/@capacitor+preferences@8.0.1_@capacitor+core@8.2.0/node_modules/@capacitor/preferences"), @@ -37,6 +38,7 @@ let package = Package( .product(name: "CapacitorBrowser", package: "CapacitorBrowser"), .product(name: "CapacitorCamera", package: "CapacitorCamera"), .product(name: "CapacitorClipboard", package: "CapacitorClipboard"), + .product(name: "CapacitorDevice", package: "CapacitorDevice"), .product(name: "CapacitorHaptics", package: "CapacitorHaptics"), .product(name: "CapacitorKeyboard", package: "CapacitorKeyboard"), .product(name: "CapacitorPreferences", package: "CapacitorPreferences"), diff --git a/package.json b/package.json index b28f5f71ed..f7dec11ce4 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "@capacitor/cli": "8.2.0", "@capacitor/clipboard": "^8.0.1", "@capacitor/core": "8.2.0", + "@capacitor/device": "^8.0.2", "@capacitor/haptics": "^8.0.2", "@capacitor/ios": "8.2.0", "@capacitor/keyboard": "^8.0.3", @@ -99,6 +100,7 @@ "js-cookie": "^3.0.5", "lucide-react": "^1.8.0", "next": "16.2.3", + "next-intl": "^4.13.1", "next-mdx-remote": "^6.0.0", "nuqs": "^2.8.6", "p2-es": "1.2.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 03fc3a3a9c..be7eac86b8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -48,6 +48,9 @@ importers: '@capacitor/core': specifier: 8.2.0 version: 8.2.0 + '@capacitor/device': + specifier: ^8.0.2 + version: 8.0.2(@capacitor/core@8.2.0) '@capacitor/haptics': specifier: ^8.0.2 version: 8.0.2(@capacitor/core@8.2.0) @@ -189,6 +192,9 @@ importers: next: specifier: 16.2.3 version: 16.2.3(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + next-intl: + specifier: ^4.13.1 + version: 4.13.1(@swc/helpers@0.5.18)(next@16.2.3(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(typescript@5.9.3) next-mdx-remote: specifier: ^6.0.0 version: 6.0.0(@types/react@18.3.27)(react@19.2.4) @@ -612,6 +618,11 @@ packages: '@capacitor/core@8.2.0': resolution: {integrity: sha512-oKaoNeNtH2iIZMDFVrb1atoyRECDGHcfLMunJ5KWN8DtvpVBeeA4c41e20NTuhMxw1cSYbpq2PV2hb+/9CJxlQ==} + '@capacitor/device@8.0.2': + resolution: {integrity: sha512-fIqSXnG0s6bz5A/0xFgSXDkbU+Xl65ti80LhucNvLI4kGhJzcNn6SwWVwpXN9SJTOFWXblXknHNppheP8X1frQ==} + peerDependencies: + '@capacitor/core': '>=8.0.0' + '@capacitor/haptics@8.0.2': resolution: {integrity: sha512-c2hZzRR5Fk1tbTvhG1jhh2XBAf3EhnIerMIb2sl7Mt41Gxx1fhBJFDa0/BI1IbY4loVepyyuqNC9820/GZuoWQ==} peerDependencies: @@ -1027,6 +1038,18 @@ packages: '@floating-ui/utils@0.2.10': resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + '@formatjs/fast-memoize@3.1.6': + resolution: {integrity: sha512-H5aexk1Le7T9TPmscacZ+1pR6CTa2n1wq+HDVGXhH8TzUlQQpeXzZs91dRtmFHrbeNbjPFPfQujUqm7MHgVoXQ==} + + '@formatjs/icu-messageformat-parser@3.5.12': + resolution: {integrity: sha512-YyzzxVgYJ8DELmmkhn0Yr0rUj0dTJFf9Jp628K3S0ysInBWxLVDOS8i3RP91cCp4DMK4WYb4cVMhWA9i4knSJg==} + + '@formatjs/icu-skeleton-parser@2.1.10': + resolution: {integrity: sha512-XuSva+8ZGawk8VnD5VD6UeH8KarQ/Z022zgjHDoHmlNiAewstXuuzXc0Hk5pGFSdG+nNw5bfJKXqj1ZXHn9yUA==} + + '@formatjs/intl-localematcher@0.8.10': + resolution: {integrity: sha512-P/IC3qws3jH+1fEs+o0RIFgXKRaQlFehjS5W0FPAqdo6hgzawLl+eD0q0JjheQ3XtoOe5n8WSYfX06KQZI/QJA==} + '@gemini-wallet/core@0.1.1': resolution: {integrity: sha512-97Ktv+vZszADHdu6hS/B5tRfOqebwGNyD2Pfvmo1kK8d54UsNZtC22D8LJEueXqgVbq5PeSn0jv88uav3t1fHg==} peerDependencies: @@ -2002,6 +2025,94 @@ packages: '@package-json/types@0.0.12': resolution: {integrity: sha512-uu43FGU34B5VM9mCNjXCwLaGHYjXdNincqKLaraaCW+7S2+SmiBg1Nv8bPnmschrIfZmfKNY9f3fC376MRrObw==} + '@parcel/watcher-android-arm64@2.5.6': + resolution: {integrity: sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [android] + + '@parcel/watcher-darwin-arm64@2.5.6': + resolution: {integrity: sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [darwin] + + '@parcel/watcher-darwin-x64@2.5.6': + resolution: {integrity: sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [darwin] + + '@parcel/watcher-freebsd-x64@2.5.6': + resolution: {integrity: sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [freebsd] + + '@parcel/watcher-linux-arm-glibc@2.5.6': + resolution: {integrity: sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@parcel/watcher-linux-arm-musl@2.5.6': + resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + libc: [musl] + + '@parcel/watcher-linux-arm64-glibc@2.5.6': + resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@parcel/watcher-linux-arm64-musl@2.5.6': + resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@parcel/watcher-linux-x64-glibc@2.5.6': + resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@parcel/watcher-linux-x64-musl@2.5.6': + resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@parcel/watcher-win32-arm64@2.5.6': + resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [win32] + + '@parcel/watcher-win32-ia32@2.5.6': + resolution: {integrity: sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==} + engines: {node: '>= 10.0.0'} + cpu: [ia32] + os: [win32] + + '@parcel/watcher-win32-x64@2.5.6': + resolution: {integrity: sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [win32] + + '@parcel/watcher@2.5.6': + resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==} + engines: {node: '>= 10.0.0'} + '@paulmillr/qr@0.2.1': resolution: {integrity: sha512-IHnV6A+zxU7XwmKFinmYjUcwlyK9+xkG3/s9KcQhI9BjQKycrJ1JRO+FbNYPwZiPKW3je/DR0k7w8/gLa5eaxQ==} deprecated: 'The package is now available as "qr": npm install qr' @@ -2527,6 +2638,9 @@ packages: engines: {node: '>=16'} deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + '@schummar/icu-type-parser@1.21.5': + resolution: {integrity: sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==} + '@scure/base@1.1.9': resolution: {integrity: sha512-8YKhl8GHiNI/pU2VMaofa2Tor7PJRAjwQLBBuilkJ9L5+13yVbC7JO/wS7piioAvPSwR3JKM1IJ/u4xQzbcXKg==} @@ -2805,12 +2919,105 @@ packages: '@sumsub/cordova-idensic-mobile-sdk-plugin@1.42.0': resolution: {integrity: sha512-I3CfGwpCAGg4gBceNzCidNKktg7arD3xybOAdKLmZx3A+6BDW85kWg+jW10NEXqeYEyMhBT7MAoNbEV4pdhAoQ==} + '@swc/core-darwin-arm64@1.15.43': + resolution: {integrity: sha512-v1aVuvXdo/BHxJzco9V2xpHrvwWmhfS8t6gziY5wJxd+Z2h8AeJRnAwPD8itCDaGXVBwJ/CaKfxEzTkG0Va0OA==} + engines: {node: '>=10'} + cpu: [arm64] + os: [darwin] + + '@swc/core-darwin-x64@1.15.43': + resolution: {integrity: sha512-lp3d4Lamc8dt5huYdGLSR+9hLxmfr1jb0l+4XXG2zPqZwYWRN9R0U2qYoTrggiU2RWW0oV9VbWM3kBnqIc2kdQ==} + engines: {node: '>=10'} + cpu: [x64] + os: [darwin] + + '@swc/core-linux-arm-gnueabihf@1.15.43': + resolution: {integrity: sha512-JWTQQELtsG5GgphDrr/XqqmM2pDN3cZqbMS0Mrg+iTiXL3F74sn/S2IyYE/5u4h2KLkTf9qQ7dXyxsbx7YzkeA==} + engines: {node: '>=10'} + cpu: [arm] + os: [linux] + + '@swc/core-linux-arm64-gnu@1.15.43': + resolution: {integrity: sha512-B4otJRdPWIsmiSBf0uG7Z/+vMWmkufjz5MmYxubwKuZazDW14Zd3symga1N62QR4RT+kEFeHEgsXfZGyn/w0hw==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@swc/core-linux-arm64-musl@1.15.43': + resolution: {integrity: sha512-6zB6OnpViBxYy4tgY3v2i6AZY9fwkcHZ032UOwtwUuW1d19sdT07qF0kZe6/3UR1tUaK6jjg2rmVcUIBCEYVjQ==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@swc/core-linux-ppc64-gnu@1.15.43': + resolution: {integrity: sha512-coxE1ZWdB3uSDVNoEtYNrRi/1epvckZx9cTJ8ICUxTMTxGk+yvQ/Twacp3ruZSaMPGCriUjP86C37VhaT6nyRg==} + engines: {node: '>=10'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@swc/core-linux-s390x-gnu@1.15.43': + resolution: {integrity: sha512-lXfLhs+LpBsD5inuYx+YDH5WsPPBQ95KPUiy8P5wq9ob9xKDZFqwNfU2QW6bGO8NqRO/H9JQomTSt5Yyh+FGfA==} + engines: {node: '>=10'} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@swc/core-linux-x64-gnu@1.15.43': + resolution: {integrity: sha512-07XnKwTmKy8TGOZG3D9fRnLWGynxPjwQnZLVmBFbo6F+7vHYzBIOuwXEhemrChBWb6yDNZsVCcMWCPX6FDD2xg==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@swc/core-linux-x64-musl@1.15.43': + resolution: {integrity: sha512-TJc+bsSIaBh+hZvZ5GRtW/K1bw66TJ9vsUwvVIsZdiWxU5ObLwZvfcnZ3UpgVfMnFibRes9uriJrQNBHEEogRQ==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@swc/core-win32-arm64-msvc@1.15.43': + resolution: {integrity: sha512-jfd7s2/bUQYkOHLs+LWQNKZdmDa8+sufKLllhpWAhVQ2GDCwsHe3vR/j+OSiItZNtkzFuaawa3+SAKz9y5gYfw==} + engines: {node: '>=10'} + cpu: [arm64] + os: [win32] + + '@swc/core-win32-ia32-msvc@1.15.43': + resolution: {integrity: sha512-rLAE8JvucqEW1ZGohxPQrQWPBQeJG4+ypKbWfdlU/qmKScvCkxf9/Jxnzki1dkUQCQ7P5Enp13RlvqOlvx/32g==} + engines: {node: '>=10'} + cpu: [ia32] + os: [win32] + + '@swc/core-win32-x64-msvc@1.15.43': + resolution: {integrity: sha512-h8MLDHZcfIukwQWj03rIJZx1I0E81AYj2X7J/nGErG4nz+QAv6G1Z+peotvinL3lqpbo32tLYSMFo32/ySzxKg==} + engines: {node: '>=10'} + cpu: [x64] + os: [win32] + + '@swc/core@1.15.43': + resolution: {integrity: sha512-1CuKjFkPxIgGdeHVuNbkxmBxkcbdc08u0aiI43pFq6yY1tTVKmXT9hFEooyyKs/sJ3xf1GPHyEwTtk9Xl8dvQw==} + engines: {node: '>=10'} + peerDependencies: + '@swc/helpers': '>=0.5.17' + peerDependenciesMeta: + '@swc/helpers': + optional: true + + '@swc/counter@0.1.3': + resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} + '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} '@swc/helpers@0.5.18': resolution: {integrity: sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==} + '@swc/types@0.1.27': + resolution: {integrity: sha512-K6h3iUlqeM946U4sXFYeahefR1YBbXJvko+hv8WS8/0BNJ4OHiHRywMnQUJCqkR7Y9+hqQ1TvEpiKqUhz7NEFg==} + '@tanstack/query-core@5.8.3': resolution: {integrity: sha512-SWFMFtcHfttLYif6pevnnMYnBvxKf3C+MHMH7bevyYfpXpTMsLB9O6nNGBdWSoPwnZRXFNyNeVZOw25Wmdasow==} @@ -5073,6 +5280,9 @@ packages: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} + icu-minify@4.13.1: + resolution: {integrity: sha512-nFYW2im0WJ3RUVZwabd71J8QTZRtkK1xxZBY7klg7a6KS/os17LZSj9q1VhbRSnk3S8Mv2I7F1izr/aEJ6cUsw==} + idb-keyval@6.2.1: resolution: {integrity: sha512-8Sb3veuYCyrZL+VBt9LJfZjLUPWVvqn8tG28VqYNFCo43KHcKuq+b4EiXGeuaLAQWL2YmyDgMp2aSpH9JHsEQg==} @@ -5146,6 +5356,9 @@ packages: resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} engines: {node: '>=12'} + intl-messageformat@11.2.9: + resolution: {integrity: sha512-cGzymZerpDhVXRKjKLgXKda9gI29TU2o88L7gwNMHp3WZVxA/0c5tX52udXbW9JklDApolvMXZG6Dhhdz5eirA==} + ip-address@10.1.0: resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} engines: {node: '>= 12'} @@ -6049,6 +6262,10 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} @@ -6056,6 +6273,19 @@ packages: resolution: {integrity: sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==} engines: {node: '>= 0.4.0'} + next-intl-swc-plugin-extractor@4.13.1: + resolution: {integrity: sha512-RhlH2DR1ViEXzcX7G3tDXAvzNrBL2Ph54Hq/q/9oP9eXQV/okh3UQpA/lx2k9U5Ck83CZOSgH0eFTNi5U+zyXw==} + + next-intl@4.13.1: + resolution: {integrity: sha512-aS8KTA+nNhSNJJBlIhxgvU135WzoObwzFwav4wTDti/Gmhxqe0fs/Q343igo8Z7HGqPB/xgmoagwySZAlHmIfA==} + peerDependencies: + next: ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + next-mdx-remote@6.0.0: resolution: {integrity: sha512-cJEpEZlgD6xGjB4jL8BnI8FaYdN9BzZM4NwadPe1YQr7pqoWjg9EBCMv3nXBkuHqMRfv2y33SzUsuyNh9LFAQQ==} engines: {node: '>=14', npm: '>=7'} @@ -6086,6 +6316,9 @@ packages: node-addon-api@2.0.2: resolution: {integrity: sha512-Ntyt4AIXyaLIuMHF6IOoTakB3K+RWxwtsHNRxllEoA6vPwP9o4866g6YWDLUdnucilZhmkxiHwHr11gAENw+QA==} + node-addon-api@7.1.1: + resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + node-exports-info@1.6.0: resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==} engines: {node: '>= 0.4'} @@ -6413,6 +6646,9 @@ packages: resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} engines: {node: '>=10.13.0'} + po-parser@2.1.1: + resolution: {integrity: sha512-ECF4zHLbUItpUgE3OTtLKlPjeBN+fKEczj2zYjDfCGOzicNs0GK3Vg2IoAYwx7LH/XYw43fZQP6xnZ4TkNxSLQ==} + poly-decomp-es@0.4.2: resolution: {integrity: sha512-akjZkaTeyfECMNkMqv0g9gFiU2HjpZ5ubJA/aczw1ILdgWQbv1OPwCi05/9JbY2GnGpjm9k85Jdbd/j/p/lg/g==} @@ -7710,6 +7946,11 @@ packages: use-haptic@1.1.13: resolution: {integrity: sha512-Kg6+MaksFTPlbAbf+WTbJPlsXt6z61LsQ1TfnGv29Q/Ewp2pmmAG6WipOMf89zDOiCZ004IHNs/iwpsxSbDa4w==} + use-intl@4.13.1: + resolution: {integrity: sha512-UU5C3zAC7yVg3m7rq5C8VF5J5jhAfvS19Wi9bPNCB9xB7jQYBsUcrqfdxs4Mxl9XR3x6BDB5K++iAw7/rcm3gg==} + peerDependencies: + react: ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0 + use-sidecar@1.1.3: resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} engines: {node: '>=10'} @@ -8385,6 +8626,10 @@ snapshots: dependencies: tslib: 2.8.1 + '@capacitor/device@8.0.2(@capacitor/core@8.2.0)': + dependencies: + '@capacitor/core': 8.2.0 + '@capacitor/haptics@8.0.2(@capacitor/core@8.2.0)': dependencies: '@capacitor/core': 8.2.0 @@ -8951,6 +9196,18 @@ snapshots: '@floating-ui/utils@0.2.10': {} + '@formatjs/fast-memoize@3.1.6': {} + + '@formatjs/icu-messageformat-parser@3.5.12': + dependencies: + '@formatjs/icu-skeleton-parser': 2.1.10 + + '@formatjs/icu-skeleton-parser@2.1.10': {} + + '@formatjs/intl-localematcher@0.8.10': + dependencies: + '@formatjs/fast-memoize': 3.1.6 + '@gemini-wallet/core@0.1.1(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6))': dependencies: '@metamask/rpc-errors': 7.0.2 @@ -10160,6 +10417,66 @@ snapshots: '@package-json/types@0.0.12': {} + '@parcel/watcher-android-arm64@2.5.6': + optional: true + + '@parcel/watcher-darwin-arm64@2.5.6': + optional: true + + '@parcel/watcher-darwin-x64@2.5.6': + optional: true + + '@parcel/watcher-freebsd-x64@2.5.6': + optional: true + + '@parcel/watcher-linux-arm-glibc@2.5.6': + optional: true + + '@parcel/watcher-linux-arm-musl@2.5.6': + optional: true + + '@parcel/watcher-linux-arm64-glibc@2.5.6': + optional: true + + '@parcel/watcher-linux-arm64-musl@2.5.6': + optional: true + + '@parcel/watcher-linux-x64-glibc@2.5.6': + optional: true + + '@parcel/watcher-linux-x64-musl@2.5.6': + optional: true + + '@parcel/watcher-win32-arm64@2.5.6': + optional: true + + '@parcel/watcher-win32-ia32@2.5.6': + optional: true + + '@parcel/watcher-win32-x64@2.5.6': + optional: true + + '@parcel/watcher@2.5.6': + dependencies: + detect-libc: 2.1.2 + is-glob: 4.0.3 + node-addon-api: 7.1.1 + picomatch: 4.0.3 + optionalDependencies: + '@parcel/watcher-android-arm64': 2.5.6 + '@parcel/watcher-darwin-arm64': 2.5.6 + '@parcel/watcher-darwin-x64': 2.5.6 + '@parcel/watcher-freebsd-x64': 2.5.6 + '@parcel/watcher-linux-arm-glibc': 2.5.6 + '@parcel/watcher-linux-arm-musl': 2.5.6 + '@parcel/watcher-linux-arm64-glibc': 2.5.6 + '@parcel/watcher-linux-arm64-musl': 2.5.6 + '@parcel/watcher-linux-x64-glibc': 2.5.6 + '@parcel/watcher-linux-x64-musl': 2.5.6 + '@parcel/watcher-win32-arm64': 2.5.6 + '@parcel/watcher-win32-ia32': 2.5.6 + '@parcel/watcher-win32-x64': 2.5.6 + '@paulmillr/qr@0.2.1': {} '@pkgjs/parseargs@0.11.0': @@ -10950,6 +11267,8 @@ snapshots: '@safe-global/safe-gateway-typescript-sdk@3.23.1': {} + '@schummar/icu-type-parser@1.21.5': {} + '@scure/base@1.1.9': {} '@scure/base@1.2.6': {} @@ -11340,6 +11659,63 @@ snapshots: '@sumsub/cordova-idensic-mobile-sdk-plugin@1.42.0': {} + '@swc/core-darwin-arm64@1.15.43': + optional: true + + '@swc/core-darwin-x64@1.15.43': + optional: true + + '@swc/core-linux-arm-gnueabihf@1.15.43': + optional: true + + '@swc/core-linux-arm64-gnu@1.15.43': + optional: true + + '@swc/core-linux-arm64-musl@1.15.43': + optional: true + + '@swc/core-linux-ppc64-gnu@1.15.43': + optional: true + + '@swc/core-linux-s390x-gnu@1.15.43': + optional: true + + '@swc/core-linux-x64-gnu@1.15.43': + optional: true + + '@swc/core-linux-x64-musl@1.15.43': + optional: true + + '@swc/core-win32-arm64-msvc@1.15.43': + optional: true + + '@swc/core-win32-ia32-msvc@1.15.43': + optional: true + + '@swc/core-win32-x64-msvc@1.15.43': + optional: true + + '@swc/core@1.15.43(@swc/helpers@0.5.18)': + dependencies: + '@swc/counter': 0.1.3 + '@swc/types': 0.1.27 + optionalDependencies: + '@swc/core-darwin-arm64': 1.15.43 + '@swc/core-darwin-x64': 1.15.43 + '@swc/core-linux-arm-gnueabihf': 1.15.43 + '@swc/core-linux-arm64-gnu': 1.15.43 + '@swc/core-linux-arm64-musl': 1.15.43 + '@swc/core-linux-ppc64-gnu': 1.15.43 + '@swc/core-linux-s390x-gnu': 1.15.43 + '@swc/core-linux-x64-gnu': 1.15.43 + '@swc/core-linux-x64-musl': 1.15.43 + '@swc/core-win32-arm64-msvc': 1.15.43 + '@swc/core-win32-ia32-msvc': 1.15.43 + '@swc/core-win32-x64-msvc': 1.15.43 + '@swc/helpers': 0.5.18 + + '@swc/counter@0.1.3': {} + '@swc/helpers@0.5.15': dependencies: tslib: 2.8.1 @@ -11348,6 +11724,10 @@ snapshots: dependencies: tslib: 2.8.1 + '@swc/types@0.1.27': + dependencies: + '@swc/counter': 0.1.3 + '@tanstack/query-core@5.8.3': {} '@tanstack/react-query@5.8.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': @@ -13259,8 +13639,7 @@ snapshots: detect-browser@5.3.0: {} - detect-libc@2.1.2: - optional: true + detect-libc@2.1.2: {} detect-newline@3.1.0: {} @@ -14433,6 +14812,10 @@ snapshots: dependencies: safer-buffer: 2.1.2 + icu-minify@4.13.1: + dependencies: + '@formatjs/icu-messageformat-parser': 3.5.12 + idb-keyval@6.2.1: {} idb-keyval@6.2.2: {} @@ -14491,6 +14874,11 @@ snapshots: internmap@2.0.3: {} + intl-messageformat@11.2.9: + dependencies: + '@formatjs/fast-memoize': 3.1.6 + '@formatjs/icu-messageformat-parser': 3.5.12 + ip-address@10.1.0: {} iron-webcrypto@1.2.1: {} @@ -15842,10 +16230,31 @@ snapshots: natural-compare@1.4.0: {} + negotiator@1.0.0: {} + neo-async@2.6.2: {} netmask@2.0.2: {} + next-intl-swc-plugin-extractor@4.13.1: {} + + next-intl@4.13.1(@swc/helpers@0.5.18)(next@16.2.3(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(typescript@5.9.3): + dependencies: + '@formatjs/intl-localematcher': 0.8.10 + '@parcel/watcher': 2.5.6 + '@swc/core': 1.15.43(@swc/helpers@0.5.18) + icu-minify: 4.13.1 + negotiator: 1.0.0 + next: 16.2.3(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + next-intl-swc-plugin-extractor: 4.13.1 + po-parser: 2.1.1 + react: 19.2.4 + use-intl: 4.13.1(react@19.2.4) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@swc/helpers' + next-mdx-remote@6.0.0(@types/react@18.3.27)(react@19.2.4): dependencies: '@babel/code-frame': 7.28.6 @@ -15888,6 +16297,8 @@ snapshots: node-addon-api@2.0.2: {} + node-addon-api@7.1.1: {} + node-exports-info@1.6.0: dependencies: array.prototype.flatmap: 1.3.3 @@ -16279,6 +16690,8 @@ snapshots: pngjs@5.0.0: {} + po-parser@2.1.1: {} + poly-decomp-es@0.4.2: {} pony-cause@2.1.11: {} @@ -17692,6 +18105,14 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) + use-intl@4.13.1(react@19.2.4): + dependencies: + '@formatjs/fast-memoize': 3.1.6 + '@schummar/icu-type-parser': 1.21.5 + icu-minify: 4.13.1 + intl-messageformat: 11.2.9 + react: 19.2.4 + use-sidecar@1.1.3(@types/react@18.3.27)(react@19.2.4): dependencies: detect-node-es: 1.1.0 From 687117f9b69b32ccd90f338f7c6f652bbd7ae2a4 Mon Sep 17 00:00:00 2001 From: oxwel77 Date: Thu, 16 Jul 2026 10:47:47 +0100 Subject: [PATCH 03/44] fix(lint): resolve no-require-imports Hoist the lazy viem require in peanut-claim.utils (viem is already statically imported there) and scope the rule off for Jest test files, where require() after jest.mock()/resetModules() is intentional. --- eslint.config.js | 6 ++++++ src/utils/peanut-claim.utils.ts | 3 +-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index 051b8f20b2..7a9b6cfa6e 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -157,4 +157,10 @@ module.exports = [ files: ['src/components/Profile/components/PublicProfile.tsx'], rules: { 'no-restricted-syntax': 'off' }, }, + { + // require() inside test bodies is the Jest idiom for reading mocks after + // jest.mock()/resetModules(); hoisting them to imports changes semantics. + files: ['src/**/__tests__/**/*.{ts,tsx}', 'src/**/*.test.{ts,tsx}'], + rules: { '@typescript-eslint/no-require-imports': 'off' }, + }, ] diff --git a/src/utils/peanut-claim.utils.ts b/src/utils/peanut-claim.utils.ts index b44b37bcf3..88fbc6c2e5 100644 --- a/src/utils/peanut-claim.utils.ts +++ b/src/utils/peanut-claim.utils.ts @@ -5,7 +5,7 @@ * is supported. */ -import { keccak256, encodePacked, toBytes, type Abi } from 'viem' +import { keccak256, encodePacked, toBytes, encodeFunctionData, parseUnits, type Abi } from 'viem' import { privateKeyToAccount } from 'viem/accounts' import type { Address, Hex } from 'viem' @@ -101,7 +101,6 @@ export function prepareRequestLinkFulfillmentTransaction(opts: { tokenDecimals: number tokenType: number }): { unsignedTx: { to: Address; data?: Hex; value?: bigint; from?: Address } } { - const { encodeFunctionData, parseUnits } = require('viem') as typeof import('viem') const amount = parseUnits(String(opts.tokenAmount), opts.tokenDecimals) if (opts.tokenType === 0) { // Native — send to recipient with no calldata. From 1eb7c3c9b51e8c951547154492119b86ab2d0e63 Mon Sep 17 00:00:00 2001 From: oxwel77 Date: Thu, 16 Jul 2026 11:22:09 +0100 Subject: [PATCH 04/44] fix(lint): resolve no-unused-vars Remove dead imports/declarations, use bare catch where the binding was unused, and _-prefix signature-bound params and kept hook results. Also ignore ios/ and build/ (generated) in eslint. --- eslint.config.js | 2 + src/app/(mobile-ui)/add-money/page.tsx | 2 +- .../dev/ds/_components/Playground.tsx | 2 +- .../dev/ds/patterns/drawer/page.tsx | 1 - src/app/(mobile-ui)/dev/full-graph/page.tsx | 6 +-- src/app/(mobile-ui)/dev/leaderboard/page.tsx | 2 +- .../dev/perk-success-test/page.tsx | 2 +- src/app/(mobile-ui)/home/page.tsx | 3 +- .../qr-pay/__tests__/qr-pay-states.test.tsx | 19 ++------- src/app/(mobile-ui)/qr-pay/page.tsx | 3 -- src/app/(mobile-ui)/qr/[code]/page.tsx | 2 +- .../__tests__/withdraw-states.test.tsx | 4 +- src/app/(mobile-ui)/withdraw/crypto/page.tsx | 11 +---- src/app/(mobile-ui)/withdraw/page.tsx | 2 +- src/app/actions/clients.ts | 2 +- src/app/api/exchange-rate/route.ts | 4 -- src/app/api/health/justaname/route.ts | 2 +- src/app/api/preview-image/route.tsx | 3 -- src/app/api/recent-transactions/route.ts | 15 +------ src/app/invite/page.tsx | 1 - src/app/quests/components/QuestCard.tsx | 2 +- .../quests/components/QuestLeaderboard.tsx | 2 +- .../AddMoney/components/MantecaAddMoney.tsx | 2 +- .../AddWithdraw/AddWithdrawCountriesList.tsx | 2 +- .../AddWithdraw/DynamicBankAccountForm.tsx | 2 +- src/components/Auth/auth.e2e.test.ts | 6 +-- src/components/Claim/Link/Initial.view.tsx | 5 +-- .../Claim/Link/Onchain/Confirm.view.tsx | 1 - .../Claim/Link/Onchain/Success.view.tsx | 1 - .../Claim/Link/views/BankFlowManager.view.tsx | 3 +- .../Claim/__tests__/claim-states.test.tsx | 6 +-- src/components/Create/useCreateLink.tsx | 8 ++-- src/components/Global/InvitesGraph/index.tsx | 19 ++------- src/components/Global/ProgressBar/index.tsx | 2 +- .../Global/ScreenOrientationLocker.tsx | 1 - .../Global/ValidatedInput/index.tsx | 2 +- .../Kyc/KycVerificationInProgressModal.tsx | 1 - src/components/LandingPage/CurrencySelect.tsx | 1 - .../Profile/components/ProfileHeader.tsx | 3 +- src/components/Request/Pay/Pay.tsx | 2 +- .../link/views/Create.request.link.view.tsx | 6 +-- .../Send/__tests__/send-states.test.tsx | 2 +- src/components/Setup/Views/JoinWaitlist.tsx | 4 +- .../TransactionDetailsReceipt.tsx | 3 -- .../__tests__/transactionTransformer.test.ts | 41 +++++++++++++++++++ .../transactionTransformer.ts | 8 ++++ src/components/UserHeader/index.tsx | 2 +- src/components/og/ReceiptCardOG.tsx | 7 ---- src/context/PeanutDebug.tsx | 6 +-- src/context/authContext.tsx | 2 +- src/context/loadingStates.context.tsx | 2 +- src/context/tokenSelector.context.tsx | 37 +++++------------ .../useSemanticRequestFlow.ts | 2 +- .../views/SemanticRequestConfirmView.tsx | 1 - .../views/SemanticRequestInputView.tsx | 1 - src/hooks/useCapabilities.test.ts | 2 +- src/hooks/useDetermineBankClaimType.ts | 2 +- src/hooks/useDetermineBankRequestType.ts | 2 +- src/hooks/useSetupFlow.ts | 2 +- src/hooks/wallet/useSpendBundle.ts | 1 - src/lib/url-parser/parser.ts | 4 +- src/services/sendLinks.ts | 2 +- src/utils/__mocks__/simplewebauthn-browser.ts | 4 +- src/utils/__tests__/capacitor.test.ts | 1 - src/utils/dateGrouping.utils.ts | 2 +- src/utils/general.utils.ts | 10 +---- src/utils/history.utils.ts | 1 + 67 files changed, 128 insertions(+), 188 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index 7a9b6cfa6e..8b4d54d4e6 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -27,6 +27,8 @@ module.exports = [ 'public/**', 'src/content/**', 'android/**', + 'ios/**', + 'build/**', 'src/types/api.generated.ts', 'coverage/**', 'playwright-report/**', diff --git a/src/app/(mobile-ui)/add-money/page.tsx b/src/app/(mobile-ui)/add-money/page.tsx index 495b65140b..7b143c8644 100644 --- a/src/app/(mobile-ui)/add-money/page.tsx +++ b/src/app/(mobile-ui)/add-money/page.tsx @@ -14,7 +14,7 @@ import { useOnrampFlow } from '@/context/OnrampFlowContext' import { useRouter, useSearchParams } from 'next/navigation' import { useEffect } from 'react' import { useQueryState, parseAsStringEnum } from 'nuqs' -import { checkIfInternalNavigation, getRedirectUrl, clearRedirectUrl, getFromLocalStorage } from '@/utils/general.utils' +import { getRedirectUrl, clearRedirectUrl, getFromLocalStorage } from '@/utils/general.utils' import posthog from 'posthog-js' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' import { addMoneyCountryUrl } from '@/utils/native-routes' diff --git a/src/app/(mobile-ui)/dev/ds/_components/Playground.tsx b/src/app/(mobile-ui)/dev/ds/_components/Playground.tsx index 1b4a15eb46..066bc78a39 100644 --- a/src/app/(mobile-ui)/dev/ds/_components/Playground.tsx +++ b/src/app/(mobile-ui)/dev/ds/_components/Playground.tsx @@ -17,7 +17,7 @@ interface PlaygroundProps { codeTemplate: (props: Record) => string } -export function Playground({ name, importPath, defaults, controls, render, codeTemplate }: PlaygroundProps) { +export function Playground({ importPath, defaults, controls, render, codeTemplate }: PlaygroundProps) { const [props, setProps] = useState>(defaults) const updateProp = (key: string, value: any) => { diff --git a/src/app/(mobile-ui)/dev/ds/patterns/drawer/page.tsx b/src/app/(mobile-ui)/dev/ds/patterns/drawer/page.tsx index b2eeb3a1da..3590b551ff 100644 --- a/src/app/(mobile-ui)/dev/ds/patterns/drawer/page.tsx +++ b/src/app/(mobile-ui)/dev/ds/patterns/drawer/page.tsx @@ -7,7 +7,6 @@ import { DrawerTitle, DrawerTrigger, DrawerHeader, - DrawerFooter, DrawerDescription, DrawerClose, } from '@/components/Global/Drawer' diff --git a/src/app/(mobile-ui)/dev/full-graph/page.tsx b/src/app/(mobile-ui)/dev/full-graph/page.tsx index 1b18b3cce3..f5c5897716 100644 --- a/src/app/(mobile-ui)/dev/full-graph/page.tsx +++ b/src/app/(mobile-ui)/dev/full-graph/page.tsx @@ -5,11 +5,7 @@ import { Button } from '@/components/0_Bruddle/Button' import { useAuth } from '@/context/authContext' import { IS_DEV } from '@/constants/general.consts' import InvitesGraph from '@/components/Global/InvitesGraph' -import { - DEFAULT_FORCE_CONFIG, - DEFAULT_VISIBILITY_CONFIG, - DEFAULT_EXTERNAL_NODES_CONFIG, -} from '@/components/Global/InvitesGraph/types' +import { DEFAULT_FORCE_CONFIG } from '@/components/Global/InvitesGraph/types' // Allowed users for full graph access (frontend check - backend also validates) const ALLOWED_USERNAMES = ['squirrel', 'kkonrad', 'hugo'] diff --git a/src/app/(mobile-ui)/dev/leaderboard/page.tsx b/src/app/(mobile-ui)/dev/leaderboard/page.tsx index c38375c6d2..56a0ad585a 100644 --- a/src/app/(mobile-ui)/dev/leaderboard/page.tsx +++ b/src/app/(mobile-ui)/dev/leaderboard/page.tsx @@ -200,7 +200,7 @@ export default function LeaderboardPage() { const timestamp = localToUtc(value) updateURL(timestamp) debouncedFetch(timestamp) - } catch (e) { + } catch { // Invalid date format while typing, ignore } }, diff --git a/src/app/(mobile-ui)/dev/perk-success-test/page.tsx b/src/app/(mobile-ui)/dev/perk-success-test/page.tsx index 0df4e22a9f..a5e8b5ecf3 100644 --- a/src/app/(mobile-ui)/dev/perk-success-test/page.tsx +++ b/src/app/(mobile-ui)/dev/perk-success-test/page.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState, useEffect } from 'react' +import { useState } from 'react' import { Card } from '@/components/0_Bruddle/Card' import { Button } from '@/components/0_Bruddle/Button' import NavHeader from '@/components/Global/NavHeader' diff --git a/src/app/(mobile-ui)/home/page.tsx b/src/app/(mobile-ui)/home/page.tsx index 9174375ae6..eac55c1116 100644 --- a/src/app/(mobile-ui)/home/page.tsx +++ b/src/app/(mobile-ui)/home/page.tsx @@ -35,7 +35,6 @@ import { useHaptic } from 'use-haptic' import { useActivationStatus } from '@/hooks/useActivationStatus' import ActivationCTAs from '@/components/Home/ActivationCTAs' import LazyLoadErrorBoundary from '@/components/Global/LazyLoadErrorBoundary' -import underMaintenanceConfig from '@/config/underMaintenance.config' import posthog from 'posthog-js' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' @@ -80,7 +79,7 @@ export default function Home() { // Track if this is a fresh signup session - captured once on mount so it persists // even after NoMoreJailModal clears the sessionStorage key - const [isPostSignupSession] = useState(() => { + const [_isPostSignupSession] = useState(() => { if (typeof window === 'undefined') return false return sessionStorage.getItem('showNoMoreJailModal') === 'true' }) diff --git a/src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx b/src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx index f305d66aa0..0729f3a52a 100644 --- a/src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx +++ b/src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx @@ -12,7 +12,7 @@ import posthog from 'posthog-js' import { render, screen, fireEvent, waitFor, act } from '@testing-library/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { parseUnits } from 'viem' -import type { RailCapability, CapabilityRestriction } from '@/types/capabilities' +import type { RailCapability } from '@/types/capabilities' // Test-local subsets — only the fields the qr-pay page actually reads from each // fixture. Mirroring the full RailCapability/CapabilityRestriction types here @@ -231,7 +231,7 @@ jest.mock('@/features/limits/hooks/useLimitsValidation', () => ({ jest.mock('@/features/limits/components/LimitsWarningCard', () => ({ __esModule: true, - default: (props: any) =>
, + default: (_props: any) =>
, })) jest.mock('@/features/limits/utils', () => ({ @@ -504,25 +504,12 @@ function setCapabilitiesGate(state: GateState, opts: { userMessage?: string | nu mockUseCapabilities.mockReturnValue(capabilitiesForGate(state, opts)) } -// Loading state context provider -const LoadingStateProvider = ({ children }: { children: React.ReactNode }) => { - const loadingStateContext = require('@/context/loadingStates.context').loadingStateContext - const [loadingState, setLoadingState] = React.useState('Idle') - const isLoading = loadingState !== 'Idle' - return ( - - {children} - - ) -} - // We need to mock the context module itself (specific file, not the barrel — the page // imports from '@/context/loadingStates.context' per the no-barrel rule) -const mockSetLoadingState = jest.fn() jest.mock('@/context/loadingStates.context', () => ({ loadingStateContext: React.createContext({ loadingState: 'Idle' as string, - setLoadingState: (s: string) => {}, + setLoadingState: (_s: string) => {}, isLoading: false, }), })) diff --git a/src/app/(mobile-ui)/qr-pay/page.tsx b/src/app/(mobile-ui)/qr-pay/page.tsx index a5e11a4b33..3c7d7a6464 100644 --- a/src/app/(mobile-ui)/qr-pay/page.tsx +++ b/src/app/(mobile-ui)/qr-pay/page.tsx @@ -1296,9 +1296,6 @@ export default function QRPayPage() {

{(() => { const amountSponsored = qrPayment?.perk?.amountSponsored - const transactionUsd = - parseFloat(qrPayment?.details?.paymentAgainstAmount || '0') || 0 - if (amountSponsored && typeof amountSponsored === 'number') { return `You earned $${amountSponsored.toFixed(2)}! Hold to claim your reward.` } diff --git a/src/app/(mobile-ui)/qr/[code]/page.tsx b/src/app/(mobile-ui)/qr/[code]/page.tsx index 7798171550..e62ee51de7 100644 --- a/src/app/(mobile-ui)/qr/[code]/page.tsx +++ b/src/app/(mobile-ui)/qr/[code]/page.tsx @@ -58,7 +58,7 @@ export default function RedirectQrClaimPage() { console.error('Untrusted external redirect blocked:', redirectQrData.redirectUrl) setError('Invalid QR code destination.') } - } catch (error) { + } catch { console.error('Invalid redirect URL:', redirectQrData.redirectUrl) setError('Invalid QR code destination.') } diff --git a/src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx b/src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx index bb51cd3c80..33200e5fc7 100644 --- a/src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx +++ b/src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx @@ -8,7 +8,7 @@ * per-test via mockReturnValue / mockImplementation. */ import React from 'react' -import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { render, screen, fireEvent } from '@testing-library/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { parseUnits } from 'viem' @@ -128,7 +128,7 @@ jest.mock('@/features/limits/hooks/useLimitsValidation', () => ({ jest.mock('@/features/limits/components/LimitsWarningCard', () => ({ __esModule: true, - default: (props: any) =>

, + default: (_props: any) =>
, })) jest.mock('@/features/limits/utils', () => ({ diff --git a/src/app/(mobile-ui)/withdraw/crypto/page.tsx b/src/app/(mobile-ui)/withdraw/crypto/page.tsx index bb10dec6f1..693c8361e3 100644 --- a/src/app/(mobile-ui)/withdraw/crypto/page.tsx +++ b/src/app/(mobile-ui)/withdraw/crypto/page.tsx @@ -23,14 +23,12 @@ import * as peanutInterfaces from '@/interfaces/peanut-sdk-types' import { useRouter } from 'next/navigation' import { useCallback, useContext, useEffect, useMemo, useState } from 'react' import { useSafeBack } from '@/hooks/useSafeBack' -import { captureMessage } from '@sentry/nextjs' import type { Address, Hex, TransactionReceipt } from 'viem' import { parseUnits } from 'viem' import { Slider } from '@/components/Slider' import { tokenSelectorContext } from '@/context' import { useHaptic } from 'use-haptic' import { PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN, PEANUT_WALLET_TOKEN_DECIMALS } from '@/constants/zerodev.consts' -import { ROUTE_NOT_FOUND_ERROR } from '@/constants/general.consts' import { useCrossChainTransfer } from '@/features/payments/shared/hooks/useCrossChainTransfer' import { usePaymentRecorder } from '@/features/payments/shared/hooks/usePaymentRecorder' import { isTxReverted } from '@/utils/general.utils' @@ -42,12 +40,11 @@ import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' export default function WithdrawCryptoPage() { const router = useRouter() const onBack = useSafeBack('/withdraw') - const { isConnected: isPeanutWallet, address, sendTransactions, sendMoney, spendableBalance } = useWallet() + const { address, sendTransactions, sendMoney, spendableBalance } = useWallet() const { resetTokenContextProvider } = useContext(tokenSelectorContext) const { amountToWithdraw, usdAmount, - setAmountToWithdraw, currentView, setCurrentView, withdrawData, @@ -308,9 +305,6 @@ export default function WithdrawCryptoPage() { // recordPayment (smart-only) or rely on the Rain webhook → // TransactionIntent reconciliation path (collateral-only / mixed). let strategy: 'collateral-only' | 'smart-only' | 'mixed' | undefined - // Backend TransactionIntent id — used to navigate to the unified - // receipt page for collateral/mixed spends. - let intentId: string | undefined if (!isCrossChainWithdrawal) { const { @@ -318,11 +312,9 @@ export default function WithdrawCryptoPage() { txHash, receipt: r, strategy: s, - intentId: i, } = await sendMoney(withdrawData.address as Address, amountToWithdraw, { kind: 'CRYPTO_WITHDRAW' }) receipt = r strategy = s - intentId = i if (receipt !== null && isTxReverted(receipt)) { throw new Error(`Transaction failed (reverted). Hash: ${receipt.transactionHash}`) } @@ -342,7 +334,6 @@ export default function WithdrawCryptoPage() { }) receipt = txResult.receipt strategy = txResult.strategy - intentId = txResult.intentId if (receipt !== null && isTxReverted(receipt)) { throw new Error(`Transaction failed (reverted). Hash: ${receipt.transactionHash}`) } diff --git a/src/app/(mobile-ui)/withdraw/page.tsx b/src/app/(mobile-ui)/withdraw/page.tsx index 6eb1a7f91c..b0deb92a77 100644 --- a/src/app/(mobile-ui)/withdraw/page.tsx +++ b/src/app/(mobile-ui)/withdraw/page.tsx @@ -76,7 +76,7 @@ export default function WithdrawPage() { // initialise the amount input with the value from context (if any) // state to keep track of the token input key to force-remount the component - const [tokenInputKey, setTokenInputKey] = useState(0) + const [_tokenInputKey, setTokenInputKey] = useState(0) // raw amount currently typed in the input const [rawTokenAmount, setRawTokenAmount] = useState(amountFromContext || '') diff --git a/src/app/actions/clients.ts b/src/app/actions/clients.ts index 6bcbc42028..0068fa9abb 100644 --- a/src/app/actions/clients.ts +++ b/src/app/actions/clients.ts @@ -3,7 +3,7 @@ import { BUNDLER_URL, PAYMASTER_URL, PEANUT_WALLET_CHAIN } from '@/constants/zer import type { PublicClient, Chain, Transport } from 'viem' import { createPublicClient, http, extractChain, fallback } from 'viem' import * as chains from 'viem/chains' -import { arbitrum, arbitrumSepolia, mainnet, base, linea } from 'viem/chains' +import { mainnet, base, linea } from 'viem/chains' const allChains = Object.values(chains) export type ChainId = (typeof allChains)[number]['id'] diff --git a/src/app/api/exchange-rate/route.ts b/src/app/api/exchange-rate/route.ts index 8a8bcfca07..b4d6e1b2cb 100644 --- a/src/app/api/exchange-rate/route.ts +++ b/src/app/api/exchange-rate/route.ts @@ -1,10 +1,6 @@ import { NextRequest, NextResponse } from 'next/server' import { getCachedCurrencyPrice } from '@/app/actions/currency' -interface ExchangeRateResponse { - rate: number -} - // LATAM currencies that should use Manteca API const MANTECA_CURRENCIES = new Set(['ARS', 'BRL', 'COP', 'CRC', 'PUSD', 'GTQ', 'PHP', 'BOB']) diff --git a/src/app/api/health/justaname/route.ts b/src/app/api/health/justaname/route.ts index 54912392f6..c10f0ec78d 100644 --- a/src/app/api/health/justaname/route.ts +++ b/src/app/api/health/justaname/route.ts @@ -58,7 +58,7 @@ export async function GET() { responseTime: lookupResponseTime, httpStatus: lookupResponse.status, } - } catch (error) { + } catch { // If lookup fails, that's okay - not all endpoints may be available lookupHealth = { status: 'degraded', diff --git a/src/app/api/preview-image/route.tsx b/src/app/api/preview-image/route.tsx index 9d00daea8d..abd3a392ea 100644 --- a/src/app/api/preview-image/route.tsx +++ b/src/app/api/preview-image/route.tsx @@ -1,6 +1,3 @@ -import { LinkPreviewImg, PreviewType } from '@/components/Global/ImageGeneration/LinkPreview' -import { ImageResponse } from 'next/og' -import { isAddress } from 'viem' import { NextResponse } from 'next/server' export const runtime = 'edge' diff --git a/src/app/api/recent-transactions/route.ts b/src/app/api/recent-transactions/route.ts index 28f05d2cd3..53ee649595 100644 --- a/src/app/api/recent-transactions/route.ts +++ b/src/app/api/recent-transactions/route.ts @@ -1,19 +1,6 @@ import { fetchWithSentry } from '@/utils/sentry.utils' import { NextRequest } from 'next/server' -interface TransferDetails { - id: string - timestamp: string - chain: string - details: any -} - -interface Portfolio { - id: string - ownerAddress: string - assetActivities: TransferDetails[] -} - const query = ` query RecentTokenTransfers($address: String!) { portfolios( @@ -79,7 +66,7 @@ const query = ` } ` -export async function POST(request: NextRequest, context: { params: Promise> }) { +export async function POST(request: NextRequest, _context: { params: Promise> }) { const body = await request.json() try { diff --git a/src/app/invite/page.tsx b/src/app/invite/page.tsx index dbe935fb8f..39e4cd0cc2 100644 --- a/src/app/invite/page.tsx +++ b/src/app/invite/page.tsx @@ -22,7 +22,6 @@ async function getInviteCodeData(inviteCode: string) { } export async function generateMetadata({ - params, searchParams, }: { params: Promise<{ id?: string }> diff --git a/src/app/quests/components/QuestCard.tsx b/src/app/quests/components/QuestCard.tsx index 4edb8c3d5b..b4b4dc844d 100644 --- a/src/app/quests/components/QuestCard.tsx +++ b/src/app/quests/components/QuestCard.tsx @@ -99,7 +99,7 @@ export function QuestCard({
) : ( <> - {leaderboard.slice(0, 3).map((entry, index) => ( + {leaderboard.slice(0, 3).map((entry) => (
{ diff --git a/src/app/quests/components/QuestLeaderboard.tsx b/src/app/quests/components/QuestLeaderboard.tsx index 0b36e45a9e..c08e9e36a8 100644 --- a/src/app/quests/components/QuestLeaderboard.tsx +++ b/src/app/quests/components/QuestLeaderboard.tsx @@ -13,7 +13,7 @@ interface QuestLeaderboardProps { isCurrency?: boolean } -export function QuestLeaderboard({ entries, metricLabel, badgeColor, isCurrency = false }: QuestLeaderboardProps) { +export function QuestLeaderboard({ entries, badgeColor, isCurrency = false }: QuestLeaderboardProps) { const router = useRouter() const getBadgeColorClasses = (color: string) => { switch (color) { diff --git a/src/components/AddMoney/components/MantecaAddMoney.tsx b/src/components/AddMoney/components/MantecaAddMoney.tsx index baa9fea45f..b783274385 100644 --- a/src/components/AddMoney/components/MantecaAddMoney.tsx +++ b/src/components/AddMoney/components/MantecaAddMoney.tsx @@ -7,7 +7,7 @@ import InputAmountStep from '@/components/AddMoney/components/InputAmountStep' import { useParams, useSearchParams } from 'next/navigation' import { addMoneyCountryUrl } from '@/utils/native-routes' import { useSafeBack } from '@/hooks/useSafeBack' -import { type CountryData, countryData } from '@/components/AddMoney/consts' +import { countryData } from '@/components/AddMoney/consts' import { type MantecaDepositResponseData } from '@/types/manteca.types' import { useCurrency } from '@/hooks/useCurrency' import { mantecaApi } from '@/services/manteca' diff --git a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx index 5391c36cde..310d02f7fe 100644 --- a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx +++ b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx @@ -166,7 +166,7 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { const handleFormSubmit = async ( payload: AddBankAccountPayload, - rawData: IBankAccountDetails + _rawData: IBankAccountDetails ): Promise<{ error?: string; silent?: boolean }> => { // re-fetch user to ensure we have the latest KYC status // (the multi-phase flow may have completed but websocket/state not yet propagated) diff --git a/src/components/AddWithdraw/DynamicBankAccountForm.tsx b/src/components/AddWithdraw/DynamicBankAccountForm.tsx index 832e52870d..06b2c5237c 100644 --- a/src/components/AddWithdraw/DynamicBankAccountForm.tsx +++ b/src/components/AddWithdraw/DynamicBankAccountForm.tsx @@ -357,7 +357,7 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D 'h-12 w-full rounded-sm border border-n-1 bg-white px-4 text-sm', errors[name] && touchedFields[name] && 'border-error' )} - onBlur={async (e) => { + onBlur={async (_e) => { // remove any whitespace from the input field // note: @dev not a great fix, this should also be fixed in the backend if (typeof field.value === 'string') { diff --git a/src/components/Auth/auth.e2e.test.ts b/src/components/Auth/auth.e2e.test.ts index a80979f243..5b2d6e46a5 100644 --- a/src/components/Auth/auth.e2e.test.ts +++ b/src/components/Auth/auth.e2e.test.ts @@ -21,11 +21,9 @@ test.describe('Auth UI Flow', () => { ] // Check if any auth element is visible - let foundAuthElement = false for (const el of authElements) { const visible = await el.isVisible().catch(() => false) if (visible) { - foundAuthElement = true break } } @@ -78,13 +76,13 @@ test.describe('Auth UI Flow', () => { }) test.describe('Auth State Persistence', () => { - test('should maintain auth state across page navigation', async ({ page }) => { + test('should maintain auth state across page navigation', async ({ page: _page }) => { // this test requires actual auth - skip for now // real auth requires wallet connection which is external dependency test.skip() }) - test('should handle auth state on page refresh', async ({ page }) => { + test('should handle auth state on page refresh', async ({ page: _page }) => { // skip - requires actual wallet connection test.skip() }) diff --git a/src/components/Claim/Link/Initial.view.tsx b/src/components/Claim/Link/Initial.view.tsx index 2e0e699a3e..792c698bfe 100644 --- a/src/components/Claim/Link/Initial.view.tsx +++ b/src/components/Claim/Link/Initial.view.tsx @@ -18,10 +18,9 @@ import { sendLinksApi } from '@/services/sendLinks' import { areEvmAddressesEqual, formatTokenAmount, toInviteCode } from '@/utils/general.utils' import { useRecipientDisplay } from '@/hooks/useRecipientDisplay' import { ErrorHandler } from '@/utils/friendly-error.utils' -import { fetchWithSentry } from '@/utils/sentry.utils' import { apiFetch } from '@/utils/api-fetch' import { getBridgeChainName, getBridgeTokenName } from '@/utils/bridge-accounts.utils' -import { NATIVE_TOKEN_ADDRESS, NATIVE_TOKEN_PROXY_ADDRESS, checkTokenSupportsXChain } from '@/utils/token.utils' +import { NATIVE_TOKEN_ADDRESS, checkTokenSupportsXChain } from '@/utils/token.utils' import * as Sentry from '@sentry/nextjs' import { useQueryClient } from '@tanstack/react-query' import { useRouter, useSearchParams } from 'next/navigation' @@ -240,7 +239,7 @@ export const InitialClaimLinkView = (props: IClaimScreenProps) => { }, [recipientType, claimLinkData.chainId, isPeanutChain, claimLinkData.tokenAddress]) const handleClaimLink = useCallback( - async (bypassModal = false, autoClaim = false) => { + async (bypassModal = false, _autoClaim = false) => { if (!selectedTokenData) return if (!isPeanutWallet && !bypassModal) { diff --git a/src/components/Claim/Link/Onchain/Confirm.view.tsx b/src/components/Claim/Link/Onchain/Confirm.view.tsx index 95aff00c72..70af16df52 100644 --- a/src/components/Claim/Link/Onchain/Confirm.view.tsx +++ b/src/components/Claim/Link/Onchain/Confirm.view.tsx @@ -32,7 +32,6 @@ export const ConfirmClaimLinkView = ({ recipient, tokenPrice, setTransactionHash, - estimatedPoints, attachment, selectedRoute, }: _consts.IClaimScreenProps) => { diff --git a/src/components/Claim/Link/Onchain/Success.view.tsx b/src/components/Claim/Link/Onchain/Success.view.tsx index adb16e3cd6..32c9a21e55 100644 --- a/src/components/Claim/Link/Onchain/Success.view.tsx +++ b/src/components/Claim/Link/Onchain/Success.view.tsx @@ -25,7 +25,6 @@ export const SuccessClaimLinkView = ({ transactionHash, setTransactionHash, claimLinkData, - type, tokenPrice, }: _consts.IClaimScreenProps) => { const { user: authUser } = useUserStore() diff --git a/src/components/Claim/Link/views/BankFlowManager.view.tsx b/src/components/Claim/Link/views/BankFlowManager.view.tsx index e520f9c14d..cc533bbd79 100644 --- a/src/components/Claim/Link/views/BankFlowManager.view.tsx +++ b/src/components/Claim/Link/views/BankFlowManager.view.tsx @@ -72,7 +72,6 @@ export const BankFlowManager = (props: IClaimScreenProps) => { setBankDetails, justCompletedKyc, setJustCompletedKyc, - showVerificationModal: isKycModalOpen, setShowVerificationModal: setIsKycModalOpen, } = useClaimBankFlow() @@ -112,7 +111,7 @@ export const BankFlowManager = (props: IClaimScreenProps) => { const [error, setError] = useState(null) const formRef = useRef<{ handleSubmit: () => void }>(null) const [isProcessingKycSuccess, setIsProcessingKycSuccess] = useState(false) - const [offrampData, setOfframpData] = useState(null) + const [_offrampData, setOfframpData] = useState(null) /** * @name handleConfirmClaim diff --git a/src/components/Claim/__tests__/claim-states.test.tsx b/src/components/Claim/__tests__/claim-states.test.tsx index e99acc1398..a41fde0509 100644 --- a/src/components/Claim/__tests__/claim-states.test.tsx +++ b/src/components/Claim/__tests__/claim-states.test.tsx @@ -179,7 +179,7 @@ jest.mock('@/components/TransactionDetails/transactionTransformer', () => ({ })) jest.mock('@/components/TransactionDetails/TransactionDetailsReceipt', () => ({ - TransactionDetailsReceipt: (props: any) =>
Receipt
, + TransactionDetailsReceipt: (_props: any) =>
Receipt
, })) jest.mock('@/context/ModalsContext', () => ({ @@ -192,7 +192,7 @@ jest.mock('@/context/ModalsContext', () => ({ // Mock the sub-components jest.mock('../Link/FlowManager', () => ({ __esModule: true, - default: (props: any) =>
Claim Flow
, + default: (_props: any) =>
Claim Flow
, })) jest.mock('../Generic', () => ({ @@ -224,7 +224,7 @@ jest.mock('@/components/0_Bruddle/PageContainer', () => ({ jest.mock('@/components/Global/PeanutLoading', () => ({ __esModule: true, - default: (props: any) =>
Loading...
, + default: (_props: any) =>
Loading...
, })) jest.mock('@/assets/mascot', () => ({ diff --git a/src/components/Create/useCreateLink.tsx b/src/components/Create/useCreateLink.tsx index 9a75561297..0219ca1690 100644 --- a/src/components/Create/useCreateLink.tsx +++ b/src/components/Create/useCreateLink.tsx @@ -21,11 +21,11 @@ import { useSignTypedData } from 'wagmi' export const useCreateLink = () => { const { setLoadingState } = useContext(loadingStateContext) - const { selectedChainID } = useContext(tokenSelectorContext) + const { selectedChainID: _selectedChainID } = useContext(tokenSelectorContext) - const { address, sendTransactions } = useWallet() - const { signTypedDataAsync } = useSignTypedData() - const { handleSendUserOpEncoded } = useZeroDev() + const { sendTransactions } = useWallet() + const { signTypedDataAsync: _signTypedDataAsync } = useSignTypedData() + const { handleSendUserOpEncoded: _handleSendUserOpEncoded } = useZeroDev() const generatePassword = async () => { try { diff --git a/src/components/Global/InvitesGraph/index.tsx b/src/components/Global/InvitesGraph/index.tsx index 699e7ce55e..21c8403b5d 100644 --- a/src/components/Global/InvitesGraph/index.tsx +++ b/src/components/Global/InvitesGraph/index.tsx @@ -266,7 +266,7 @@ export default function InvitesGraph(props: InvitesGraphProps) { // Particle arrival popups for user mode (+1 pt animations) // Map: linkId → { timestamp, x, y, nodeId } - const particleArrivalsRef = useRef>( + const _particleArrivalsRef = useRef>( new Map() ) @@ -1047,7 +1047,6 @@ export default function InvitesGraph(props: InvitesGraphProps) { showUsernames: showNames, isMinimal: minimal, activityFilter: filter, - visibilityConfig: visibility, externalNodesConfig: extConfig, } = displaySettingsRef.current @@ -1296,7 +1295,7 @@ export default function InvitesGraph(props: InvitesGraphProps) { [getUserActivityStatus] ) - const linkColor = useCallback( + const _linkColor = useCallback( (link: any) => { // P2P edges: cyan/teal - solid line, particles show movement if (link.isP2P) { @@ -1316,12 +1315,8 @@ export default function InvitesGraph(props: InvitesGraphProps) { // Custom link rendering for arrows on invites and particles on P2P const linkCanvasObject = useCallback( - (link: any, ctx: CanvasRenderingContext2D, globalScale: number) => { - const { - activityFilter, - visibilityConfig: visibility, - externalNodesConfig: extConfig, - } = displaySettingsRef.current + (link: any, ctx: CanvasRenderingContext2D, _globalScale: number) => { + const { externalNodesConfig: extConfig } = displaySettingsRef.current const source = link.source const target = link.target @@ -1479,8 +1474,6 @@ export default function InvitesGraph(props: InvitesGraphProps) { const baseColor = isDirect ? [255, 144, 232] : [186, 139, 255] const alpha = inactive ? 0.12 : 0.35 const arrowAlpha = inactive ? 0.2 : 0.6 - const { mode: currentMode } = displaySettingsRef.current - // Draw main line ctx.strokeStyle = `rgba(${baseColor.join(',')}, ${alpha})` ctx.lineWidth = isDirect ? 1 : 0.8 @@ -1779,10 +1772,6 @@ export default function InvitesGraph(props: InvitesGraphProps) { } return fc.inviteLinks.enabled ? Math.min(fc.inviteLinks.strength, 1.0) : 0 }) - - const inviteStr = fc.inviteLinks.enabled ? Math.min(fc.inviteLinks.strength, 1.0) : 0 - const p2pStr = fc.p2pLinks.enabled ? Math.min(fc.p2pLinks.strength, 1.0) : 0 - const extStr = extConfig.enabled ? Math.min(extConfig.strength, 1.0) : 0 } // CENTER: Pulls nodes toward origin. sizeBias controls how much bigger nodes are pulled more diff --git a/src/components/Global/ProgressBar/index.tsx b/src/components/Global/ProgressBar/index.tsx index fdd4cf4f2b..84af3b3cdf 100644 --- a/src/components/Global/ProgressBar/index.tsx +++ b/src/components/Global/ProgressBar/index.tsx @@ -131,7 +131,7 @@ const ProgressBar: React.FC = ({ goal, progress, isClosed }) = ) } - const renderMarker = (color: string, position: string | number, isPercentage = true) => { + const renderMarker = (color: string, position: string | number, _isPercentage = true) => { const positionStyle = typeof position === 'string' ? { left: position } : { left: `${position}%` } return (
{ diff --git a/src/components/Global/ValidatedInput/index.tsx b/src/components/Global/ValidatedInput/index.tsx index e52c951532..bf79afc384 100644 --- a/src/components/Global/ValidatedInput/index.tsx +++ b/src/components/Global/ValidatedInput/index.tsx @@ -59,7 +59,7 @@ const ValidatedInput = ({ } = useClipboardSuggestion(smartPasteKind ?? 'recipient', value, !!smartPasteKind) const previousValueRef = useRef(value) const currentValueRef = useRef(value) - const listId = useRef(`datalist-${Math.random().toString(36).substr(2, 9)}`) + const _listId = useRef(`datalist-${Math.random().toString(36).substr(2, 9)}`) const inputRef = useRef(null) useEffect(() => { diff --git a/src/components/Kyc/KycVerificationInProgressModal.tsx b/src/components/Kyc/KycVerificationInProgressModal.tsx index dd71115b38..a7838ec583 100644 --- a/src/components/Kyc/KycVerificationInProgressModal.tsx +++ b/src/components/Kyc/KycVerificationInProgressModal.tsx @@ -24,7 +24,6 @@ export const KycVerificationInProgressModal = ({ onClose, phase = 'verifying', onAcceptTerms, - onSkipTerms, onContinue, tosError, isLoadingTos, diff --git a/src/components/LandingPage/CurrencySelect.tsx b/src/components/LandingPage/CurrencySelect.tsx index 9cf2658c9f..f0f5a57f09 100644 --- a/src/components/LandingPage/CurrencySelect.tsx +++ b/src/components/LandingPage/CurrencySelect.tsx @@ -143,7 +143,6 @@ interface CurrencyBoxProps { const CurrencyBox = ({ selected, countryCode, - country, currency, currencyName, comingSoon = false, diff --git a/src/components/Profile/components/ProfileHeader.tsx b/src/components/Profile/components/ProfileHeader.tsx index 50a2fed5ef..276762098c 100644 --- a/src/components/Profile/components/ProfileHeader.tsx +++ b/src/components/Profile/components/ProfileHeader.tsx @@ -1,7 +1,7 @@ import { Button } from '@/components/0_Bruddle/Button' import { BASE_URL } from '@/components/Global/DirectSendQR/utils' import { Icon } from '@/components/Global/Icons/Icon' -import React, { useState } from 'react' +import React from 'react' import { twMerge } from 'tailwind-merge' import AvatarWithBadge from '../AvatarWithBadge' import { VerifiedUserLabel } from '@/components/UserHeader' @@ -33,7 +33,6 @@ const ProfileHeader: React.FC = ({ // /users/:userId). Rail-approval is unrelated. const { isVerified: selfIsIdentityVerified } = useIdentityVerification() const isAuthenticatedUserVerified = selfIsIdentityVerified && authenticatedUser?.user.username === username - const [isDrawerOpen, setIsDrawerOpen] = useState(false) const isSelfProfile = authenticatedUser?.user.username?.toLowerCase() === username.toLowerCase() const profileUrl = `${BASE_URL}/${username}` diff --git a/src/components/Request/Pay/Pay.tsx b/src/components/Request/Pay/Pay.tsx index f45a2a9881..507bed3b70 100644 --- a/src/components/Request/Pay/Pay.tsx +++ b/src/components/Request/Pay/Pay.tsx @@ -28,7 +28,7 @@ export const PayRequestLink = () => { router.push(link) } return - } catch (e) { + } catch { router.push('/404') return } diff --git a/src/components/Request/link/views/Create.request.link.view.tsx b/src/components/Request/link/views/Create.request.link.view.tsx index 09d7308373..cf7a6a50f6 100644 --- a/src/components/Request/link/views/Create.request.link.view.tsx +++ b/src/components/Request/link/views/Create.request.link.view.tsx @@ -85,7 +85,7 @@ export const CreateRequestLinkView = () => { [balance, formattedSpendableBalance] ) - const usdValue = useMemo(() => { + const _usdValue = useMemo(() => { if (!selectedTokenData?.price || !tokenValue) return '' return (parseFloat(tokenValue) * selectedTokenData.price).toString() }, [tokenValue, selectedTokenData?.price]) @@ -107,11 +107,11 @@ export const CreateRequestLinkView = () => { return harnessFallbackAddress }, [isConnected, address, harnessFallbackAddress]) - const isValidRecipient = useMemo(() => { + const _isValidRecipient = useMemo(() => { return (isConnected && !!address) || !!harnessFallbackAddress }, [isConnected, address, harnessFallbackAddress]) - const hasAttachment = useMemo(() => { + const _hasAttachment = useMemo(() => { return !!(attachmentOptions.rawFile || attachmentOptions.message) }, [attachmentOptions.rawFile, attachmentOptions.message]) diff --git a/src/components/Send/__tests__/send-states.test.tsx b/src/components/Send/__tests__/send-states.test.tsx index a336a2f494..31fcdcbd3b 100644 --- a/src/components/Send/__tests__/send-states.test.tsx +++ b/src/components/Send/__tests__/send-states.test.tsx @@ -124,7 +124,7 @@ jest.mock('@/components/0_Bruddle/Button', () => ({ jest.mock('@/components/0_Bruddle/Divider', () => ({ __esModule: true, - default: (props: any) =>
, + default: (_props: any) =>
, })) jest.mock('@/components/ActionListCard', () => ({ diff --git a/src/components/Setup/Views/JoinWaitlist.tsx b/src/components/Setup/Views/JoinWaitlist.tsx index 5c94a7e5f6..243bcfa94b 100644 --- a/src/components/Setup/Views/JoinWaitlist.tsx +++ b/src/components/Setup/Views/JoinWaitlist.tsx @@ -35,7 +35,7 @@ const JoinWaitlist = () => { const toast = useToast() const { handleNext } = useSetupFlow() const dispatch = useAppDispatch() - const { handleLoginClick, isLoggingIn } = useLogin() + const { handleLoginClick } = useLogin() const router = useRouter() const queryClient = useQueryClient() @@ -89,7 +89,7 @@ const JoinWaitlist = () => { Sentry.captureException(error, { extra: { errorCode: error.code } }) } - const onLoginClick = async () => { + const _onLoginClick = async () => { try { await handleLoginClick() } catch (e) { diff --git a/src/components/TransactionDetails/TransactionDetailsReceipt.tsx b/src/components/TransactionDetails/TransactionDetailsReceipt.tsx index 1df2353653..ef95e134ae 100644 --- a/src/components/TransactionDetails/TransactionDetailsReceipt.tsx +++ b/src/components/TransactionDetails/TransactionDetailsReceipt.tsx @@ -22,7 +22,6 @@ import { formatPoints } from '@/utils/format.utils' import { getAvatarUrl } from '@/utils/history.utils' import { formatIban, printableAddress, shortenAddress, shortenStringLong, slugify } from '@/utils/general.utils' import { maskAccountIdentifier } from '@/utils/account-mask.utils' -import { cancelOnramp } from '@/app/actions/onramp' import { captureException } from '@sentry/nextjs' import { useQueryClient } from '@tanstack/react-query' import Link from 'next/link' @@ -79,7 +78,6 @@ export const TransactionDetailsReceipt = ({ contentRef, transactionAmount, className, - isModalOpen = false, setIsModalOpen, avatarUrl, isPublic = false, @@ -128,7 +126,6 @@ export const TransactionDetailsReceipt = ({ country, rowVisibilityConfig, shouldHideBorder, - shouldHideGroupBorder, shouldShowShareReceipt, requestPotContributors, formattedTotalAmountCollected, diff --git a/src/components/TransactionDetails/__tests__/transactionTransformer.test.ts b/src/components/TransactionDetails/__tests__/transactionTransformer.test.ts index 922d8dc087..2fbade543b 100644 --- a/src/components/TransactionDetails/__tests__/transactionTransformer.test.ts +++ b/src/components/TransactionDetails/__tests__/transactionTransformer.test.ts @@ -482,6 +482,47 @@ describe('mapTransactionDataForDrawer', () => { }) }) + describe('request pot rollup status', () => { + const pot = (overrides: Partial): HistoryEntry => + baseEntry({ + userRole: EHistoryUserRole.RECIPIENT, + status: EHistoryStatus.OPEN, + amount: '1750', + isRequestLink: true, + extraData: { kind: 'P2P_REQUEST_FULFILL', provider: 'PEANUT', isRequestPotRollup: true }, + ...overrides, + }) + + it('an open pot that met its goal reads as completed', () => { + const result = mapTransactionDataForDrawer(pot({ totalAmountCollected: 1750 })).transactionDetails + expect(result.status).toBe('completed') + }) + + it('an over-funded open pot reads as completed', () => { + const result = mapTransactionDataForDrawer(pot({ totalAmountCollected: 2000 })).transactionDetails + expect(result.status).toBe('completed') + }) + + it('a partially funded open pot stays pending', () => { + const result = mapTransactionDataForDrawer(pot({ totalAmountCollected: 500 })).transactionDetails + expect(result.status).toBe('pending') + }) + + it('a goal-less pot stays pending no matter what it collected', () => { + const result = mapTransactionDataForDrawer( + pot({ amount: '0', totalAmountCollected: 500 }) + ).transactionDetails + expect(result.status).toBe('pending') + }) + + it('a closed pot still reads as closed, not completed', () => { + const result = mapTransactionDataForDrawer( + pot({ status: EHistoryStatus.CLOSED, totalAmountCollected: 1750 }) + ).transactionDetails + expect(result.status).toBe('closed') + }) + }) + describe('refund credit rows (status + sign + flag)', () => { const negativeAuth = baseEntry({ userRole: EHistoryUserRole.SENDER, diff --git a/src/components/TransactionDetails/transactionTransformer.ts b/src/components/TransactionDetails/transactionTransformer.ts index e9036bf9ef..def42a522d 100644 --- a/src/components/TransactionDetails/transactionTransformer.ts +++ b/src/components/TransactionDetails/transactionTransformer.ts @@ -192,6 +192,14 @@ function mapEntryStatusToUiStatus(entry: HistoryEntry, direction: TransactionDir } } + // A request link stays OPEN until the requester closes it, so its raw status + // never reports that the goal was met — derive that from the collected sum. + // Goal 0 is the "no goal set" pot, which can never be fully funded. + if (entry.isRequestLink && status === 'OPEN') { + const goal = Number(entry.amount ?? 0) + return goal > 0 && (entry.totalAmountCollected ?? 0) >= goal ? 'completed' : 'pending' + } + switch (status) { case 'NEW': case 'PENDING': diff --git a/src/components/UserHeader/index.tsx b/src/components/UserHeader/index.tsx index 51d0c036a5..0815c60c12 100644 --- a/src/components/UserHeader/index.tsx +++ b/src/components/UserHeader/index.tsx @@ -17,7 +17,7 @@ interface UserHeaderProps { isVerified?: boolean } -export const UserHeader = ({ username, fullName, isVerified }: UserHeaderProps) => { +export const UserHeader = ({ username, fullName }: UserHeaderProps) => { const { user } = useAuth() // respect user's showFullName preference: use fullName only if showFullName is true, otherwise use username const nameForAvatar = user?.user.showFullName && fullName ? fullName : username diff --git a/src/components/og/ReceiptCardOG.tsx b/src/components/og/ReceiptCardOG.tsx index 74505a2d7f..7975a0f5ee 100644 --- a/src/components/og/ReceiptCardOG.tsx +++ b/src/components/og/ReceiptCardOG.tsx @@ -1,10 +1,5 @@ import { type PaymentLink } from '@/interfaces' -function usernamePxWidth(name: string) { - const charPx = 0.6 * 80 // ≈48 px per glyph - return Math.round(name.length * charPx) + 40 // +40 padding -} - function formatTokenAmount(amount: string, token?: string) { return token && token.toLowerCase() !== 'usdc' ? `${amount} ${token}` : `$${amount}` } @@ -13,7 +8,6 @@ export function ReceiptCardOG({ link, iconSrc, logoSrc, - scribbleSrc, arrowSrcs, }: { link: PaymentLink & { token?: string } @@ -29,7 +23,6 @@ export function ReceiptCardOG({ }) { /* ----- palette ----- */ const pink = '#fe91e6' - const scribbleWidth = usernamePxWidth(link.username) /* ----- outer white frame ----- */ return ( diff --git a/src/context/PeanutDebug.tsx b/src/context/PeanutDebug.tsx index fb95a8b87e..7294c3865b 100644 --- a/src/context/PeanutDebug.tsx +++ b/src/context/PeanutDebug.tsx @@ -43,7 +43,7 @@ export function PeanutDebug() { // Captures the most recent BE response so callers can surface the // structured `hint`/`suggestions` fields in helpful errors instead of // generic "fetch failed" / "user not found". - let lastResponse: { status?: number; json?: any; networkError?: string } = {} + let _lastResponse: { status?: number; json?: any; networkError?: string } = {} async function call(path: string, body?: object, method: 'GET' | 'POST' = 'POST') { const url = @@ -62,10 +62,10 @@ export function PeanutDebug() { const ms = Math.round(performance.now() - start) const ok = res.ok && json?.ok !== false debugLog(`${ok ? '✓' : '✗'} ${method} ${path} (${ms}ms)`, json) - lastResponse = { status: res.status, json } + _lastResponse = { status: res.status, json } return json } catch (err) { - lastResponse = { networkError: (err as Error).message } + _lastResponse = { networkError: (err as Error).message } debugLog(`✗ ${method} ${path} — network error: ${(err as Error).message}`) throw new Error( `cheat call ${method} ${path} failed: ${(err as Error).message}.\n` + diff --git a/src/context/authContext.tsx b/src/context/authContext.tsx index 1c13495230..c0604d5800 100644 --- a/src/context/authContext.tsx +++ b/src/context/authContext.tsx @@ -61,7 +61,7 @@ const AuthContext = createContext(undefined) * adding accounts and logging out. It also provides hooks for child components to access user data and auth-related functions. */ export const AuthProvider = ({ children }: { children: ReactNode }) => { - const router = useRouter() + const _router = useRouter() const dispatch = useAppDispatch() const toast = useToast() const queryClient = useQueryClient() diff --git a/src/context/loadingStates.context.tsx b/src/context/loadingStates.context.tsx index 6329de93f0..38f4f155f5 100644 --- a/src/context/loadingStates.context.tsx +++ b/src/context/loadingStates.context.tsx @@ -4,7 +4,7 @@ import React, { createContext, useMemo, useState } from 'react' import type { LoadingStates } from '@/constants/loadingStates.consts' export const loadingStateContext = createContext({ loadingState: '' as LoadingStates, - setLoadingState: (state: LoadingStates) => {}, + setLoadingState: (_state: LoadingStates) => {}, isLoading: false as boolean, }) diff --git a/src/context/tokenSelector.context.tsx b/src/context/tokenSelector.context.tsx index c027f0eaa9..7d25d11a21 100644 --- a/src/context/tokenSelector.context.tsx +++ b/src/context/tokenSelector.context.tsx @@ -1,14 +1,7 @@ 'use client' import React, { createContext, useState, useCallback, useEffect, useMemo } from 'react' -import { - PEANUT_WALLET_CHAIN, - PEANUT_WALLET_TOKEN, - PEANUT_WALLET_TOKEN_DECIMALS, - PEANUT_WALLET_TOKEN_IMG_URL, - PEANUT_WALLET_TOKEN_NAME, - PEANUT_WALLET_TOKEN_SYMBOL, -} from '@/constants/zerodev.consts' +import { PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN } from '@/constants/zerodev.consts' import { useWallet } from '@/hooks/wallet/useWallet' import { useSupportedChainsAndTokens } from '@/hooks/useSupportedChainsAndTokens' import { NON_EVM_WITHDRAW_CHAINS } from '@/constants/chainRegistry.consts' @@ -23,22 +16,22 @@ export const tokenSelectorContext = createContext({ devconnectTokenAddress: '', devconnectChainId: '', devconnectRecipientAddress: '', - setDevconnectTokenAddress: (address: string) => {}, - setDevconnectChainId: (chainId: string) => {}, - setDevconnectRecipientAddress: (address: string) => {}, - setSelectedTokenAddress: (address: string) => {}, - setSelectedChainID: (chainID: string) => {}, - updateSelectedChainID: (chainID: string) => {}, + setDevconnectTokenAddress: (_address: string) => {}, + setDevconnectChainId: (_chainId: string) => {}, + setDevconnectRecipientAddress: (_address: string) => {}, + setSelectedTokenAddress: (_address: string) => {}, + setSelectedChainID: (_chainID: string) => {}, + updateSelectedChainID: (_chainID: string) => {}, refetchXchainRoute: false as boolean, - setRefetchXchainRoute: (value: boolean) => {}, + setRefetchXchainRoute: (_value: boolean) => {}, resetTokenContextProvider: () => {}, isXChain: false as boolean, - setIsXChain: (value: boolean) => {}, + setIsXChain: (_value: boolean) => {}, selectedTokenData: undefined as ITokenPriceData | null | undefined, isFetchingTokenData: false as boolean, supportedChainsAndTokens: {} as Record, selectedTokenBalance: undefined as string | undefined, - setSelectedTokenBalance: (balance: string | undefined) => {}, + setSelectedTokenBalance: (_balance: string | undefined) => {}, }) /** @@ -48,16 +41,6 @@ export const tokenSelectorContext = createContext({ export const TokenContextProvider = ({ children }: { children: React.ReactNode }) => { const { isConnected: isPeanutWallet } = useWallet() - const peanutWalletTokenData = { - price: 1, - decimals: PEANUT_WALLET_TOKEN_DECIMALS, - symbol: PEANUT_WALLET_TOKEN_SYMBOL, - name: PEANUT_WALLET_TOKEN_NAME, - address: PEANUT_WALLET_TOKEN, - chainId: PEANUT_WALLET_CHAIN.id.toString(), - logoURI: PEANUT_WALLET_TOKEN_IMG_URL, - } as ITokenPriceData - const emptyTokenData = { address: '', chainId: '', diff --git a/src/features/payments/flows/semantic-request/useSemanticRequestFlow.ts b/src/features/payments/flows/semantic-request/useSemanticRequestFlow.ts index 5729171c76..039f6bbcd4 100644 --- a/src/features/payments/flows/semantic-request/useSemanticRequestFlow.ts +++ b/src/features/payments/flows/semantic-request/useSemanticRequestFlow.ts @@ -13,7 +13,7 @@ * supports route expiry handling - auto-refreshes routes when expired */ -import { useCallback, useMemo, useEffect, useContext, useState } from 'react' +import { useCallback, useMemo, useEffect, useContext } from 'react' import { parseUnits, type Address, type Hash } from 'viem' import { useSemanticRequestFlowContext } from './SemanticRequestFlowContext' import { useChargeManager } from '@/features/payments/shared/hooks/useChargeManager' diff --git a/src/features/payments/flows/semantic-request/views/SemanticRequestConfirmView.tsx b/src/features/payments/flows/semantic-request/views/SemanticRequestConfirmView.tsx index c64f880b07..1f4a8bb261 100644 --- a/src/features/payments/flows/semantic-request/views/SemanticRequestConfirmView.tsx +++ b/src/features/payments/flows/semantic-request/views/SemanticRequestConfirmView.tsx @@ -24,7 +24,6 @@ import { useSemanticRequestFlow } from '../useSemanticRequestFlow' import { formatAmount, isStableCoin } from '@/utils/general.utils' import { useTokenChainIcons } from '@/hooks/useTokenChainIcons' import { useMemo } from 'react' -import { formatUnits } from 'viem' import { PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN, PEANUT_WALLET_TOKEN_SYMBOL } from '@/constants/zerodev.consts' import PeanutActionDetailsCard, { type PeanutActionDetailsCardRecipientType, diff --git a/src/features/payments/flows/semantic-request/views/SemanticRequestInputView.tsx b/src/features/payments/flows/semantic-request/views/SemanticRequestInputView.tsx index cf90439551..59d0a8f15f 100644 --- a/src/features/payments/flows/semantic-request/views/SemanticRequestInputView.tsx +++ b/src/features/payments/flows/semantic-request/views/SemanticRequestInputView.tsx @@ -57,7 +57,6 @@ export function SemanticRequestInputView() { selectedChainID, selectedTokenAddress, supportedChainsAndTokens, - selectedTokenData, } = useContext(tokenSelectorContext) // initialize token/chain from parsed url diff --git a/src/hooks/useCapabilities.test.ts b/src/hooks/useCapabilities.test.ts index 6ab7e2ae60..402c96c600 100644 --- a/src/hooks/useCapabilities.test.ts +++ b/src/hooks/useCapabilities.test.ts @@ -1,4 +1,4 @@ -import { act, renderHook } from '@testing-library/react' +import { renderHook } from '@testing-library/react' import { useAuth } from '@/context/authContext' import { type UserCapabilities } from '@/types/capabilities' import { useCapabilities } from './useCapabilities' diff --git a/src/hooks/useDetermineBankClaimType.ts b/src/hooks/useDetermineBankClaimType.ts index 781efc7b7b..82bea5edf1 100644 --- a/src/hooks/useDetermineBankClaimType.ts +++ b/src/hooks/useDetermineBankClaimType.ts @@ -67,7 +67,7 @@ export function useDetermineBankClaimType(senderUserId: string): { setClaimType(BankClaimType.GuestKycNeeded) } } - } catch (error) { + } catch { if (user?.user.userId) { setClaimType(BankClaimType.ReceiverKycNeeded) } else { diff --git a/src/hooks/useDetermineBankRequestType.ts b/src/hooks/useDetermineBankRequestType.ts index 5d5658e739..a4dc34b7b0 100644 --- a/src/hooks/useDetermineBankRequestType.ts +++ b/src/hooks/useDetermineBankRequestType.ts @@ -62,7 +62,7 @@ export function useDetermineBankRequestType(requesterUserId: string): { setRequestType(BankRequestType.GuestKycNeeded) } } - } catch (error) { + } catch { if (user?.user.userId) { setRequestType(BankRequestType.PayerKycNeeded) } else { diff --git a/src/hooks/useSetupFlow.ts b/src/hooks/useSetupFlow.ts index a8bdc48116..bb18887003 100644 --- a/src/hooks/useSetupFlow.ts +++ b/src/hooks/useSetupFlow.ts @@ -1,4 +1,4 @@ -import { type ScreenId, type ScreenProps } from '@/components/Setup/Setup.types' +import { type ScreenId } from '@/components/Setup/Setup.types' import { useAppDispatch, useSetupStore } from '@/redux/hooks' import { setupActions } from '@/redux/slices/setup-slice' import { useCallback } from 'react' diff --git a/src/hooks/wallet/useSpendBundle.ts b/src/hooks/wallet/useSpendBundle.ts index a7a2dbcd7d..2ae7568123 100644 --- a/src/hooks/wallet/useSpendBundle.ts +++ b/src/hooks/wallet/useSpendBundle.ts @@ -18,7 +18,6 @@ import { useRainCardOverview } from '@/hooks/useRainCardOverview' import { useGrantSessionKey } from './useGrantSessionKey' import { usdcUnitsToRainCents } from '@/utils/balance.utils' import { useModalsContextOptional } from '@/context/ModalsContext' -import { smartUsdcBalanceQueryOptions } from './useBalance' import { isDemoMode } from '@/utils/demo' import { debitDemoBalance } from '@/utils/demo-balance' import { diff --git a/src/lib/url-parser/parser.ts b/src/lib/url-parser/parser.ts index 818fc27ff8..b0889545b1 100644 --- a/src/lib/url-parser/parser.ts +++ b/src/lib/url-parser/parser.ts @@ -110,7 +110,7 @@ export async function parsePaymentURL( if (isPeanutRecipient && PEANUT_WALLET_CHAIN.id.toString() !== chainDetails.chainId) { throw new Error('Invalid chain') } - } catch (error) { + } catch { return { parsedUrl: null, error: { message: EParseUrlError.INVALID_CHAIN, recipient } } } } else if (isPeanutRecipient) { @@ -126,7 +126,7 @@ export async function parsePaymentURL( if (amount) { try { parsedAmount = validateAmount(amount) - } catch (error) { + } catch { return { parsedUrl: null, error: { message: EParseUrlError.INVALID_AMOUNT, recipient } } } } diff --git a/src/services/sendLinks.ts b/src/services/sendLinks.ts index 0fca717df6..8b09bf6944 100644 --- a/src/services/sendLinks.ts +++ b/src/services/sendLinks.ts @@ -140,7 +140,7 @@ export const sendLinksApi = { throw new Error( `HTTP error! status: ${response.status}, message: ${errorJson.message || errorJson.error || errorText}` ) - } catch (e) { + } catch { // fallback to plain text error console.error('API Error Text:', errorText) throw new Error(`HTTP error! status: ${response.status}, message: ${errorText}`) diff --git a/src/utils/__mocks__/simplewebauthn-browser.ts b/src/utils/__mocks__/simplewebauthn-browser.ts index 18179aa0ef..3dea0dfe98 100644 --- a/src/utils/__mocks__/simplewebauthn-browser.ts +++ b/src/utils/__mocks__/simplewebauthn-browser.ts @@ -31,11 +31,11 @@ export const startAuthentication = jest.fn(() => export const platformAuthenticatorIsAvailable = jest.fn(() => Promise.resolve(true)) -export const base64URLStringToBuffer = jest.fn((base64URLString: string) => { +export const base64URLStringToBuffer = jest.fn((_base64URLString: string) => { return new ArrayBuffer(8) }) -export const bufferToBase64URLString = jest.fn((buffer: ArrayBuffer) => { +export const bufferToBase64URLString = jest.fn((_buffer: ArrayBuffer) => { return 'mock-base64-string' }) diff --git a/src/utils/__tests__/capacitor.test.ts b/src/utils/__tests__/capacitor.test.ts index 8140a180c1..8b32b08d29 100644 --- a/src/utils/__tests__/capacitor.test.ts +++ b/src/utils/__tests__/capacitor.test.ts @@ -88,7 +88,6 @@ describe('capacitor utils', () => { }) describe('getPlatform', () => { - const originalNavigator = window.navigator const originalMatchMedia = window.matchMedia afterEach(() => { diff --git a/src/utils/dateGrouping.utils.ts b/src/utils/dateGrouping.utils.ts index 79273b22f6..e0682a5667 100644 --- a/src/utils/dateGrouping.utils.ts +++ b/src/utils/dateGrouping.utils.ts @@ -90,7 +90,7 @@ export function getDateGroup(date: Date, today: Date = new Date()): DateGroup { * @param today the current date (optional). * @returns a formatted date string for the header. */ -export function formatGroupHeaderDate(date: Date, group: DateGroup, today: Date = new Date()): string { +export function formatGroupHeaderDate(date: Date, group: DateGroup, _today: Date = new Date()): string { switch (group) { case DateGroup.Today: return 'today' diff --git a/src/utils/general.utils.ts b/src/utils/general.utils.ts index 8b9e69bbd8..982a9aeebf 100644 --- a/src/utils/general.utils.ts +++ b/src/utils/general.utils.ts @@ -439,8 +439,7 @@ export async function copyTextToClipboardWithFallback(text: string) { textarea.style.left = '-9999px' document.body.appendChild(textarea) textarea.select() - const successful = document.execCommand('copy') - const msg = successful ? 'successful' : 'unsuccessful' + document.execCommand('copy') document.body.removeChild(textarea) } catch (err) { Sentry.captureException(err) @@ -554,13 +553,6 @@ export const getExplorerUrl = (chainId: string) => { } } -interface TransferDetails { - id: string - timestamp: string - chain: string - details: any -} - export function formatDate(date: Date | null | undefined): string { // Receipts and timeline rows pass dates that may be missing for paths // that haven't reached that lifecycle event yet (e.g. cancelledDate on a diff --git a/src/utils/history.utils.ts b/src/utils/history.utils.ts index 6913240bc7..a49a46a7df 100644 --- a/src/utils/history.utils.ts +++ b/src/utils/history.utils.ts @@ -52,6 +52,7 @@ export enum EHistoryStatus { refunded = 'refunded', canceled = 'canceled', // from simplefi, canceled with only one l expired = 'expired', + OPEN = 'OPEN', CLOSED = 'CLOSED', } From 6e7445ab7695667e3b68382766ae1de61497ed98 Mon Sep 17 00:00:00 2001 From: oxwel77 Date: Thu, 16 Jul 2026 11:45:24 +0100 Subject: [PATCH 05/44] fix(lint): use next/link for internal links Replace raw internal-route anchors with (client-side navigation) across LandingPage components and the dev debug page. --- src/app/(mobile-ui)/dev/debug/page.tsx | 17 +++++++++-------- src/components/LandingPage/Footer.tsx | 4 ++-- src/components/LandingPage/Manteca.tsx | 17 +++++++++-------- src/components/LandingPage/SendInSecondsCTA.tsx | 5 +++-- src/components/LandingPage/StickyMobileCTA.tsx | 5 +++-- src/components/LandingPage/hero.tsx | 13 +++++++------ src/components/LandingPage/securityBuiltIn.tsx | 5 +++-- 7 files changed, 36 insertions(+), 30 deletions(-) diff --git a/src/app/(mobile-ui)/dev/debug/page.tsx b/src/app/(mobile-ui)/dev/debug/page.tsx index 4bdbb8b2d7..b1acb5c1fe 100644 --- a/src/app/(mobile-ui)/dev/debug/page.tsx +++ b/src/app/(mobile-ui)/dev/debug/page.tsx @@ -17,6 +17,7 @@ */ import { useEffect, useState, useCallback, useRef } from 'react' +import Link from 'next/link' import NavHeader from '@/components/Global/NavHeader' import { Button } from '@/components/0_Bruddle/Button' import { useAuth } from '@/context/authContext' @@ -122,9 +123,9 @@ export default function DebugPage() {

Not signed in. Sign up via{' '} - + /setup - {' '} + {' '} first, then come back.

@@ -537,21 +538,21 @@ export default function DebugPage() {

Shortcuts

- + /home - {' '} + {' '} — check balance + activity
- + /history - {' '} + {' '} — full activity feed
- + /add-money - {' '} + {' '} — Bridge onramp instructions (after KYC)
diff --git a/src/components/LandingPage/Footer.tsx b/src/components/LandingPage/Footer.tsx index bc6630e888..145108746b 100644 --- a/src/components/LandingPage/Footer.tsx +++ b/src/components/LandingPage/Footer.tsx @@ -60,9 +60,9 @@ const Footer = ({ showSiteDirectory = true, locale = 'en' }: { showSiteDirectory
- + Support - + Content diff --git a/src/components/LandingPage/Manteca.tsx b/src/components/LandingPage/Manteca.tsx index 855e8eb3a6..6794e78052 100644 --- a/src/components/LandingPage/Manteca.tsx +++ b/src/components/LandingPage/Manteca.tsx @@ -1,5 +1,6 @@ import mantecaIphone from '@/assets/iphone-ss/manteca_ss.webp' import Image from 'next/image' +import Link from 'next/link' import { MEPA_ARGENTINA_LOGO, PIX_BRZ_LOGO, Star } from '@/assets' import { CloudsCss } from './CloudsCss' import { AnimateOnView } from '@/components/Global/AnimateOnView' @@ -56,25 +57,25 @@ const Manteca = () => { Mercado pago payment
{/* Desktop layout */}
- + Mepa Argentina - + Mercado pago payment - + Pix Brazil - +

diff --git a/src/components/LandingPage/SendInSecondsCTA.tsx b/src/components/LandingPage/SendInSecondsCTA.tsx index 65be1b83dc..3167dbc505 100644 --- a/src/components/LandingPage/SendInSecondsCTA.tsx +++ b/src/components/LandingPage/SendInSecondsCTA.tsx @@ -1,6 +1,7 @@ 'use client' import { motion } from 'framer-motion' +import Link from 'next/link' import { Button } from '@/components/0_Bruddle/Button' export function SendInSecondsCTA() { @@ -20,14 +21,14 @@ export function SendInSecondsCTA() { whileHover={{ translateY: 6, translateX: 0, rotate: 0.75 }} transition={{ type: 'spring', damping: 15 }} > - + - +

) diff --git a/src/components/LandingPage/StickyMobileCTA.tsx b/src/components/LandingPage/StickyMobileCTA.tsx index 884b5e80c5..3e8a87181d 100644 --- a/src/components/LandingPage/StickyMobileCTA.tsx +++ b/src/components/LandingPage/StickyMobileCTA.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react' import { motion, AnimatePresence } from 'framer-motion' +import Link from 'next/link' import { Button } from '@/components/0_Bruddle/Button' export function StickyMobileCTA() { @@ -42,11 +43,11 @@ export function StickyMobileCTA() { transition={{ type: 'spring', damping: 20, stiffness: 300 }} className="pointer-events-none fixed bottom-0 left-0 right-0 z-50 border-t-2 border-n-1 bg-white px-4 py-3 md:hidden" > - + - + )} diff --git a/src/components/LandingPage/hero.tsx b/src/components/LandingPage/hero.tsx index 6c41eae7b0..60a04c06e6 100644 --- a/src/components/LandingPage/hero.tsx +++ b/src/components/LandingPage/hero.tsx @@ -3,6 +3,7 @@ import { PeanutWhistling } from '@/assets/mascot' import { GlobalCashLocalFeel, Star } from '@/assets/illustrations' import { motion } from 'framer-motion' +import Link from 'next/link' import { useEffect, useCallback, useRef } from 'react' import { Button } from '@/components/0_Bruddle/Button' import { CloudsCss } from './CloudsCss' @@ -179,17 +180,17 @@ export function Hero({ primaryCta, secondaryCta, buttonVisible, buttonScale = 1 className="mt-2 block text-center text-xl leading-tight text-n-1 md:mt-4 md:text-5xl" style={{ fontWeight: 500, letterSpacing: '-0.5px' }} > - + Buenos Aires - + .{' '} - + São Paulo - + .{' '} - + Floripa - + . diff --git a/src/components/LandingPage/securityBuiltIn.tsx b/src/components/LandingPage/securityBuiltIn.tsx index 1748684479..4d7276287d 100644 --- a/src/components/LandingPage/securityBuiltIn.tsx +++ b/src/components/LandingPage/securityBuiltIn.tsx @@ -1,4 +1,5 @@ import Image from 'next/image' +import Link from 'next/link' import { Button } from '@/components/0_Bruddle/Button' import handThumbsUp from '@/assets/illustrations/hand-thumbs-up.svg' import handWaving from '@/assets/illustrations/hand-waving.svg' @@ -98,14 +99,14 @@ export function SecurityBuiltIn() { )} {feature.id === 3 && ( )}
From 16499276eb853339d4bcfc187481d208ee938feb Mon Sep 17 00:00:00 2001 From: oxwel77 Date: Thu, 16 Jul 2026 12:16:36 +0100 Subject: [PATCH 06/44] feat(i18n): client-side next-intl foundation for the product UI - src/i18n/app: locale config + resolveLocale normalizer, catalogs (en/es-419/pt-BR), deep-merge loader so missing keys render English, runtime locale store (Preferences/Device on native, cookie on web), AppIntlProvider with hydration-safe English-first state - provider wired into ClientProviders; native splash gated on the startup locale being painted (2s timeout guard) - loadingStates union converted to a const array + key mapping - jest: transform ESM-only intl packages (pnpm-aware ignore pattern) Marketing i18n (src/i18n/*.json) untouched. --- package.json | 9 +- src/app/ClientProviders.tsx | 37 ++++---- src/constants/loadingStates.consts.ts | 63 +++++++------- src/hooks/useNativePlugins.ts | 5 ++ src/i18n/app/AppIntlProvider.tsx | 84 +++++++++++++++++++ src/i18n/app/__tests__/loading-states.test.ts | 21 +++++ src/i18n/app/__tests__/messages.test.ts | 63 ++++++++++++++ src/i18n/app/__tests__/resolve-locale.test.ts | 35 ++++++++ src/i18n/app/config.ts | 31 +++++++ src/i18n/app/loading-states.ts | 43 ++++++++++ src/i18n/app/locale-store.ts | 79 +++++++++++++++++ src/i18n/app/messages.ts | 34 ++++++++ src/i18n/app/messages/en.json | 54 ++++++++++++ src/i18n/app/messages/es-419.json | 54 ++++++++++++ src/i18n/app/messages/pt-BR.json | 54 ++++++++++++ src/i18n/app/next-intl.d.ts | 9 ++ tsconfig.test.json | 3 +- 17 files changed, 629 insertions(+), 49 deletions(-) create mode 100644 src/i18n/app/AppIntlProvider.tsx create mode 100644 src/i18n/app/__tests__/loading-states.test.ts create mode 100644 src/i18n/app/__tests__/messages.test.ts create mode 100644 src/i18n/app/__tests__/resolve-locale.test.ts create mode 100644 src/i18n/app/config.ts create mode 100644 src/i18n/app/loading-states.ts create mode 100644 src/i18n/app/locale-store.ts create mode 100644 src/i18n/app/messages.ts create mode 100644 src/i18n/app/messages/en.json create mode 100644 src/i18n/app/messages/es-419.json create mode 100644 src/i18n/app/messages/pt-BR.json create mode 100644 src/i18n/app/next-intl.d.ts diff --git a/package.json b/package.json index f7dec11ce4..cd039bfc97 100644 --- a/package.json +++ b/package.json @@ -181,10 +181,17 @@ "tsconfig": "tsconfig.test.json", "useESM": true } + ], + "^.+\\.(js|mjs)$": [ + "ts-jest", + { + "tsconfig": "tsconfig.test.json", + "isolatedModules": true + } ] }, "transformIgnorePatterns": [ - "node_modules/(?!(@wagmi|wagmi|viem|@viem|@walletconnect|@justaname\\.id|@zerodev|permissionless)/)" + "node_modules/(?!(\\.pnpm/)?(next-intl|use-intl|@formatjs|intl-messageformat)([@+/]|$))" ], "moduleNameMapper": { "\\.(svg|png|jpg|jpeg|gif|webp)$": "/src/utils/__mocks__/static-image.ts", diff --git a/src/app/ClientProviders.tsx b/src/app/ClientProviders.tsx index e1892bdc37..fa45ea019e 100644 --- a/src/app/ClientProviders.tsx +++ b/src/app/ClientProviders.tsx @@ -12,6 +12,7 @@ import StaleCardApprovalReEnableModal from '@/components/Global/StaleCardApprova import BadgeEarnToast from '@/components/Badges/BadgeEarnToast' import { ScreenOrientationLocker } from '@/components/Global/ScreenOrientationLocker' import { TranslationSafeWrapper } from '@/components/Global/TranslationSafeWrapper' +import { AppIntlProvider } from '@/i18n/app/AppIntlProvider' import { PeanutProvider } from '@/config/peanut.config' import { ContextProvider } from '@/context/contextProvider' import { FooterVisibilityProvider } from '@/context/footerVisibility' @@ -39,29 +40,31 @@ export function ClientProviders({ children }: { children: React.ReactNode }) { - - - - - {/* Mounted here (not in a route-group layout) so the cooldown + + + + + + {/* Mounted here (not in a route-group layout) so the cooldown explainer also covers public pay/send/request pages — the rain:cooldown event fires on every spend path. */} - - {/* Global recovery prompt: a withdraw refused with 409 + + {/* Global recovery prompt: a withdraw refused with 409 STALE_CARD_APPROVAL (stale session-key approval) fires RAIN_STALE_APPROVAL_EVENT — mount here so the re-enable CTA covers every spend path, not just the card screen. */} - - {/* Non-intrusive "badge unlocked" toast on /home (TASK-19791). + + {/* Non-intrusive "badge unlocked" toast on /home (TASK-19791). Global so it surfaces wherever the user lands after earning. */} - - {HarnessBootstrap && ( - - - - )} - {children} - + + {HarnessBootstrap && ( + + + + )} + {children} + + diff --git a/src/constants/loadingStates.consts.ts b/src/constants/loadingStates.consts.ts index e0cd66b686..eef12a7092 100644 --- a/src/constants/loadingStates.consts.ts +++ b/src/constants/loadingStates.consts.ts @@ -1,30 +1,33 @@ -export type LoadingStates = - | 'Idle' - | 'Loading' - | 'Preparing transaction' - | 'Approve transaction' - | 'Sign in wallet' - | 'Executing transaction' - | 'Allow network switch' - | 'Creating link' - | 'Switching network' - | 'Fetching route' - | 'Fetching details' - | 'Awaiting route fulfillment' - | 'Asserting values' - | 'Generating details' - | 'Estimating points' - | 'Getting deposit details' - | 'Getting KYC status' - | 'Awaiting TOS confirmation' - | 'Awaiting KYC confirmation' - | 'Linking IBAN' - | 'Linking account' - | 'Submitting Offramp' - | 'Getting profile' - | 'Registering' - | 'Requesting' - | 'Logging in' - | 'Logging out' - | 'Paying' - | 'Withdrawing' +export const LOADING_STATES = [ + 'Idle', + 'Loading', + 'Preparing transaction', + 'Approve transaction', + 'Sign in wallet', + 'Executing transaction', + 'Allow network switch', + 'Creating link', + 'Switching network', + 'Fetching route', + 'Fetching details', + 'Awaiting route fulfillment', + 'Asserting values', + 'Generating details', + 'Estimating points', + 'Getting deposit details', + 'Getting KYC status', + 'Awaiting TOS confirmation', + 'Awaiting KYC confirmation', + 'Linking IBAN', + 'Linking account', + 'Submitting Offramp', + 'Getting profile', + 'Registering', + 'Requesting', + 'Logging in', + 'Logging out', + 'Paying', + 'Withdrawing', +] as const + +export type LoadingStates = (typeof LOADING_STATES)[number] diff --git a/src/hooks/useNativePlugins.ts b/src/hooks/useNativePlugins.ts index 557c94f92d..3aabf3ef12 100644 --- a/src/hooks/useNativePlugins.ts +++ b/src/hooks/useNativePlugins.ts @@ -3,6 +3,7 @@ import { useEffect } from 'react' import { useRouter } from 'next/navigation' import { isCapacitor, getPlatform } from '@/utils/capacitor' +import { localeApplied } from '@/i18n/app/locale-store' import { deepLinkToNativePath } from '@/utils/native-routes' import { sanitizeRedirectURL } from '@/utils/general.utils' @@ -60,6 +61,10 @@ export function useNativePlugins() { } try { + // hold the splash until the startup locale has painted, so + // es/pt users never see an English flash. The timeout guard + // means an i18n bug can never keep the splash up. + await Promise.race([localeApplied(), new Promise((resolve) => setTimeout(resolve, 2000))]) const { SplashScreen } = await import('@capacitor/splash-screen') await SplashScreen.hide() } catch (e) { diff --git a/src/i18n/app/AppIntlProvider.tsx b/src/i18n/app/AppIntlProvider.tsx new file mode 100644 index 0000000000..8e440b9e02 --- /dev/null +++ b/src/i18n/app/AppIntlProvider.tsx @@ -0,0 +1,84 @@ +'use client' + +import { NextIntlClientProvider, IntlErrorCode, type IntlError } from 'next-intl' +import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react' +import { DEFAULT_APP_LOCALE, type AppLocale } from './config' +import { loadMessages, type AppMessages } from './messages' +import { localeReady, markLocaleApplied, persistLocale } from './locale-store' +import en from './messages/en.json' + +interface AppLocaleContextValue { + locale: AppLocale + setLocale: (locale: AppLocale) => Promise +} + +const AppLocaleContext = createContext(null) + +export function useAppLocale(): AppLocaleContextValue { + const ctx = useContext(AppLocaleContext) + if (!ctx) throw new Error('useAppLocale must be used within AppIntlProvider') + return ctx +} + +function onIntlError(error: IntlError): void { + if (error.code === IntlErrorCode.MISSING_MESSAGE) { + // unreachable for valid keys (catalogs are deep-merged over English); + // never crash on copy in production + if (process.env.NODE_ENV !== 'production') console.warn(error.message) + return + } + console.error(error) +} + +export function AppIntlProvider({ children }: { children: React.ReactNode }) { + /* SSR and the first client render must both use English so the hydration + passes match; the real locale is resolved and swapped in an effect. */ + const [{ locale, messages }, setIntlState] = useState<{ locale: AppLocale; messages: AppMessages }>({ + locale: DEFAULT_APP_LOCALE, + messages: en, + }) + const startupLocale = useRef(null) + + useEffect(() => { + let cancelled = false + localeReady().then(async (resolved) => { + startupLocale.current = resolved + if (resolved === DEFAULT_APP_LOCALE) { + // already rendered in English — nothing to swap + markLocaleApplied() + return + } + if (cancelled) return + const loaded = await loadMessages(resolved) + if (!cancelled) setIntlState({ locale: resolved, messages: loaded }) + }) + return () => { + cancelled = true + } + }, []) + + useEffect(() => { + document.documentElement.lang = locale + // signal "startup locale is painted" — the native splash gates on this + if (locale === startupLocale.current) markLocaleApplied() + }, [locale]) + + const setLocale = useCallback(async (next: AppLocale) => { + persistLocale(next) + const loaded = await loadMessages(next) + setIntlState({ locale: next, messages: loaded }) + }, []) + + return ( + + + {children} + + + ) +} diff --git a/src/i18n/app/__tests__/loading-states.test.ts b/src/i18n/app/__tests__/loading-states.test.ts new file mode 100644 index 0000000000..e1bd3dfc43 --- /dev/null +++ b/src/i18n/app/__tests__/loading-states.test.ts @@ -0,0 +1,21 @@ +import { LOADING_STATES } from '@/constants/loadingStates.consts' +import { loadingStateKey } from '../loading-states' +import en from '../messages/en.json' + +describe('loadingStateKey', () => { + it('maps every loading state to an existing catalog key', () => { + for (const state of LOADING_STATES) { + const key = loadingStateKey(state) + expect(en.loadingStates[key]).toBeTruthy() + } + }) + + it('keeps English catalog copy in sync with the state literals', () => { + // the union literals double as the English copy at setLoadingState() + // call sites; 'Idle'/'Loading' aside, drift would show users different + // text pre- and post-extraction + for (const state of LOADING_STATES) { + expect(en.loadingStates[loadingStateKey(state)]).toBe(state) + } + }) +}) diff --git a/src/i18n/app/__tests__/messages.test.ts b/src/i18n/app/__tests__/messages.test.ts new file mode 100644 index 0000000000..ced42b7c5c --- /dev/null +++ b/src/i18n/app/__tests__/messages.test.ts @@ -0,0 +1,63 @@ +import { createTranslator } from 'next-intl' +import { APP_LOCALES } from '../config' +import { deepMerge, loadMessages } from '../messages' +import en from '../messages/en.json' +import es419 from '../messages/es-419.json' +import ptBR from '../messages/pt-BR.json' + +const CATALOGS = { en, 'es-419': es419, 'pt-BR': ptBR } as const + +function leafPaths(obj: Record, prefix = ''): string[] { + return Object.entries(obj).flatMap(([key, value]) => { + const path = prefix ? `${prefix}.${key}` : key + return typeof value === 'object' && value !== null ? leafPaths(value as Record, path) : [path] + }) +} + +describe('deepMerge fallback', () => { + it('fills keys missing from a locale catalog with English', async () => { + const partial = { common: { cancel: 'Cancelar' } } + const merged = deepMerge(en, partial) + expect(merged.common.cancel).toBe('Cancelar') + expect(merged.common.continue).toBe(en.common.continue) + expect(merged.loadingStates).toEqual(en.loadingStates) + }) + + it('loadMessages returns complete catalogs for every locale', async () => { + const enPaths = leafPaths(en).sort() + for (const locale of APP_LOCALES) { + const messages = await loadMessages(locale) + expect(leafPaths(messages as unknown as Record).sort()).toEqual(enPaths) + } + }) +}) + +describe('catalog key parity', () => { + const enPaths = leafPaths(en).sort() + it.each(APP_LOCALES)('%s has exactly the en key set', (locale) => { + expect(leafPaths(CATALOGS[locale]).sort()).toEqual(enPaths) + }) +}) + +describe('ICU message compilation', () => { + it.each(APP_LOCALES)('every %s message compiles and formats', (locale) => { + const messages = CATALOGS[locale] + const invalid: string[] = [] + const t = createTranslator({ + locale, + messages, + onError: (error) => { + // formatting errors from our dummy values are fine; a message + // that fails to PARSE (e.g. unescaped ICU apostrophe) is not + if (error.code === 'INVALID_MESSAGE') invalid.push(error.message) + }, + getMessageFallback: ({ key }) => key, + }) + const dummy = { count: 2, amount: '10', name: 'Ana', username: 'ana', value: '1', date: new Date(0), days: 3 } + for (const path of leafPaths(messages)) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + t(path as any, dummy) + } + expect(invalid).toEqual([]) + }) +}) diff --git a/src/i18n/app/__tests__/resolve-locale.test.ts b/src/i18n/app/__tests__/resolve-locale.test.ts new file mode 100644 index 0000000000..f1037b9718 --- /dev/null +++ b/src/i18n/app/__tests__/resolve-locale.test.ts @@ -0,0 +1,35 @@ +import { resolveLocale, APP_LOCALES, LOCALE_LABELS } from '../config' + +describe('resolveLocale', () => { + it.each([ + ['en', 'en'], + ['en-US', 'en'], + ['en-GB', 'en'], + ['es-419', 'es-419'], + ['es-AR', 'es-419'], + ['es-MX', 'es-419'], + ['es-ES', 'es-419'], + ['es', 'es-419'], + ['pt-BR', 'pt-BR'], + ['pt-PT', 'pt-BR'], + ['pt', 'pt-BR'], + ['ES-419', 'es-419'], + ['PT-br', 'pt-BR'], + ] as const)('%s → %s', (input, expected) => { + expect(resolveLocale(input)).toBe(expected) + }) + + it.each([null, undefined, '', ' ', 'fr-FR', 'de', 'zh-Hans-CN', 'garbage', 'espresso'])( + 'unsupported %p falls back to en', + (input) => { + expect(resolveLocale(input)).toBe('en') + } + ) + + it('every supported locale resolves to itself and has a label', () => { + for (const locale of APP_LOCALES) { + expect(resolveLocale(locale)).toBe(locale) + expect(LOCALE_LABELS[locale]).toBeTruthy() + } + }) +}) diff --git a/src/i18n/app/config.ts b/src/i18n/app/config.ts new file mode 100644 index 0000000000..f6c3371ea3 --- /dev/null +++ b/src/i18n/app/config.ts @@ -0,0 +1,31 @@ +// App (product UI) locales — separate from the marketing site's i18n +// (src/i18n/*.json), which keeps its own locale set for SEO routing. +export const APP_LOCALES = ['en', 'es-419', 'pt-BR'] as const + +export type AppLocale = (typeof APP_LOCALES)[number] + +export const DEFAULT_APP_LOCALE: AppLocale = 'en' + +/** Native-language display names for the language picker. */ +export const LOCALE_LABELS: Record = { + en: 'English', + 'es-419': 'Español', + 'pt-BR': 'Português (Brasil)', +} + +/** + * Normalizes any BCP 47-ish tag (device language, cookie, navigator.language) + * to a supported app locale. Every locale source must pass through here so an + * unsupported tag can never reach the intl provider. + */ +export function resolveLocale(raw: string | null | undefined): AppLocale { + if (!raw) return DEFAULT_APP_LOCALE + const tag = raw.trim().toLowerCase() + if (!tag) return DEFAULT_APP_LOCALE + const exact = APP_LOCALES.find((locale) => locale.toLowerCase() === tag) + if (exact) return exact + const language = tag.split('-')[0] + if (language === 'es') return 'es-419' + if (language === 'pt') return 'pt-BR' + return DEFAULT_APP_LOCALE +} diff --git a/src/i18n/app/loading-states.ts b/src/i18n/app/loading-states.ts new file mode 100644 index 0000000000..626623fe7d --- /dev/null +++ b/src/i18n/app/loading-states.ts @@ -0,0 +1,43 @@ +import type { LoadingStates } from '@/constants/loadingStates.consts' +import type en from './messages/en.json' + +export type LoadingStateKey = keyof typeof en.loadingStates + +/* setLoadingState() call sites keep passing the English literals (they double + as the LoadingStates union); only render sites translate, via + useTranslations('loadingStates') + loadingStateKey(state). */ +const KEY_BY_STATE: Record = { + Idle: 'idle', + Loading: 'loading', + 'Preparing transaction': 'preparingTransaction', + 'Approve transaction': 'approveTransaction', + 'Sign in wallet': 'signInWallet', + 'Executing transaction': 'executingTransaction', + 'Allow network switch': 'allowNetworkSwitch', + 'Creating link': 'creatingLink', + 'Switching network': 'switchingNetwork', + 'Fetching route': 'fetchingRoute', + 'Fetching details': 'fetchingDetails', + 'Awaiting route fulfillment': 'awaitingRouteFulfillment', + 'Asserting values': 'assertingValues', + 'Generating details': 'generatingDetails', + 'Estimating points': 'estimatingPoints', + 'Getting deposit details': 'gettingDepositDetails', + 'Getting KYC status': 'gettingKycStatus', + 'Awaiting TOS confirmation': 'awaitingTosConfirmation', + 'Awaiting KYC confirmation': 'awaitingKycConfirmation', + 'Linking IBAN': 'linkingIban', + 'Linking account': 'linkingAccount', + 'Submitting Offramp': 'submittingOfframp', + 'Getting profile': 'gettingProfile', + Registering: 'registering', + Requesting: 'requesting', + 'Logging in': 'loggingIn', + 'Logging out': 'loggingOut', + Paying: 'paying', + Withdrawing: 'withdrawing', +} + +export function loadingStateKey(state: LoadingStates): LoadingStateKey { + return KEY_BY_STATE[state] +} diff --git a/src/i18n/app/locale-store.ts b/src/i18n/app/locale-store.ts new file mode 100644 index 0000000000..e986136091 --- /dev/null +++ b/src/i18n/app/locale-store.ts @@ -0,0 +1,79 @@ +// Runtime locale resolution, mirroring the auth-token.ts pattern: +// web: cookie (+ localStorage mirror); capacitor: native Preferences with the +// device language as default. Every raw tag passes through resolveLocale so +// an unsupported value can never leak out. + +import Cookies from 'js-cookie' +import { isCapacitor } from '@/utils/capacitor' +import { resolveLocale, type AppLocale } from './config' + +const LOCALE_KEY = 'app-locale' + +let resolution: Promise | null = null + +function navigatorLocale(): AppLocale { + return resolveLocale(typeof navigator !== 'undefined' ? navigator.language : null) +} + +async function resolveStartupLocale(): Promise { + if (isCapacitor()) { + try { + const { Preferences } = await import('@capacitor/preferences') + const { value } = await Preferences.get({ key: LOCALE_KEY }) + if (value) return resolveLocale(value) + } catch { + // plugin unavailable — fall through to device language + } + try { + const { Device } = await import('@capacitor/device') + const { value } = await Device.getLanguageTag() + return resolveLocale(value) + } catch { + // older binary running OTA'd JS without @capacitor/device + return navigatorLocale() + } + } + const stored = + Cookies.get(LOCALE_KEY) ?? (typeof localStorage !== 'undefined' ? localStorage.getItem(LOCALE_KEY) : null) + if (stored) return resolveLocale(stored) + return navigatorLocale() +} + +/** Resolves the startup locale once; memoized for the session. */ +export function localeReady(): Promise { + if (!resolution) resolution = resolveStartupLocale() + return resolution +} + +export function persistLocale(locale: AppLocale): void { + if (isCapacitor()) { + import('@capacitor/preferences') + .then(({ Preferences }) => Preferences.set({ key: LOCALE_KEY, value: locale })) + .catch(() => {}) + return + } + Cookies.set(LOCALE_KEY, locale, { expires: 365, path: '/' }) + try { + localStorage.setItem(LOCALE_KEY, locale) + } catch { + // storage may be unavailable (private mode); cookie is authoritative + } +} + +let markApplied: (() => void) | null = null +const applied = new Promise((resolve) => { + markApplied = resolve +}) + +/** + * Resolves once the startup locale has been rendered by AppIntlProvider. + * The native splash screen awaits this (with a timeout guard) so users never + * see an English flash before their locale applies. + */ +export function localeApplied(): Promise { + return applied +} + +export function markLocaleApplied(): void { + markApplied?.() +} diff --git a/src/i18n/app/messages.ts b/src/i18n/app/messages.ts new file mode 100644 index 0000000000..3cc291bd38 --- /dev/null +++ b/src/i18n/app/messages.ts @@ -0,0 +1,34 @@ +import type { AppLocale } from './config' +import en from './messages/en.json' + +export type AppMessages = typeof en + +type DeepPartial = { [K in keyof T]?: T[K] extends object ? DeepPartial : T[K] } + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** + * Overlays a (possibly incomplete) locale catalog onto the English one so a + * missing key always renders English copy, never a raw key path. + */ +export function deepMerge(base: T, override: DeepPartial): T { + if (!isRecord(base) || !isRecord(override)) return (override ?? base) as T + const result: Record = { ...base } + for (const [key, value] of Object.entries(override)) { + if (value === undefined || value === null) continue + const baseValue = result[key] + result[key] = isRecord(baseValue) && isRecord(value) ? deepMerge(baseValue, value) : value + } + return result as T +} + +export async function loadMessages(locale: AppLocale): Promise { + if (locale === 'en') return en + const overrides = + locale === 'es-419' + ? (await import('./messages/es-419.json')).default + : (await import('./messages/pt-BR.json')).default + return deepMerge(en, overrides as DeepPartial) +} diff --git a/src/i18n/app/messages/en.json b/src/i18n/app/messages/en.json new file mode 100644 index 0000000000..78b7f7ea9e --- /dev/null +++ b/src/i18n/app/messages/en.json @@ -0,0 +1,54 @@ +{ + "common": { + "cancel": "Cancel", + "continue": "Continue", + "back": "Back", + "done": "Done", + "save": "Save", + "confirm": "Confirm", + "retry": "Retry", + "close": "Close", + "loading": "Loading...", + "somethingWentWrong": "Something went wrong", + "copiedToClipboard": "Copied to clipboard" + }, + "profile": { + "language": "Language" + }, + "settings": { + "language": { + "title": "Language" + } + }, + "loadingStates": { + "idle": "Idle", + "loading": "Loading", + "preparingTransaction": "Preparing transaction", + "approveTransaction": "Approve transaction", + "signInWallet": "Sign in wallet", + "executingTransaction": "Executing transaction", + "allowNetworkSwitch": "Allow network switch", + "creatingLink": "Creating link", + "switchingNetwork": "Switching network", + "fetchingRoute": "Fetching route", + "fetchingDetails": "Fetching details", + "awaitingRouteFulfillment": "Awaiting route fulfillment", + "assertingValues": "Asserting values", + "generatingDetails": "Generating details", + "estimatingPoints": "Estimating points", + "gettingDepositDetails": "Getting deposit details", + "gettingKycStatus": "Getting KYC status", + "awaitingTosConfirmation": "Awaiting TOS confirmation", + "awaitingKycConfirmation": "Awaiting KYC confirmation", + "linkingIban": "Linking IBAN", + "linkingAccount": "Linking account", + "submittingOfframp": "Submitting Offramp", + "gettingProfile": "Getting profile", + "registering": "Registering", + "requesting": "Requesting", + "loggingIn": "Logging in", + "loggingOut": "Logging out", + "paying": "Paying", + "withdrawing": "Withdrawing" + } +} diff --git a/src/i18n/app/messages/es-419.json b/src/i18n/app/messages/es-419.json new file mode 100644 index 0000000000..94609b0659 --- /dev/null +++ b/src/i18n/app/messages/es-419.json @@ -0,0 +1,54 @@ +{ + "common": { + "cancel": "Cancelar", + "continue": "Continuar", + "back": "Atrás", + "done": "Listo", + "save": "Guardar", + "confirm": "Confirmar", + "retry": "Reintentar", + "close": "Cerrar", + "loading": "Cargando...", + "somethingWentWrong": "Algo salió mal", + "copiedToClipboard": "Copiado al portapapeles" + }, + "profile": { + "language": "Idioma" + }, + "settings": { + "language": { + "title": "Idioma" + } + }, + "loadingStates": { + "idle": "Inactivo", + "loading": "Cargando", + "preparingTransaction": "Preparando la transacción", + "approveTransaction": "Aprueba la transacción", + "signInWallet": "Firma en tu billetera", + "executingTransaction": "Ejecutando la transacción", + "allowNetworkSwitch": "Permite el cambio de red", + "creatingLink": "Creando el enlace", + "switchingNetwork": "Cambiando de red", + "fetchingRoute": "Buscando la ruta", + "fetchingDetails": "Obteniendo los detalles", + "awaitingRouteFulfillment": "Esperando la ejecución de la ruta", + "assertingValues": "Verificando los valores", + "generatingDetails": "Generando los detalles", + "estimatingPoints": "Estimando los puntos", + "gettingDepositDetails": "Obteniendo los datos del depósito", + "gettingKycStatus": "Consultando el estado de KYC", + "awaitingTosConfirmation": "Esperando la aceptación de los términos", + "awaitingKycConfirmation": "Esperando la confirmación de KYC", + "linkingIban": "Vinculando el IBAN", + "linkingAccount": "Vinculando la cuenta", + "submittingOfframp": "Enviando el retiro", + "gettingProfile": "Obteniendo el perfil", + "registering": "Registrando", + "requesting": "Solicitando", + "loggingIn": "Iniciando sesión", + "loggingOut": "Cerrando sesión", + "paying": "Pagando", + "withdrawing": "Retirando" + } +} diff --git a/src/i18n/app/messages/pt-BR.json b/src/i18n/app/messages/pt-BR.json new file mode 100644 index 0000000000..88785118e3 --- /dev/null +++ b/src/i18n/app/messages/pt-BR.json @@ -0,0 +1,54 @@ +{ + "common": { + "cancel": "Cancelar", + "continue": "Continuar", + "back": "Voltar", + "done": "Concluído", + "save": "Salvar", + "confirm": "Confirmar", + "retry": "Tentar novamente", + "close": "Fechar", + "loading": "Carregando...", + "somethingWentWrong": "Algo deu errado", + "copiedToClipboard": "Copiado para a área de transferência" + }, + "profile": { + "language": "Idioma" + }, + "settings": { + "language": { + "title": "Idioma" + } + }, + "loadingStates": { + "idle": "Inativo", + "loading": "Carregando", + "preparingTransaction": "Preparando a transação", + "approveTransaction": "Aprove a transação", + "signInWallet": "Assine na sua carteira", + "executingTransaction": "Executando a transação", + "allowNetworkSwitch": "Permita a troca de rede", + "creatingLink": "Criando o link", + "switchingNetwork": "Trocando de rede", + "fetchingRoute": "Buscando a rota", + "fetchingDetails": "Obtendo os detalhes", + "awaitingRouteFulfillment": "Aguardando a execução da rota", + "assertingValues": "Verificando os valores", + "generatingDetails": "Gerando os detalhes", + "estimatingPoints": "Estimando os pontos", + "gettingDepositDetails": "Obtendo os dados do depósito", + "gettingKycStatus": "Consultando o status do KYC", + "awaitingTosConfirmation": "Aguardando a aceitação dos termos", + "awaitingKycConfirmation": "Aguardando a confirmação do KYC", + "linkingIban": "Vinculando o IBAN", + "linkingAccount": "Vinculando a conta", + "submittingOfframp": "Enviando o saque", + "gettingProfile": "Obtendo o perfil", + "registering": "Registrando", + "requesting": "Solicitando", + "loggingIn": "Entrando", + "loggingOut": "Saindo", + "paying": "Pagando", + "withdrawing": "Sacando" + } +} diff --git a/src/i18n/app/next-intl.d.ts b/src/i18n/app/next-intl.d.ts new file mode 100644 index 0000000000..64a1a0112b --- /dev/null +++ b/src/i18n/app/next-intl.d.ts @@ -0,0 +1,9 @@ +import type en from './messages/en.json' +import type { AppLocale } from './config' + +declare module 'next-intl' { + interface AppConfig { + Locale: AppLocale + Messages: typeof en + } +} diff --git a/tsconfig.test.json b/tsconfig.test.json index 7a76ddf41f..51270d5aa6 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -1,7 +1,8 @@ { "extends": "./tsconfig.json", "compilerOptions": { - "jsx": "react-jsx" + "jsx": "react-jsx", + "allowJs": true }, "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.test.ts", "src/**/*.test.tsx"] } From b6fb3cc5305888d01459a5dd147dcf7a0b9ec519 Mon Sep 17 00:00:00 2001 From: oxwel77 Date: Thu, 16 Jul 2026 12:16:47 +0100 Subject: [PATCH 07/44] feat(i18n): language picker /settings/language screen with the supported locales, reached from a new Profile row showing the active language. Live re-render on switch, persisted via Preferences (native) / cookie (web). --- .../(mobile-ui)/settings/language/page.tsx | 16 ++++++ src/components/Global/Icons/Icon.tsx | 2 + .../Profile/components/ProfileMenuItem.tsx | 5 +- src/components/Profile/index.tsx | 13 +++++ src/components/Settings/LanguageView.tsx | 52 +++++++++++++++++++ 5 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 src/app/(mobile-ui)/settings/language/page.tsx create mode 100644 src/components/Settings/LanguageView.tsx diff --git a/src/app/(mobile-ui)/settings/language/page.tsx b/src/app/(mobile-ui)/settings/language/page.tsx new file mode 100644 index 0000000000..6aa97957b7 --- /dev/null +++ b/src/app/(mobile-ui)/settings/language/page.tsx @@ -0,0 +1,16 @@ +import { generateMetadata } from '@/app/metadata' +import PageContainer from '@/components/0_Bruddle/PageContainer' +import { LanguageView } from '@/components/Settings/LanguageView' + +export const metadata = generateMetadata({ + title: 'Language | Peanut', + description: 'Choose the language of your Peanut app.', +}) + +export default function LanguagePage() { + return ( + + + + ) +} diff --git a/src/components/Global/Icons/Icon.tsx b/src/components/Global/Icons/Icon.tsx index 76a413aacd..791e3c0943 100644 --- a/src/components/Global/Icons/Icon.tsx +++ b/src/components/Global/Icons/Icon.tsx @@ -152,6 +152,7 @@ export type IconName = | 'lock' | 'split' | 'globe-lock' + | 'globe' | 'bulb' | 'undo' | 'upload-cloud' @@ -289,6 +290,7 @@ const iconComponents: Record>> = lock: (props) => , split: (props) => , 'globe-lock': (props) => , + globe: (props) => , 'plus-circle': (props) => , 'minus-circle': (props) => , meter: (props) => , diff --git a/src/components/Profile/components/ProfileMenuItem.tsx b/src/components/Profile/components/ProfileMenuItem.tsx index 95880ae657..71bf9cf9c5 100644 --- a/src/components/Profile/components/ProfileMenuItem.tsx +++ b/src/components/Profile/components/ProfileMenuItem.tsx @@ -19,6 +19,7 @@ interface ProfileMenuItemProps { isExternalLink?: boolean endIcon?: IconName endIconClassName?: string + endText?: string showTooltip?: boolean toolTipText?: string badge?: string @@ -36,6 +37,7 @@ const ProfileMenuItem: React.FC = ({ isExternalLink, endIcon, endIconClassName, + endText, showTooltip = false, toolTipText, badge, @@ -67,7 +69,8 @@ const ProfileMenuItem: React.FC = ({ )}
-
+
+ {endText && {endText}} {comingSoon ? ( ) : endIcon ? ( diff --git a/src/components/Profile/index.tsx b/src/components/Profile/index.tsx index 6ec21a604f..269059fcb0 100644 --- a/src/components/Profile/index.tsx +++ b/src/components/Profile/index.tsx @@ -8,6 +8,9 @@ import ProfileHeader from './components/ProfileHeader' import ProfileMenuItem from './components/ProfileMenuItem' import { useRouter } from 'next/navigation' import { useState } from 'react' +import { useTranslations } from 'next-intl' +import { LOCALE_LABELS } from '@/i18n/app/config' +import { useAppLocale } from '@/i18n/app/AppIntlProvider' import { useIdentityVerification } from '@/hooks/useIdentityVerification' import { useSafeBack } from '@/hooks/useSafeBack' import { useCardInfo } from '@/hooks/useCardInfo' @@ -29,6 +32,8 @@ export const Profile = () => { // applicant state. Bridge/Manteca rail approval does NOT flip this badge. const { isVerified: isUserSumsubKycApproved } = useIdentityVerification() const { hasCardAccess } = useCardInfo() + const t = useTranslations('profile') + const { locale } = useAppLocale() const logout = async () => { await logoutUser() @@ -88,6 +93,14 @@ export const Profile = () => { + +
diff --git a/src/components/Settings/LanguageView.tsx b/src/components/Settings/LanguageView.tsx new file mode 100644 index 0000000000..8dda3e9fea --- /dev/null +++ b/src/components/Settings/LanguageView.tsx @@ -0,0 +1,52 @@ +'use client' + +import { useState } from 'react' +import { useTranslations } from 'next-intl' +import Card from '@/components/Global/Card' +import { Icon } from '@/components/Global/Icons/Icon' +import NavHeader from '@/components/Global/NavHeader' +import { useSafeBack } from '@/hooks/useSafeBack' +import { APP_LOCALES, LOCALE_LABELS, type AppLocale } from '@/i18n/app/config' +import { useAppLocale } from '@/i18n/app/AppIntlProvider' + +const positionFor = (index: number) => (index === 0 ? 'first' : index === APP_LOCALES.length - 1 ? 'last' : 'middle') + +export const LanguageView = () => { + const t = useTranslations('settings.language') + const { locale, setLocale } = useAppLocale() + const onBack = useSafeBack('/profile') + const [switching, setSwitching] = useState(null) + + const select = async (next: AppLocale) => { + if (next === locale || switching) return + setSwitching(next) + try { + await setLocale(next) + } finally { + setSwitching(null) + } + } + + return ( +
+ +
+ {APP_LOCALES.map((appLocale, index) => ( + select(appLocale)} + className="cursor-pointer p-4 active:bg-grey-4" + > +
+ + {LOCALE_LABELS[appLocale]} + + {appLocale === locale && } +
+
+ ))} +
+
+ ) +} From 0b0b5d8e934e43ab8b956608e26fafa1e512a5d1 Mon Sep 17 00:00:00 2001 From: oxwel77 Date: Thu, 16 Jul 2026 12:18:15 +0100 Subject: [PATCH 08/44] chore: move request-pot status fix out of this branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The OPEN-status badge fix was picked up by 1eb7c3c9b ("fix(lint): resolve no-unused-vars") along with that commit's lint sweep. It is unrelated to localization and is being reviewed on its own branch against main (peanut-ui#2430), so remove it here to keep the two changes separable. Pure removal — no behaviour change beyond reverting to main's badge logic. --- .../__tests__/transactionTransformer.test.ts | 41 ------------------- .../transactionTransformer.ts | 8 ---- src/utils/history.utils.ts | 1 - 3 files changed, 50 deletions(-) diff --git a/src/components/TransactionDetails/__tests__/transactionTransformer.test.ts b/src/components/TransactionDetails/__tests__/transactionTransformer.test.ts index 2fbade543b..922d8dc087 100644 --- a/src/components/TransactionDetails/__tests__/transactionTransformer.test.ts +++ b/src/components/TransactionDetails/__tests__/transactionTransformer.test.ts @@ -482,47 +482,6 @@ describe('mapTransactionDataForDrawer', () => { }) }) - describe('request pot rollup status', () => { - const pot = (overrides: Partial): HistoryEntry => - baseEntry({ - userRole: EHistoryUserRole.RECIPIENT, - status: EHistoryStatus.OPEN, - amount: '1750', - isRequestLink: true, - extraData: { kind: 'P2P_REQUEST_FULFILL', provider: 'PEANUT', isRequestPotRollup: true }, - ...overrides, - }) - - it('an open pot that met its goal reads as completed', () => { - const result = mapTransactionDataForDrawer(pot({ totalAmountCollected: 1750 })).transactionDetails - expect(result.status).toBe('completed') - }) - - it('an over-funded open pot reads as completed', () => { - const result = mapTransactionDataForDrawer(pot({ totalAmountCollected: 2000 })).transactionDetails - expect(result.status).toBe('completed') - }) - - it('a partially funded open pot stays pending', () => { - const result = mapTransactionDataForDrawer(pot({ totalAmountCollected: 500 })).transactionDetails - expect(result.status).toBe('pending') - }) - - it('a goal-less pot stays pending no matter what it collected', () => { - const result = mapTransactionDataForDrawer( - pot({ amount: '0', totalAmountCollected: 500 }) - ).transactionDetails - expect(result.status).toBe('pending') - }) - - it('a closed pot still reads as closed, not completed', () => { - const result = mapTransactionDataForDrawer( - pot({ status: EHistoryStatus.CLOSED, totalAmountCollected: 1750 }) - ).transactionDetails - expect(result.status).toBe('closed') - }) - }) - describe('refund credit rows (status + sign + flag)', () => { const negativeAuth = baseEntry({ userRole: EHistoryUserRole.SENDER, diff --git a/src/components/TransactionDetails/transactionTransformer.ts b/src/components/TransactionDetails/transactionTransformer.ts index def42a522d..e9036bf9ef 100644 --- a/src/components/TransactionDetails/transactionTransformer.ts +++ b/src/components/TransactionDetails/transactionTransformer.ts @@ -192,14 +192,6 @@ function mapEntryStatusToUiStatus(entry: HistoryEntry, direction: TransactionDir } } - // A request link stays OPEN until the requester closes it, so its raw status - // never reports that the goal was met — derive that from the collected sum. - // Goal 0 is the "no goal set" pot, which can never be fully funded. - if (entry.isRequestLink && status === 'OPEN') { - const goal = Number(entry.amount ?? 0) - return goal > 0 && (entry.totalAmountCollected ?? 0) >= goal ? 'completed' : 'pending' - } - switch (status) { case 'NEW': case 'PENDING': diff --git a/src/utils/history.utils.ts b/src/utils/history.utils.ts index a49a46a7df..6913240bc7 100644 --- a/src/utils/history.utils.ts +++ b/src/utils/history.utils.ts @@ -52,7 +52,6 @@ export enum EHistoryStatus { refunded = 'refunded', canceled = 'canceled', // from simplefi, canceled with only one l expired = 'expired', - OPEN = 'OPEN', CLOSED = 'CLOSED', } From 690ce23da03c26fba749b7fd089c32767e3c783e Mon Sep 17 00:00:00 2001 From: oxwel77 Date: Thu, 16 Jul 2026 12:34:54 +0100 Subject: [PATCH 09/44] feat(i18n): extract setup flow strings Step titles/descriptions keyed by screenId (removed from ISetupStep), all Setup views, wrapper, and (setup) pages on useTranslations. es-419 + pt-BR drafts included. --- src/app/(setup)/setup/finish/page.tsx | 6 +- src/app/(setup)/setup/page.tsx | 17 ++- src/components/Setup/Setup.consts.tsx | 17 --- src/components/Setup/Setup.types.ts | 2 - src/components/Setup/Views/InstallPWA.tsx | 31 ++-- src/components/Setup/Views/JoinWaitlist.tsx | 21 +-- src/components/Setup/Views/Landing.tsx | 10 +- .../Setup/Views/PasskeySetupHelpModal.tsx | 68 ++++----- src/components/Setup/Views/SetupPasskey.tsx | 16 +- .../Setup/Views/SignTestTransaction.tsx | 28 ++-- src/components/Setup/Views/Signup.tsx | 42 +++--- .../Setup/components/SetupWrapper.tsx | 24 +-- src/i18n/app/messages/en.json | 139 ++++++++++++++++++ src/i18n/app/messages/es-419.json | 139 ++++++++++++++++++ src/i18n/app/messages/pt-BR.json | 139 ++++++++++++++++++ 15 files changed, 558 insertions(+), 141 deletions(-) diff --git a/src/app/(setup)/setup/finish/page.tsx b/src/app/(setup)/setup/finish/page.tsx index f9d1ae328a..d4cfda9816 100644 --- a/src/app/(setup)/setup/finish/page.tsx +++ b/src/app/(setup)/setup/finish/page.tsx @@ -6,12 +6,14 @@ import { SetupWrapper } from '@/components/Setup/components/SetupWrapper' import SignTestTransaction from '@/components/Setup/Views/SignTestTransaction' import { PeanutWhistling } from '@/assets/mascot' import { useAuth } from '@/context/authContext' +import { useTranslations } from 'next-intl' /** * finish setup page for users who logged in but haven't completed account setup * shows test transaction step to verify passkey works before finalizing */ function FinishSetupPageContent() { + const t = useTranslations('setup') const { logoutUser, isLoggingOut } = useAuth() return ( @@ -22,8 +24,8 @@ function FinishSetupPageContent() { showLogoutButton={true} onLogout={logoutUser} isLoggingOut={isLoggingOut} - title="Sign a test transaction" - description="Let's make sure your passkey is working and you have everything set up correctly." + title={t('finish.title')} + description={t('finish.description')} showBackButton={false} showSkipButton={false} contentClassName="flex flex-col items-center justify-center gap-5" diff --git a/src/app/(setup)/setup/page.tsx b/src/app/(setup)/setup/page.tsx index c348a3ac04..04cca6bcd7 100644 --- a/src/app/(setup)/setup/page.tsx +++ b/src/app/(setup)/setup/page.tsx @@ -20,8 +20,10 @@ import { Button } from '@/components/0_Bruddle/Button' import { PeanutWavingHello } from '@/assets/mascot' import posthog from 'posthog-js' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' +import { useTranslations } from 'next-intl' function SetupPageContent() { + const t = useTranslations('setup') const { steps, inviteCode } = useSetupStore() const { step, handleNext, handleBack } = useSetupFlow() const { logoutUser, isLoggingOut, user, isFetchingUser } = useAuth() @@ -252,16 +254,16 @@ function SetupPageContent() { layoutType="signup" screenId="welcome" image={PeanutWavingHello.src} - title="You're already signed in" - description={`This device is signed in as ${existingSessionUsername}. Continue with that account, or log out to create a new one.`} + title={t('existingSession.title')} + description={t('existingSession.description', { username: existingSessionUsername })} contentClassName="flex flex-col items-center justify-center gap-5" >
@@ -292,13 +294,16 @@ function SetupPageContent() { ) } + const titleKey = `steps.${step.screenId}.title` as Parameters[0] + const descriptionKey = `steps.${step.screenId}.description` as Parameters[0] + return ( showBackButton?: boolean diff --git a/src/components/Setup/Views/InstallPWA.tsx b/src/components/Setup/Views/InstallPWA.tsx index e0a15a4172..6bfee6880b 100644 --- a/src/components/Setup/Views/InstallPWA.tsx +++ b/src/components/Setup/Views/InstallPWA.tsx @@ -7,6 +7,7 @@ import QRCodeWrapper from '@/components/Global/QRCodeWrapper' import { type BeforeInstallPromptEvent, type ScreenId } from '@/components/Setup/Setup.types' import { useAuth } from '@/context/authContext' import { useSetupFlow } from '@/hooks/useSetupFlow' +import { useTranslations } from 'next-intl' import { useRouter } from 'next/navigation' import { useCallback, useEffect, useState } from 'react' import { captureException } from '@sentry/nextjs' @@ -30,6 +31,8 @@ const InstallPWA = ({ screenId?: ScreenId setShowBraveSuccessMessage?: (show: boolean) => void }) => { + const t = useTranslations('setup.installPwa') + const tCommon = useTranslations('common') const toast = useToast() const { handleNext, isLoading: isSetupFlowLoading } = useSetupFlow() const [showModal, setShowModal] = useState(false) @@ -156,7 +159,7 @@ const InstallPWA = ({ shadowSize="4" loading={isSetupFlowLoading} > - Continue + {tCommon('continue')}
) @@ -184,7 +187,7 @@ const InstallPWA = ({ shadowSize="4" loading={isSetupFlowLoading} > - Open Peanut app + {t('openPeanutApp')}
) @@ -195,7 +198,7 @@ const InstallPWA = ({ return (
) @@ -206,11 +209,9 @@ const InstallPWA = ({ return (
- {installCancelled && ( - - )} + {installCancelled && }
) } @@ -218,11 +219,9 @@ const InstallPWA = ({ // Scenario 4: Fallback (manual install instructions) return (
-

- To install the app, please add it to your Home Screen from your browser menu. -

+

{t('manualInstallHint')}

) @@ -234,10 +233,8 @@ const InstallPWA = ({
- -

- For a better experience, use Peanut on your phone. -

+ +

{t('mobileFirstDescription')}

@@ -262,7 +259,7 @@ const InstallPWA = ({ shadowSize="4" variant="purple" > - {installComplete ? 'Open in the App' : 'Install App'} + {installComplete ? t('openInApp') : t('installApp')}
{showModal && ( @@ -280,7 +277,7 @@ const InstallPWA = ({ shadowSize="4" variant="purple" > - Got it! + {t('gotIt')}
diff --git a/src/components/Setup/Views/JoinWaitlist.tsx b/src/components/Setup/Views/JoinWaitlist.tsx index 243bcfa94b..6f709583cc 100644 --- a/src/components/Setup/Views/JoinWaitlist.tsx +++ b/src/components/Setup/Views/JoinWaitlist.tsx @@ -14,8 +14,8 @@ import ErrorAlert from '@/components/Global/ErrorAlert' import { useLogin } from '@/hooks/useLogin' import posthog from 'posthog-js' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' -import { INVITER_NOT_FOUND_ERROR } from '@/constants/invites.consts' import { enableDemoMode, isDemoInviteCode } from '@/utils/demo' +import { useTranslations } from 'next-intl' import { isCapacitor } from '@/utils/capacitor' import { useRouter } from 'next/navigation' import { useQueryClient } from '@tanstack/react-query' @@ -26,6 +26,7 @@ import { toInviteCode } from '@/utils/general.utils' import { USERNAME_MIN_LENGTH } from '@/constants/general.consts' const JoinWaitlist = () => { + const t = useTranslations('setup') const [inviteCode, setInviteCode] = useState('') const [isValid, setIsValid] = useState(false) const [isChanging, setIsChanging] = useState(false) @@ -62,7 +63,7 @@ const JoinWaitlist = () => { invite_code: inviteCode, }) if (!isValid) { - setError(INVITER_NOT_FOUND_ERROR) + setError(t('waitlist.inviterNotFound')) } return isValid } catch { @@ -71,7 +72,7 @@ const JoinWaitlist = () => { source: 'setup', invite_code: inviteCode, }) - setError(INVITER_NOT_FOUND_ERROR) + setError(t('waitlist.inviterNotFound')) return false } finally { setisLoading(false) @@ -81,10 +82,10 @@ const JoinWaitlist = () => { const handleError = (error: any) => { const errorMessage = error.code === 'LOGIN_CANCELED' - ? 'Login was canceled. Please try again.' + ? t('waitlist.loginCanceled') : error.code === 'NO_PASSKEY' - ? 'No passkey found. Please create a wallet first.' - : 'An unexpected error occurred during login.' + ? t('waitlist.noPasskey') + : t('waitlist.loginUnexpectedError') toast.error(errorMessage) Sentry.captureException(error, { extra: { errorCode: error.code } }) } @@ -101,7 +102,7 @@ const JoinWaitlist = () => {
{ shadowSize="4" className="h-12 w-4/12" > - Next + {t('next')}
@@ -152,7 +153,7 @@ const JoinWaitlist = () => {
- or + {t('waitlist.or')}
@@ -162,7 +163,7 @@ const JoinWaitlist = () => { }} shadowSize="4" > - Join waitlist + {t('waitlist.joinWaitlist')}
) diff --git a/src/components/Setup/Views/Landing.tsx b/src/components/Setup/Views/Landing.tsx index 8c2d8b3e8f..3023915259 100644 --- a/src/components/Setup/Views/Landing.tsx +++ b/src/components/Setup/Views/Landing.tsx @@ -11,8 +11,10 @@ import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' import { useEffect } from 'react' import { disableDemoMode } from '@/utils/demo' import DocsLink from '@/components/Global/DocsLink' +import { useTranslations } from 'next-intl' const LandingStep = () => { + const t = useTranslations('setup') const { handleNext } = useSetupFlow() const { handleLoginClick, isLoggingIn } = useLogin() const toast = useToast() @@ -25,7 +27,7 @@ const LandingStep = () => { }, []) const handleError = (error: any) => { - toast.error(error?.message || 'We couldn’t log you in. Please try again.') + toast.error(error?.message || t('loginFailed')) Sentry.captureException(error, { extra: { errorCode: error?.code } }) posthog.capture(ANALYTICS_EVENTS.SIGNUP_LOGIN_ERROR, { error_code: error?.code }) } @@ -49,7 +51,7 @@ const LandingStep = () => { handleNext() }} > - Sign up + {t('landing.signUp')}
- Need to recover your Peanut wallet? + {t('landing.recoverWallet')}
diff --git a/src/components/Setup/Views/PasskeySetupHelpModal.tsx b/src/components/Setup/Views/PasskeySetupHelpModal.tsx index 69cab640dd..4baa399d62 100644 --- a/src/components/Setup/Views/PasskeySetupHelpModal.tsx +++ b/src/components/Setup/Views/PasskeySetupHelpModal.tsx @@ -4,6 +4,7 @@ import { Button } from '@/components/0_Bruddle/Button' import ActionModal from '@/components/Global/ActionModal' import InfoCard from '@/components/Global/InfoCard' import { PASSKEY_TROUBLESHOOTING_STEPS, PASSKEY_WARNINGS, WebAuthnErrorName } from '@/utils/webauthn.utils' +import { useTranslations } from 'next-intl' interface PasskeySetupHelpModalProps { visible: boolean @@ -13,30 +14,6 @@ interface PasskeySetupHelpModalProps { platform: 'android' | 'ios' | 'web' } -const getErrorTitle = (errorName: string): string => { - if (errorName === WebAuthnErrorName.NotReadable) return 'Passkey Issue' - if (errorName === WebAuthnErrorName.NotAllowed) return 'Passkeys Not Enabled' - if (errorName === WebAuthnErrorName.InvalidState) return 'Passkey Already Exists' - if (errorName === WebAuthnErrorName.NotSupported) return 'Passkeys Not Supported' - return 'Passkey Issue' -} - -const getErrorDescription = (errorName: string, platform: 'android' | 'ios' | 'web'): string => { - if (errorName === WebAuthnErrorName.NotReadable) { - return "There's an issue with your device's passkey settings. Please try again." - } - if (errorName === WebAuthnErrorName.NotAllowed) { - if (platform === 'android') { - return 'We couldn’t create a passkey. This usually means you’re not signed in to a Google account, Google Play Services is out of date, or screen lock isn’t set up. Fix one of these and retry — or it may have been cancelled.' - } - return 'Passkeys are not enabled on your device. Check your device settings to enable them' - } - if (errorName === WebAuthnErrorName.InvalidState) { - return 'A passkey already exists for this account on your device.' - } - return 'Something went wrong during passkey setup. This is likely a device issue.' -} - const getTroubleshootingSteps = (errorName: string, platform: 'android' | 'ios' | 'web'): readonly string[] => { if (platform === 'android') { const steps = PASSKEY_TROUBLESHOOTING_STEPS.android @@ -63,8 +40,28 @@ export const PasskeySetupHelpModal = ({ errorName, platform, }: PasskeySetupHelpModalProps) => { - const title = getErrorTitle(errorName) - const description = getErrorDescription(errorName, platform) + const t = useTranslations('setup.passkey.help') + const tCommon = useTranslations('common') + + const getTitle = (): string => { + if (errorName === WebAuthnErrorName.NotReadable) return t('titles.notReadable') + if (errorName === WebAuthnErrorName.NotAllowed) return t('titles.notAllowed') + if (errorName === WebAuthnErrorName.InvalidState) return t('titles.invalidState') + if (errorName === WebAuthnErrorName.NotSupported) return t('titles.notSupported') + return t('titles.default') + } + + const getDescription = (): string => { + if (errorName === WebAuthnErrorName.NotReadable) return t('descriptions.notReadable') + if (errorName === WebAuthnErrorName.NotAllowed) { + return platform === 'android' ? t('descriptions.notAllowedAndroid') : t('descriptions.notAllowed') + } + if (errorName === WebAuthnErrorName.InvalidState) return t('descriptions.invalidState') + return t('descriptions.default') + } + + const title = getTitle() + const description = getDescription() const troubleshootingSteps = getTroubleshootingSteps(errorName, platform) const warning = getWarningMessage(errorName, platform) @@ -78,14 +75,14 @@ export const PasskeySetupHelpModal = ({ title={title} footer={ } content={

{description}

-

Try these fixes:

+

{t('tryTheseFixes')}

)}
-

Still having issues?

+

{t('stillHavingIssues')}

- Contact our support team at{' '} - - peanut.me/support - + {t.rich('contactSupport', { + link: (chunks) => ( + + {chunks} + + ), + })}

diff --git a/src/components/Setup/Views/SetupPasskey.tsx b/src/components/Setup/Views/SetupPasskey.tsx index 5e7367965d..8c343c63fd 100644 --- a/src/components/Setup/Views/SetupPasskey.tsx +++ b/src/components/Setup/Views/SetupPasskey.tsx @@ -17,6 +17,7 @@ import ErrorAlert from '@/components/Global/ErrorAlert' import * as Sentry from '@sentry/nextjs' import posthog from 'posthog-js' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' +import { useTranslations } from 'next-intl' // A signup that failed client-side AFTER the server verified may have already // registered this username (user + passkey exist server-side, so login works). @@ -34,6 +35,7 @@ const isUsernameTaken = async (username: string): Promise => { } const SetupPasskey = () => { + const t = useTranslations('setup') const { username } = useSetupStore() const { isLoading, handleNext } = useSetupFlow() const { handleRegister, address, isRegistering } = useZeroDev() @@ -76,7 +78,7 @@ const SetupPasskey = () => { // calling create() and failing cryptically. (Was the May-18 rejection.) const support = await checkPasskeySupport() if (!support.isSupported) { - const message = support.warning ?? 'Passkeys aren’t available on this device yet. Please try again.' + const message = support.warning ?? t('passkey.notAvailable') setPreflightWarning(message) setInlineError(message) posthog.capture(ANALYTICS_EVENTS.SIGNUP_PASSKEY_FAILED, { @@ -140,7 +142,7 @@ const SetupPasskey = () => { } else { // likely user cancellation - show simple inline error console.log('[SetupPasskey] User likely cancelled, showing inline error') - setInlineError('Passkey setup was cancelled. Please try again when ready.') + setInlineError(t('passkey.cancelled')) } return } @@ -174,7 +176,7 @@ const SetupPasskey = () => { // success — useLogin's effect redirects once the user is loaded } catch (error) { // handleLogin throws PasskeyError with a curated user-facing message - setInlineError((error as Error)?.message || 'We couldn’t log you in. Please try again.') + setInlineError((error as Error)?.message || t('loginFailed')) } } @@ -205,12 +207,12 @@ const SetupPasskey = () => { className="text-nowrap" shadowSize="4" > - Set it up + {t('passkey.setItUp')} {preflightWarning &&

{preflightWarning}

} {usernameTaken && ( <> - + )} @@ -228,7 +230,7 @@ const SetupPasskey = () => {

- Learn more about what Passkeys are + {t('passkey.learnMore')} {' '}

diff --git a/src/components/Setup/Views/SignTestTransaction.tsx b/src/components/Setup/Views/SignTestTransaction.tsx index 6961f20d2e..54194e9762 100644 --- a/src/components/Setup/Views/SignTestTransaction.tsx +++ b/src/components/Setup/Views/SignTestTransaction.tsx @@ -15,8 +15,11 @@ import posthog from 'posthog-js' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' import { getFromCookie } from '@/utils/general.utils' import { twMerge } from 'tailwind-merge' +import { useTranslations } from 'next-intl' const SignTestTransaction = () => { + const t = useTranslations('setup') + const tCommon = useTranslations('common') const dispatch = useAppDispatch() const { address, handleSendUserOpEncoded } = useZeroDev() const { finalizeAccountSetup, isProcessing, error: setupError, handleRedirect } = useAccountSetup() @@ -41,7 +44,7 @@ const SignTestTransaction = () => { tags: { feature: 'signup-test-transaction' }, extra: { context: 'user-fetch-on-mount' }, }) - setError('Failed to load user data. Please refresh the page.') + setError(t('testTransaction.errors.loadUserFailed')) }) } else if (user) { console.log('[SignTestTransaction] User loaded successfully:', { @@ -65,13 +68,13 @@ const SignTestTransaction = () => { const handleTestTransaction = async () => { if (!address) { - setError('No wallet address found. Please try refreshing the page.') + setError(t('testTransaction.errors.noWalletAddress')) return } if (!user) { console.error('[SignTestTransaction] Cannot proceed without user data') - setError('User data not loaded. Please refresh the page.') + setError(t('testTransaction.errors.userNotLoaded')) return } @@ -123,9 +126,7 @@ const SignTestTransaction = () => { const success = await finalizeAccountSetup(address) if (!success) { console.error('[SignTestTransaction] Failed to finalize account setup') - setError( - setupError || 'Failed to complete account setup. Please try again by clicking the button below.' - ) + setError(setupError || t('testTransaction.errors.setupFailed')) setIsSigning(false) dispatch(setupActions.setLoading(false)) return @@ -163,9 +164,7 @@ const SignTestTransaction = () => { }) posthog.capture(ANALYTICS_EVENTS.SIGNUP_TEST_TX_FAILED, { error_name: (e as Error).name }) - setError( - "We're having trouble setting up your account. Our team has been notified. Please contact support for help." - ) + setError(t('testTransaction.errors.supportNeeded')) setIsSigning(false) dispatch(setupActions.setLoading(false)) } @@ -177,9 +176,9 @@ const SignTestTransaction = () => { // determine button text based on state const getButtonText = () => { - if (isFetchingUser || !user) return 'Loading...' - if (testTransactionCompleted && displayError) return 'Retry account setup' - return 'Confirm & finish' + if (isFetchingUser || !user) return tCommon('loading') + if (testTransactionCompleted && displayError) return t('testTransaction.retryAccountSetup') + return t('testTransaction.confirmAndFinish') } return ( @@ -200,7 +199,7 @@ const SignTestTransaction = () => {

- Learn more about what Passkeys are + {t('passkey.learnMore')} {' '}

@@ -210,10 +209,11 @@ const SignTestTransaction = () => { } export const PasskeyDocsLink = ({ className }: { className?: string }) => { + const t = useTranslations('setup') return (

- Learn more about what Passkeys are + {t('passkey.learnMore')} {' '}

) diff --git a/src/components/Setup/Views/Signup.tsx b/src/components/Setup/Views/Signup.tsx index 132c29eeb5..280fef007b 100644 --- a/src/components/Setup/Views/Signup.tsx +++ b/src/components/Setup/Views/Signup.tsx @@ -13,8 +13,10 @@ import posthog from 'posthog-js' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' import { useState } from 'react' import { twMerge } from 'tailwind-merge' +import { useTranslations } from 'next-intl' const SignupStep = () => { + const t = useTranslations('setup') const dispatch = useAppDispatch() const { username } = useSetupStore() const [error, setError] = useState('') @@ -28,23 +30,23 @@ const SignupStep = () => { // handle empty input if (!username) { - setError('Username is required') + setError(t('signupStep.errors.required')) return false } // check length requirement if (username.length < USERNAME_MIN_LENGTH) { - setError('Username must be at least 4 characters long') + setError(t('signupStep.errors.tooShort')) return false } if (username.length > 12) { - setError('Username must be at most 12 characters long') + setError(t('signupStep.errors.tooLong')) return false } // check character requirement if (!username.match(/^[a-z][a-z0-9]{3,11}$/)) { - setError('Username must contain only lowercase letters and numbers and start with a letter') + setError(t('signupStep.errors.invalidFormat')) return false } @@ -56,7 +58,7 @@ const SignupStep = () => { }) switch (res.status) { case 200: - setError('Username already taken') + setError(t('signupStep.errors.taken')) posthog.capture(ANALYTICS_EVENTS.SIGNUP_USERNAME_VALIDATED, { is_valid: false, error_type: 'taken', @@ -64,7 +66,7 @@ const SignupStep = () => { }) return false case 400: - setError('Username is invalid, please use a different one') + setError(t('signupStep.errors.invalid')) posthog.capture(ANALYTICS_EVENTS.SIGNUP_USERNAME_VALIDATED, { is_valid: false, error_type: 'invalid', @@ -79,7 +81,7 @@ const SignupStep = () => { default: // we dont expect any other status code console.error('Unexpected status code when checking handle availability:', res.status) - setError('Failed to check handle availability. Please try again.') + setError(t('signupStep.errors.checkFailed')) Sentry.captureMessage('Unexpected status code when checking handle availability', { level: 'error', extra: { @@ -91,7 +93,7 @@ const SignupStep = () => { return false } } catch { - setError('Failed to check handle availability. Please try again or contact support.') + setError(t('signupStep.errors.checkFailedSupport')) return false } } @@ -121,7 +123,7 @@ const SignupStep = () => {
{ onClick={() => handleNext(async () => isValid)} disabled={!isValid || isChanging || isLoading} > - Next + {t('next')}
{error && ( @@ -153,14 +155,18 @@ const SignupStep = () => {

- By creating account you agree with - - T&C - - {' and '} - - Privacy Policy - + {t.rich('signupStep.termsAgreement', { + terms: (chunks) => ( + + {chunks} + + ), + privacy: (chunks) => ( + + {chunks} + + ), + })}

diff --git a/src/components/Setup/components/SetupWrapper.tsx b/src/components/Setup/components/SetupWrapper.tsx index 3106a06fc2..de8ca585ba 100644 --- a/src/components/Setup/components/SetupWrapper.tsx +++ b/src/components/Setup/components/SetupWrapper.tsx @@ -8,6 +8,7 @@ import { useBravePWAInstallState } from '@/hooks/useBravePWAInstallState' import { DeviceType } from '@/hooks/useGetDeviceType' import classNames from 'classnames' import { motion, useReducedMotion } from 'framer-motion' +import { useTranslations } from 'next-intl' import Image from 'next/image' import { Children, type ReactNode, cloneElement, memo, type ReactElement, useState } from 'react' import { twMerge } from 'tailwind-merge' @@ -74,13 +75,15 @@ const Navigation = memo(function Navigation({ SetupWrapperProps, 'showBackButton' | 'showSkipButton' | 'showLogoutButton' | 'onBack' | 'onSkip' | 'onLogout' | 'isLoggingOut' >) { + const t = useTranslations('setup.navigation') + if (!showBackButton && !showSkipButton && !showLogoutButton) return null return (
{showBackButton && ( - )} @@ -88,7 +91,7 @@ const Navigation = memo(function Navigation({
{showSkipButton && ( )} {showLogoutButton && ( @@ -97,7 +100,7 @@ const Navigation = memo(function Navigation({ loading={isLoggingOut} variant="stroke" className={twMerge('h-7 w-7 p-0', isLoggingOut && 'pl-3')} - aria-label="Logout" + aria-label={t('logout')} disabled={isLoggingOut} > @@ -118,6 +121,8 @@ const ImageSection = ({ screenId, imageClassName, }: Pick) => { + const t = useTranslations('setup.wrapper') + if (!image) return null const isSignup = layoutType === 'signup' @@ -140,7 +145,7 @@ const ImageSection = ({ star Section illustration diff --git a/src/i18n/app/messages/en.json b/src/i18n/app/messages/en.json index 78b7f7ea9e..b2f4c06c6c 100644 --- a/src/i18n/app/messages/en.json +++ b/src/i18n/app/messages/en.json @@ -20,6 +20,145 @@ "title": "Language" } }, + "setup": { + "steps": { + "unsupported-browser": { + "title": "Browser Not Supported", + "description": "Please open Peanut in your device's main browser (Safari, Chrome, Firefox) to continue setup. If you are already in your main browser your device may not support Peanut." + }, + "android-initial-pwa-install": { + "title": "Get the full experience", + "description": "Keep your wallet one tap away. Safe, fast, and on your home screen." + }, + "pwa-install": { + "title": "Install Peanut on your phone", + "description": "Please install Peanut on your phone for the best experience!" + }, + "landing": { + "title": "Peanut makes dollars easy.", + "description": "Create your wallet in seconds to save, send, or cash out dollars fast." + }, + "welcome": { + "title": "Peanut is invite-only", + "description": "Who invited you? Enter their username to continue, or join the waitlist and we'll reach out when you're eligible." + }, + "signup": { + "title": "How should we call you?", + "description": "Choose your username. It'll be your ID to send and receive money." + }, + "passkey-permission": { + "title": "Let's set up your passkey", + "description": "Use your face or fingerprint to log in. No passwords, no hassle. Just you." + }, + "sign-test-transaction": { + "title": "Your account is ready", + "description": "Just confirm it's you — one tap and you're in." + } + }, + "existingSession": { + "title": "You're already signed in", + "description": "This device is signed in as {username}. Continue with that account, or log out to create a new one.", + "continueAs": "Continue as {username}", + "logoutAndStartFresh": "Log out & start fresh" + }, + "navigation": { + "goBack": "Go back", + "skip": "Skip", + "logout": "Logout" + }, + "wrapper": { + "starAlt": "star", + "illustrationAlt": "Section illustration" + }, + "braveInstall": { + "title": "Success!", + "description": "Please open the Peanut app from your home screen to continue setup." + }, + "installPwa": { + "openPeanutApp": "Open Peanut app", + "installing": "Installing", + "addToHomeScreen": "Add Peanut to Home Screen", + "installCancelled": "Installation cancelled. You can try adding to Home Screen again.", + "manualInstallHint": "To install the app, please add it to your Home Screen from your browser menu.", + "mobileFirstTitle": "Peanut is mobile first!", + "mobileFirstDescription": "For a better experience, use Peanut on your phone.", + "openInApp": "Open in the App", + "installApp": "Install App", + "gotIt": "Got it!" + }, + "next": "Next", + "logIn": "Log In", + "loginFailed": "We couldn’t log you in. Please try again.", + "landing": { + "signUp": "Sign up", + "recoverWallet": "Need to recover your Peanut wallet?" + }, + "waitlist": { + "inviterUsernamePlaceholder": "Their username", + "inviterNotFound": "No Peanut member with that username. Double-check your inviter's exact handle.", + "or": "or", + "joinWaitlist": "Join waitlist", + "loginCanceled": "Login was canceled. Please try again.", + "noPasskey": "No passkey found. Please create a wallet first.", + "loginUnexpectedError": "An unexpected error occurred during login." + }, + "signupStep": { + "usernamePlaceholder": "Enter a username", + "errors": { + "required": "Username is required", + "tooShort": "Username must be at least 4 characters long", + "tooLong": "Username must be at most 12 characters long", + "invalidFormat": "Username must contain only lowercase letters and numbers and start with a letter", + "taken": "Username already taken", + "invalid": "Username is invalid, please use a different one", + "checkFailed": "Failed to check handle availability. Please try again.", + "checkFailedSupport": "Failed to check handle availability. Please try again or contact support." + }, + "termsAgreement": "By creating account you agree with T&C and Privacy Policy" + }, + "passkey": { + "setItUp": "Set it up", + "notAvailable": "Passkeys aren’t available on this device yet. Please try again.", + "cancelled": "Passkey setup was cancelled. Please try again when ready.", + "usernameTaken": "This username is already registered — possibly from an earlier attempt on this device. If that was you, your passkey is ready: just log in.", + "learnMore": "Learn more about what Passkeys are", + "help": { + "titles": { + "notReadable": "Passkey Issue", + "notAllowed": "Passkeys Not Enabled", + "invalidState": "Passkey Already Exists", + "notSupported": "Passkeys Not Supported", + "default": "Passkey Issue" + }, + "descriptions": { + "notReadable": "There's an issue with your device's passkey settings. Please try again.", + "notAllowedAndroid": "We couldn’t create a passkey. This usually means you’re not signed in to a Google account, Google Play Services is out of date, or screen lock isn’t set up. Fix one of these and retry — or it may have been cancelled.", + "notAllowed": "Passkeys are not enabled on your device. Check your device settings to enable them", + "invalidState": "A passkey already exists for this account on your device.", + "default": "Something went wrong during passkey setup. This is likely a device issue." + }, + "tryTheseFixes": "Try these fixes:", + "importantNote": "Important Note", + "stillHavingIssues": "Still having issues?", + "contactSupport": "Contact our support team at peanut.me/support" + } + }, + "testTransaction": { + "confirmAndFinish": "Confirm & finish", + "retryAccountSetup": "Retry account setup", + "errors": { + "loadUserFailed": "Failed to load user data. Please refresh the page.", + "noWalletAddress": "No wallet address found. Please try refreshing the page.", + "userNotLoaded": "User data not loaded. Please refresh the page.", + "setupFailed": "Failed to complete account setup. Please try again by clicking the button below.", + "supportNeeded": "We're having trouble setting up your account. Our team has been notified. Please contact support for help." + } + }, + "finish": { + "title": "Sign a test transaction", + "description": "Let's make sure your passkey is working and you have everything set up correctly." + } + }, "loadingStates": { "idle": "Idle", "loading": "Loading", diff --git a/src/i18n/app/messages/es-419.json b/src/i18n/app/messages/es-419.json index 94609b0659..44b5c4ef83 100644 --- a/src/i18n/app/messages/es-419.json +++ b/src/i18n/app/messages/es-419.json @@ -20,6 +20,145 @@ "title": "Idioma" } }, + "setup": { + "steps": { + "unsupported-browser": { + "title": "Navegador no compatible", + "description": "Abre Peanut en el navegador principal de tu dispositivo (Safari, Chrome, Firefox) para continuar con la configuración. Si ya estás en tu navegador principal, puede que tu dispositivo no sea compatible con Peanut." + }, + "android-initial-pwa-install": { + "title": "Vive la experiencia completa", + "description": "Tu billetera a un toque de distancia. Segura, rápida y en tu pantalla de inicio." + }, + "pwa-install": { + "title": "Instala Peanut en tu teléfono", + "description": "¡Instala Peanut en tu teléfono para una mejor experiencia!" + }, + "landing": { + "title": "Peanut hace que los dólares sean fáciles.", + "description": "Crea tu billetera en segundos para ahorrar, enviar o retirar dólares rápido." + }, + "welcome": { + "title": "Peanut es solo con invitación", + "description": "¿Quién te invitó? Ingresa su nombre de usuario para continuar, o únete a la lista de espera y te avisaremos cuando puedas entrar." + }, + "signup": { + "title": "¿Cómo te llamamos?", + "description": "Elige tu nombre de usuario. Será tu ID para enviar y recibir dinero." + }, + "passkey-permission": { + "title": "Configuremos tu passkey", + "description": "Usa tu cara o tu huella para iniciar sesión. Sin contraseñas ni complicaciones. Solo tú." + }, + "sign-test-transaction": { + "title": "Tu cuenta está lista", + "description": "Solo confirma que eres tú: un toque y listo." + } + }, + "existingSession": { + "title": "Ya tienes una sesión iniciada", + "description": "Este dispositivo tiene una sesión iniciada como {username}. Continúa con esa cuenta o cierra sesión para crear una nueva.", + "continueAs": "Continuar como {username}", + "logoutAndStartFresh": "Cerrar sesión y empezar de cero" + }, + "navigation": { + "goBack": "Volver", + "skip": "Omitir", + "logout": "Cerrar sesión" + }, + "wrapper": { + "starAlt": "estrella", + "illustrationAlt": "Ilustración de la sección" + }, + "braveInstall": { + "title": "¡Listo!", + "description": "Abre la app de Peanut desde tu pantalla de inicio para continuar con la configuración." + }, + "installPwa": { + "openPeanutApp": "Abrir la app de Peanut", + "installing": "Instalando", + "addToHomeScreen": "Agregar Peanut a la pantalla de inicio", + "installCancelled": "Instalación cancelada. Puedes intentar agregarlo a la pantalla de inicio de nuevo.", + "manualInstallHint": "Para instalar la app, agrégala a tu pantalla de inicio desde el menú de tu navegador.", + "mobileFirstTitle": "¡Peanut está hecho para el celular!", + "mobileFirstDescription": "Para una mejor experiencia, usa Peanut en tu teléfono.", + "openInApp": "Abrir en la app", + "installApp": "Instalar la app", + "gotIt": "¡Entendido!" + }, + "next": "Siguiente", + "logIn": "Iniciar sesión", + "loginFailed": "No pudimos iniciar tu sesión. Inténtalo de nuevo.", + "landing": { + "signUp": "Regístrate", + "recoverWallet": "¿Necesitas recuperar tu billetera de Peanut?" + }, + "waitlist": { + "inviterUsernamePlaceholder": "Su nombre de usuario", + "inviterNotFound": "No hay ningún miembro de Peanut con ese nombre de usuario. Verifica el usuario exacto de quien te invitó.", + "or": "o", + "joinWaitlist": "Unirme a la lista de espera", + "loginCanceled": "El inicio de sesión fue cancelado. Inténtalo de nuevo.", + "noPasskey": "No se encontró ninguna passkey. Primero crea una billetera.", + "loginUnexpectedError": "Ocurrió un error inesperado al iniciar sesión." + }, + "signupStep": { + "usernamePlaceholder": "Escribe un nombre de usuario", + "errors": { + "required": "El nombre de usuario es obligatorio", + "tooShort": "El nombre de usuario debe tener al menos 4 caracteres", + "tooLong": "El nombre de usuario debe tener como máximo 12 caracteres", + "invalidFormat": "El nombre de usuario solo puede tener letras minúsculas y números, y debe empezar con una letra", + "taken": "Ese nombre de usuario ya está en uso", + "invalid": "El nombre de usuario no es válido, usa uno diferente", + "checkFailed": "No pudimos verificar la disponibilidad del usuario. Inténtalo de nuevo.", + "checkFailedSupport": "No pudimos verificar la disponibilidad del usuario. Inténtalo de nuevo o contacta a soporte." + }, + "termsAgreement": "Al crear una cuenta aceptas los Términos y Condiciones y la Política de Privacidad" + }, + "passkey": { + "setItUp": "Configurar", + "notAvailable": "Las passkeys aún no están disponibles en este dispositivo. Inténtalo de nuevo.", + "cancelled": "La configuración de la passkey fue cancelada. Inténtalo de nuevo cuando quieras.", + "usernameTaken": "Este nombre de usuario ya está registrado, posiblemente por un intento anterior en este dispositivo. Si fuiste tú, tu passkey está lista: solo inicia sesión.", + "learnMore": "Aprende más sobre qué son las passkeys", + "help": { + "titles": { + "notReadable": "Problema con la passkey", + "notAllowed": "Passkeys no habilitadas", + "invalidState": "La passkey ya existe", + "notSupported": "Passkeys no compatibles", + "default": "Problema con la passkey" + }, + "descriptions": { + "notReadable": "Hay un problema con la configuración de passkeys de tu dispositivo. Inténtalo de nuevo.", + "notAllowedAndroid": "No pudimos crear una passkey. Esto suele pasar cuando no has iniciado sesión en una cuenta de Google, Google Play Services está desactualizado o no tienes bloqueo de pantalla configurado. Corrige uno de estos puntos y reintenta, o puede que se haya cancelado.", + "notAllowed": "Las passkeys no están habilitadas en tu dispositivo. Revisa la configuración de tu dispositivo para habilitarlas", + "invalidState": "Ya existe una passkey para esta cuenta en tu dispositivo.", + "default": "Algo salió mal al configurar la passkey. Probablemente es un problema del dispositivo." + }, + "tryTheseFixes": "Prueba estas soluciones:", + "importantNote": "Nota importante", + "stillHavingIssues": "¿Sigues con problemas?", + "contactSupport": "Contacta a nuestro equipo de soporte en peanut.me/support" + } + }, + "testTransaction": { + "confirmAndFinish": "Confirmar y terminar", + "retryAccountSetup": "Reintentar la configuración de la cuenta", + "errors": { + "loadUserFailed": "No pudimos cargar tus datos. Recarga la página.", + "noWalletAddress": "No se encontró la dirección de la billetera. Intenta recargar la página.", + "userNotLoaded": "Tus datos no se han cargado. Recarga la página.", + "setupFailed": "No pudimos completar la configuración de tu cuenta. Inténtalo de nuevo con el botón de abajo.", + "supportNeeded": "Estamos teniendo problemas para configurar tu cuenta. Nuestro equipo ya fue notificado. Contacta a soporte para recibir ayuda." + } + }, + "finish": { + "title": "Firma una transacción de prueba", + "description": "Asegurémonos de que tu passkey funciona y de que todo está configurado correctamente." + } + }, "loadingStates": { "idle": "Inactivo", "loading": "Cargando", diff --git a/src/i18n/app/messages/pt-BR.json b/src/i18n/app/messages/pt-BR.json index 88785118e3..5be6ec1afa 100644 --- a/src/i18n/app/messages/pt-BR.json +++ b/src/i18n/app/messages/pt-BR.json @@ -20,6 +20,145 @@ "title": "Idioma" } }, + "setup": { + "steps": { + "unsupported-browser": { + "title": "Navegador não compatível", + "description": "Abra o Peanut no navegador principal do seu dispositivo (Safari, Chrome, Firefox) para continuar a configuração. Se você já está no navegador principal, seu dispositivo pode não ser compatível com o Peanut." + }, + "android-initial-pwa-install": { + "title": "Tenha a experiência completa", + "description": "Sua carteira a um toque de distância. Segura, rápida e na sua tela inicial." + }, + "pwa-install": { + "title": "Instale o Peanut no seu celular", + "description": "Instale o Peanut no seu celular para ter a melhor experiência!" + }, + "landing": { + "title": "Com o Peanut, dólares ficam fáceis.", + "description": "Crie sua carteira em segundos para guardar, enviar ou sacar dólares rápido." + }, + "welcome": { + "title": "O Peanut é só por convite", + "description": "Quem convidou você? Digite o nome de usuário da pessoa para continuar, ou entre na lista de espera e avisaremos quando você puder entrar." + }, + "signup": { + "title": "Como devemos te chamar?", + "description": "Escolha seu nome de usuário. Ele será seu ID para enviar e receber dinheiro." + }, + "passkey-permission": { + "title": "Vamos configurar sua passkey", + "description": "Use seu rosto ou sua digital para entrar. Sem senhas, sem complicação. Só você." + }, + "sign-test-transaction": { + "title": "Sua conta está pronta", + "description": "É só confirmar que é você — um toque e pronto." + } + }, + "existingSession": { + "title": "Você já está conectado", + "description": "Este dispositivo está conectado como {username}. Continue com essa conta ou saia para criar uma nova.", + "continueAs": "Continuar como {username}", + "logoutAndStartFresh": "Sair e começar do zero" + }, + "navigation": { + "goBack": "Voltar", + "skip": "Pular", + "logout": "Sair" + }, + "wrapper": { + "starAlt": "estrela", + "illustrationAlt": "Ilustração da seção" + }, + "braveInstall": { + "title": "Pronto!", + "description": "Abra o app do Peanut na sua tela inicial para continuar a configuração." + }, + "installPwa": { + "openPeanutApp": "Abrir o app do Peanut", + "installing": "Instalando", + "addToHomeScreen": "Adicionar o Peanut à tela inicial", + "installCancelled": "Instalação cancelada. Você pode tentar adicionar à tela inicial de novo.", + "manualInstallHint": "Para instalar o app, adicione-o à sua tela inicial pelo menu do navegador.", + "mobileFirstTitle": "O Peanut foi feito para o celular!", + "mobileFirstDescription": "Para uma experiência melhor, use o Peanut no seu celular.", + "openInApp": "Abrir no app", + "installApp": "Instalar o app", + "gotIt": "Entendi!" + }, + "next": "Avançar", + "logIn": "Entrar", + "loginFailed": "Não conseguimos fazer seu login. Tente de novo.", + "landing": { + "signUp": "Criar conta", + "recoverWallet": "Precisa recuperar sua carteira do Peanut?" + }, + "waitlist": { + "inviterUsernamePlaceholder": "Nome de usuário da pessoa", + "inviterNotFound": "Não há nenhum membro do Peanut com esse nome de usuário. Confira o usuário exato de quem convidou você.", + "or": "ou", + "joinWaitlist": "Entrar na lista de espera", + "loginCanceled": "O login foi cancelado. Tente de novo.", + "noPasskey": "Nenhuma passkey encontrada. Primeiro crie uma carteira.", + "loginUnexpectedError": "Ocorreu um erro inesperado ao fazer login." + }, + "signupStep": { + "usernamePlaceholder": "Digite um nome de usuário", + "errors": { + "required": "O nome de usuário é obrigatório", + "tooShort": "O nome de usuário deve ter pelo menos 4 caracteres", + "tooLong": "O nome de usuário deve ter no máximo 12 caracteres", + "invalidFormat": "O nome de usuário só pode ter letras minúsculas e números, e deve começar com uma letra", + "taken": "Esse nome de usuário já está em uso", + "invalid": "O nome de usuário não é válido, use um diferente", + "checkFailed": "Não conseguimos verificar a disponibilidade do usuário. Tente de novo.", + "checkFailedSupport": "Não conseguimos verificar a disponibilidade do usuário. Tente de novo ou fale com o suporte." + }, + "termsAgreement": "Ao criar uma conta você aceita os Termos e Condições e a Política de Privacidade" + }, + "passkey": { + "setItUp": "Configurar", + "notAvailable": "As passkeys ainda não estão disponíveis neste dispositivo. Tente de novo.", + "cancelled": "A configuração da passkey foi cancelada. Tente de novo quando quiser.", + "usernameTaken": "Este nome de usuário já está registrado — possivelmente de uma tentativa anterior neste dispositivo. Se foi você, sua passkey está pronta: é só fazer login.", + "learnMore": "Saiba mais sobre o que são as passkeys", + "help": { + "titles": { + "notReadable": "Problema com a passkey", + "notAllowed": "Passkeys não habilitadas", + "invalidState": "A passkey já existe", + "notSupported": "Passkeys não compatíveis", + "default": "Problema com a passkey" + }, + "descriptions": { + "notReadable": "Há um problema com as configurações de passkey do seu dispositivo. Tente de novo.", + "notAllowedAndroid": "Não conseguimos criar uma passkey. Isso geralmente acontece quando você não está conectado a uma conta do Google, o Google Play Services está desatualizado ou o bloqueio de tela não está configurado. Corrija um desses pontos e tente de novo — ou pode ter sido cancelado.", + "notAllowed": "As passkeys não estão habilitadas no seu dispositivo. Verifique as configurações do dispositivo para habilitá-las", + "invalidState": "Já existe uma passkey para esta conta no seu dispositivo.", + "default": "Algo deu errado ao configurar a passkey. Provavelmente é um problema do dispositivo." + }, + "tryTheseFixes": "Tente estas soluções:", + "importantNote": "Nota importante", + "stillHavingIssues": "Ainda com problemas?", + "contactSupport": "Fale com nosso time de suporte em peanut.me/support" + } + }, + "testTransaction": { + "confirmAndFinish": "Confirmar e concluir", + "retryAccountSetup": "Tentar configurar a conta de novo", + "errors": { + "loadUserFailed": "Não conseguimos carregar seus dados. Recarregue a página.", + "noWalletAddress": "Nenhum endereço de carteira encontrado. Tente recarregar a página.", + "userNotLoaded": "Seus dados não foram carregados. Recarregue a página.", + "setupFailed": "Não conseguimos concluir a configuração da sua conta. Tente de novo pelo botão abaixo.", + "supportNeeded": "Estamos com problemas para configurar sua conta. Nosso time já foi notificado. Fale com o suporte para receber ajuda." + } + }, + "finish": { + "title": "Assine uma transação de teste", + "description": "Vamos garantir que sua passkey está funcionando e que está tudo configurado direitinho." + } + }, "loadingStates": { "idle": "Inativo", "loading": "Carregando", From 5abd72a205e8c22c4c09b4ae811f2f84d2d24c12 Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Thu, 16 Jul 2026 13:04:02 +0100 Subject: [PATCH 10/44] feat(i18n): extract home and navigation strings Home screen, activation CTAs, carousel, perk/welcome modals, tab bar, desktop sidebar and top navbar. TopNavbar maps pathname to typed navigation.* keys, replacing the pathTitles util. --- src/app/(mobile-ui)/home/page.tsx | 26 ++- src/components/Global/LogoutButton/index.tsx | 6 +- src/components/Global/TopNavbar/index.tsx | 17 +- .../Global/WalletNavigation/index.tsx | 32 ++-- src/components/Home/ActivationCTAs.tsx | 103 ++++++------ .../CardLaunchCTA/CardLaunchCTABanner.tsx | 12 +- .../Home/EnableAutoBalanceBanner.tsx | 19 ++- .../Home/HomeCarouselCTA/CarouselCTA.tsx | 12 +- src/components/Home/HomeCarouselCTA/index.tsx | 16 +- src/components/Home/HomeHistory.tsx | 28 ++-- src/components/Home/PerkClaimModal.tsx | 47 +++--- .../Home/WelcomeUnlockModal/index.tsx | 85 ++++------ .../Home/__tests__/ActivationCTAs.test.tsx | 11 +- .../EnableAutoBalanceBanner.test.tsx | 11 +- src/constants/general.consts.ts | 11 -- src/hooks/useHomeCarouselCTAs.tsx | 74 +++------ src/i18n/app/messages/en.json | 155 +++++++++++++++++- src/i18n/app/messages/es-419.json | 155 +++++++++++++++++- src/i18n/app/messages/pt-BR.json | 155 +++++++++++++++++- src/utils/general.utils.ts | 11 +- 20 files changed, 713 insertions(+), 273 deletions(-) diff --git a/src/app/(mobile-ui)/home/page.tsx b/src/app/(mobile-ui)/home/page.tsx index eac55c1116..78d48a144d 100644 --- a/src/app/(mobile-ui)/home/page.tsx +++ b/src/app/(mobile-ui)/home/page.tsx @@ -32,6 +32,7 @@ import InvitesIcon from '@/components/Home/InvitesIcon' import NavigationArrow from '@/components/Global/NavigationArrow' import { updateUserById } from '@/app/actions/users' import { useHaptic } from 'use-haptic' +import { useTranslations } from 'next-intl' import { useActivationStatus } from '@/hooks/useActivationStatus' import ActivationCTAs from '@/components/Home/ActivationCTAs' import LazyLoadErrorBoundary from '@/components/Global/LazyLoadErrorBoundary' @@ -52,6 +53,8 @@ const BALANCE_WARNING_THRESHOLD = parseInt(process.env.NEXT_PUBLIC_BALANCE_WARNI const BALANCE_WARNING_EXPIRY = parseInt(process.env.NEXT_PUBLIC_BALANCE_WARNING_EXPIRY ?? '1814400') // 21 days in seconds export default function Home() { + const t = useTranslations('home') + const tNav = useTranslations('navigation') const { showPermissionModal } = useNotifications() const { balance, isFetchingBalance, spendableBalance, isFetchingSpendableBalance } = useWallet() const { resetFlow: resetClaimBankFlow } = useClaimBankFlow() @@ -175,7 +178,9 @@ export default function Home() { {isActivated && ( triggerHaptic()} href="/rewards" className="flex items-center gap-0"> - Rewards + + {t('rewards')} + )} @@ -183,8 +188,13 @@ export default function Home() {
- - + + - + { + const t = useTranslations('navigation') const { logoutUser, isLoggingOut, user } = useAuth() const logout = async () => { @@ -22,8 +24,8 @@ const LogoutButton = () => { onClick={logout} className="flex w-fit items-center gap-3 px-0 hover:text-gray-1 md:px-3" > - {isLoggingOut ? : Logout} - {isLoggingOut ? 'Logging out...' : 'Logout'} + {isLoggingOut ? : {t('logout')}} + {isLoggingOut ? t('loggingOut') : t('logout')} ) } diff --git a/src/components/Global/TopNavbar/index.tsx b/src/components/Global/TopNavbar/index.tsx index e61c2cf2a6..f3b0f10ba1 100644 --- a/src/components/Global/TopNavbar/index.tsx +++ b/src/components/Global/TopNavbar/index.tsx @@ -1,14 +1,27 @@ 'use client' -import { getHeaderTitle } from '@/utils/general.utils' +import { useTranslations } from 'next-intl' import { usePathname } from 'next/navigation' import LogoutButton from '../LogoutButton' +const PATH_TITLE_KEYS = { + '/home': 'dashboard', + '/send': 'send', + '/request/create': 'request', + '/request/pay': 'pay', + '/cashout': 'cashout', + '/history': 'history', + '/support': 'support', + '/claim': 'claim', +} as const + const TopNavbar = () => { + const t = useTranslations('navigation') const pathname = usePathname() + const titleKey = PATH_TITLE_KEYS[pathname as keyof typeof PATH_TITLE_KEYS] return (
-

{getHeaderTitle(pathname)}

+

{titleKey ? t(titleKey) : 'Peanut'}

) diff --git a/src/components/Global/WalletNavigation/index.tsx b/src/components/Global/WalletNavigation/index.tsx index 89f4c1505f..1571cf2596 100644 --- a/src/components/Global/WalletNavigation/index.tsx +++ b/src/components/Global/WalletNavigation/index.tsx @@ -7,12 +7,13 @@ import { useModalsContext } from '@/context/ModalsContext' import { useUserStore } from '@/redux/hooks' import classNames from 'classnames' import Image from 'next/image' +import { useTranslations } from 'next-intl' import Link from 'next/link' import { usePathname, useRouter } from 'next/navigation' import { useHaptic } from 'use-haptic' type NavPathProps = { - name: string + labelKey: 'send' | 'request' | 'add' | 'withdraw' | 'history' | 'docs' | 'support' href: string icon: IconName size?: number @@ -20,13 +21,13 @@ type NavPathProps = { // todo: update icons based on new the design const desktopPaths: NavPathProps[] = [ - { name: 'Send', href: '/send', icon: 'arrow-up-right', size: 14 }, - { name: 'Request', href: '/request', icon: 'arrow-down-left', size: 14 }, - { name: 'Add', href: '/add-money', icon: 'arrow-down', size: 15 }, - { name: 'Withdraw', href: '/withdraw', icon: 'arrow-up', size: 15 }, - { name: 'History', href: '/history', icon: 'history', size: 15 }, - { name: 'Docs', href: '/en/help', icon: 'docs', size: 14 }, - { name: 'Support', href: '/support', icon: 'peanut-support', size: 14 }, + { labelKey: 'send', href: '/send', icon: 'arrow-up-right', size: 14 }, + { labelKey: 'request', href: '/request', icon: 'arrow-down-left', size: 14 }, + { labelKey: 'add', href: '/add-money', icon: 'arrow-down', size: 15 }, + { labelKey: 'withdraw', href: '/withdraw', icon: 'arrow-up', size: 15 }, + { labelKey: 'history', href: '/history', icon: 'history', size: 15 }, + { labelKey: 'docs', href: '/en/help', icon: 'docs', size: 14 }, + { labelKey: 'support', href: '/support', icon: 'peanut-support', size: 14 }, ] type NavSectionProps = { @@ -35,11 +36,12 @@ type NavSectionProps = { } const NavSection: React.FC = ({ paths, pathName }) => { + const t = useTranslations('navigation') const router = useRouter() return ( <> - {paths.map(({ name, href, icon, size }, index) => ( -
+ {paths.map(({ labelKey, href, icon, size }, index) => ( +
= ({ paths, pathName }) => { }} > - {name} + {t(labelKey)} {index === 4 &&
}
@@ -69,6 +71,7 @@ type MobileNavProps = { } const MobileNav: React.FC = ({ pathName }) => { + const t = useTranslations('navigation') const { setIsSupportModalOpen } = useModalsContext() const { triggerHaptic } = useHaptic() @@ -85,7 +88,7 @@ const MobileNav: React.FC = ({ pathName }) => { )} > - Home + {t('home')} {/* QR Button - Main Action */} @@ -107,13 +110,14 @@ const MobileNav: React.FC = ({ pathName }) => { )} > - Support + {t('support')}
) } const WalletNavigation: React.FC = () => { + const t = useTranslations('navigation') const pathName = usePathname() const { user } = useUserStore() const isLoggedIn = !!user?.user.userId || false @@ -122,7 +126,7 @@ const WalletNavigation: React.FC = () => {
- Peanut Logo + {t('peanutLogoAlt')}
diff --git a/src/components/Home/ActivationCTAs.tsx b/src/components/Home/ActivationCTAs.tsx index f164579285..e1f3d2982e 100644 --- a/src/components/Home/ActivationCTAs.tsx +++ b/src/components/Home/ActivationCTAs.tsx @@ -8,6 +8,7 @@ import { useModalsContext } from '@/context/ModalsContext' import Card from '../Global/Card' import CardLaunchCTABanner from '@/components/Home/CardLaunchCTA/CardLaunchCTABanner' import { useEffect, useMemo, useRef, useState } from 'react' +import { useTranslations } from 'next-intl' import posthog from 'posthog-js' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' import { useCapabilities } from '@/hooks/useCapabilities' @@ -34,42 +35,6 @@ interface StepConfig { dismissable?: boolean } -const STEPS: Record, StepConfig> = { - verify: { - icon: 'globe-lock', - iconBg: 'bg-primary-1', - title: 'Unlock payments', - description: 'Bank deposits, QR codes, and local payment methods', - ctaLabel: 'Unlock now', - href: '/profile/identity-verification', - }, - deposit: { - icon: 'arrow-down', - iconBg: 'bg-primary-1', - title: 'Deposit', - description: 'Add money to make your first payment', - ctaLabel: 'Add money', - href: '/add-money', - }, - card: { - icon: 'credit-card', - iconBg: 'bg-yellow-1', - title: 'Spend anywhere Visa is accepted', - description: 'Use your balance at 150M+ merchants. Online, contactless.', - ctaLabel: 'Get your card', - href: '/card', - dismissable: true, - }, - outbound: { - icon: 'qr-code', - iconBg: 'bg-primary-1', - title: 'Make your first payment', - description: 'Start paying to Pix and MercadoPago QR codes', - ctaLabel: 'Start Spending', - href: '/send', - }, -} - /** * single activation CTA for non-activated users on the home screen. * shows only the current step the user needs to complete. @@ -77,6 +42,8 @@ const STEPS: Record, StepConfig> = { * the deposit/outbound step with a "complete your setup" message. */ export default function ActivationCTAs({ activationStep, onDismissCard }: ActivationCTAsProps) { + const t = useTranslations('home.activation') + const tCommon = useTranslations('common') const router = useRouter() const { setIsQRScannerOpen, openSupportWithMessage } = useModalsContext() const { rails, channelOf, nextActionsForRail } = useCapabilities() @@ -127,6 +94,45 @@ export default function ActivationCTAs({ activationStep, onDismissCard }: Activa const [showProvideEmail, setShowProvideEmail] = useState(false) + const steps: Record, StepConfig> = useMemo( + () => ({ + verify: { + icon: 'globe-lock', + iconBg: 'bg-primary-1', + title: t('steps.verify.title'), + description: t('steps.verify.description'), + ctaLabel: t('steps.verify.cta'), + href: '/profile/identity-verification', + }, + deposit: { + icon: 'arrow-down', + iconBg: 'bg-primary-1', + title: t('steps.deposit.title'), + description: t('steps.deposit.description'), + ctaLabel: t('steps.deposit.cta'), + href: '/add-money', + }, + card: { + icon: 'credit-card', + iconBg: 'bg-yellow-1', + title: t('steps.card.title'), + description: t('steps.card.description'), + ctaLabel: t('steps.card.cta'), + href: '/card', + dismissable: true, + }, + outbound: { + icon: 'qr-code', + iconBg: 'bg-primary-1', + title: t('steps.outbound.title'), + description: t('steps.outbound.description'), + ctaLabel: t('steps.outbound.cta'), + href: '/send', + }, + }), + [t] + ) + // Inline self-heal so the home "Upload document" CTA opens the Sumsub document // re-upload directly, instead of routing to /profile/identity-verification (which // only showed the regions list, forcing the user to hunt for the Upload-document @@ -183,10 +189,9 @@ export default function ActivationCTAs({ activationStep, onDismissCard }: Activa return { icon: 'globe-lock', iconBg: 'bg-primary-1', - title: 'Add your email', - description: - primaryRejectionMessage || 'We need an email address to finish setting up your account.', - ctaLabel: 'Add email', + title: t('addEmail.title'), + description: primaryRejectionMessage || t('addEmail.description'), + ctaLabel: t('addEmail.cta'), href: '', // handled in onClick } } @@ -194,9 +199,9 @@ export default function ActivationCTAs({ activationStep, onDismissCard }: Activa return { icon: 'globe-lock', iconBg: 'bg-primary-1', - title: 'Complete your setup', - description: primaryRejectionMessage || 'We need an updated document before you can add money.', - ctaLabel: 'Upload document', + title: t('completeSetup.title'), + description: primaryRejectionMessage || t('completeSetup.description'), + ctaLabel: t('completeSetup.cta'), href: '/profile/identity-verification', } } @@ -204,15 +209,17 @@ export default function ActivationCTAs({ activationStep, onDismissCard }: Activa return { icon: 'globe-lock', iconBg: 'bg-primary-1', - title: 'Verification issue', - description: 'Contact support for help with your verification.', - ctaLabel: 'Contact support', + title: t('verificationIssue.title'), + description: t('verificationIssue.description'), + ctaLabel: t('verificationIssue.cta'), href: '', // handled in onClick } } - return STEPS[activationStep as Exclude] + return steps[activationStep as Exclude] }, [ + t, + steps, activationStep, hasProviderRejection, hasFixableRejection, @@ -283,7 +290,7 @@ export default function ActivationCTAs({ activationStep, onDismissCard }: Activa {step.dismissable && onDismissCard && ( )}
diff --git a/src/components/Home/CardLaunchCTA/CardLaunchCTABanner.tsx b/src/components/Home/CardLaunchCTA/CardLaunchCTABanner.tsx index fae5b136f8..6fc21ca6f6 100644 --- a/src/components/Home/CardLaunchCTA/CardLaunchCTABanner.tsx +++ b/src/components/Home/CardLaunchCTA/CardLaunchCTABanner.tsx @@ -2,6 +2,7 @@ import { Icon } from '@/components/Global/Icons/Icon' import { Button } from '@/components/0_Bruddle/Button' +import { useTranslations } from 'next-intl' import { useHaptic } from 'use-haptic' interface CardLaunchCTABannerProps { @@ -22,6 +23,7 @@ interface CardLaunchCTABannerProps { * with CarouselCTA); the X stops propagation. */ export default function CardLaunchCTABanner({ onTryDoor, onDismiss }: CardLaunchCTABannerProps) { + const t = useTranslations('home.cardLaunch') const { triggerHaptic } = useHaptic() const handleTryDoor = () => { @@ -43,7 +45,7 @@ export default function CardLaunchCTABanner({ onTryDoor, onDismiss }: CardLaunch >
-

shhhh

-

Tap to find out if you're in

+

+ {t('title')} +

+

{t('subtitle')}

diff --git a/src/components/Home/EnableAutoBalanceBanner.tsx b/src/components/Home/EnableAutoBalanceBanner.tsx index 4015dad022..b3bb4f25df 100644 --- a/src/components/Home/EnableAutoBalanceBanner.tsx +++ b/src/components/Home/EnableAutoBalanceBanner.tsx @@ -1,6 +1,7 @@ 'use client' import { useEffect, useRef, useState } from 'react' +import { useTranslations } from 'next-intl' import { captureMessage } from '@sentry/nextjs' import ActionModal, { type ActionModalButtonProps } from '@/components/Global/ActionModal' import { findActiveCard } from '@/components/Card/cardState.utils' @@ -38,6 +39,8 @@ import { useGrantSessionKey } from '@/hooks/wallet/useGrantSessionKey' * own clean setup pass. */ export default function EnableAutoBalanceBanner() { + const t = useTranslations('home.autoBalance') + const tCommon = useTranslations('common') const { overview } = useRainCardOverview() const { grant, isGranting, lastError } = useGrantSessionKey() // Card id the user chose "Skip for now" for — per card, so skipping a @@ -96,7 +99,11 @@ export default function EnableAutoBalanceBanner() { const ctas: ActionModalButtonProps[] = [ { - text: isGranting ? 'Working…' : hardError || stuckAfterSuccess ? 'Try again' : 'Continue', + text: isGranting + ? t('working') + : hardError || stuckAfterSuccess + ? tCommon('tryAgain') + : tCommon('continue'), variant: 'purple', shadowSize: '4', disabled: isGranting, @@ -117,7 +124,7 @@ export default function EnableAutoBalanceBanner() { // non-dismissible modal. if (errorForThisCard || stuckAfterSuccess) { ctas.push({ - text: 'Skip for now', + text: tCommon('skipForNow'), variant: 'stroke', disabled: isGranting, onClick: () => setDismissedFor(card?.id ?? null), @@ -134,12 +141,8 @@ export default function EnableAutoBalanceBanner() { hideModalCloseButton icon="credit-card" iconContainerClassName="bg-yellow-1" - title="Finish setting up your card" - description={ - hardError || stuckAfterSuccess - ? "We couldn't finish setting up your card. Please try again, or skip for now — we'll prompt you again on your first card payment." - : 'One passkey tap to start using your card.' - } + title={t('title')} + description={hardError || stuckAfterSuccess ? t('descriptionError') : t('description')} ctas={ctas} /> ) diff --git a/src/components/Home/HomeCarouselCTA/CarouselCTA.tsx b/src/components/Home/HomeCarouselCTA/CarouselCTA.tsx index 61e2f38bea..660fc9c2bb 100644 --- a/src/components/Home/HomeCarouselCTA/CarouselCTA.tsx +++ b/src/components/Home/HomeCarouselCTA/CarouselCTA.tsx @@ -3,6 +3,7 @@ import { Icon, type IconName } from '@/components/Global/Icons/Icon' import type { StaticImageData } from 'next/image' import Image from 'next/image' +import { useTranslations } from 'next-intl' import React from 'react' import { twMerge } from 'tailwind-merge' import { CAROUSEL_CLOSE_BUTTON_POSITION, CAROUSEL_CLOSE_ICON_SIZE } from '@/constants/carousel.consts' @@ -37,6 +38,7 @@ const CarouselCTA = ({ logoSize = 36, isPerkClaim, }: CarouselCTAProps) => { + const t = useTranslations('home.carousel') const { triggerHaptic } = useHaptic() const handleClose = (e: React.MouseEvent) => { @@ -58,16 +60,16 @@ const CarouselCTA = ({ // Get descriptive title for accessibility const getAriaLabel = () => { if (typeof title === 'string') { - return `Close ${title}` + return t('closeTitled', { title }) } // For React nodes (e.g., "Unlock QR code payments"), use icon as hint if (icon === 'shield') { - return 'Close verification prompt' + return t('closeVerificationPrompt') } if (icon === 'bell') { - return 'Close notification prompt' + return t('closeNotificationPrompt') } - return 'Close prompt' + return t('closePrompt') } return ( @@ -77,7 +79,7 @@ const CarouselCTA = ({ > {/* Close button or pink dot indicator for perk claims */} {isPerkClaim ? ( -
+
) : ( diff --git a/src/components/Home/HomeCarouselCTA/index.tsx b/src/components/Home/HomeCarouselCTA/index.tsx index 2e90393d23..6be536278b 100644 --- a/src/components/Home/HomeCarouselCTA/index.tsx +++ b/src/components/Home/HomeCarouselCTA/index.tsx @@ -1,6 +1,7 @@ 'use client' import { useState, useCallback, useMemo, useEffect } from 'react' +import { useTranslations } from 'next-intl' import { useQuery, useQueryClient } from '@tanstack/react-query' import Carousel from '@/components/Global/Carousel' import CarouselCTA from './CarouselCTA' @@ -13,6 +14,7 @@ import { extractInviteeName } from '@/utils/general.utils' import PerkClaimModal from '../PerkClaimModal' const HomeCarouselCTA = () => { + const t = useTranslations('home.carousel') const { carouselCTAs, dismissCTA } = useHomeCarouselCTAs() const { user } = useAuth() const queryClient = useQueryClient() @@ -53,20 +55,14 @@ const HomeCarouselCTA = () => { return claimablePerks.map((perk) => { const inviteeName = extractInviteeName(perk.reason) const description = inviteeName ? ( -

- {inviteeName} used Peanut. Tap to claim. -

+

{t.rich('usedPeanutTapToClaim', { inviteeName, name: (chunks) => {chunks} })}

) : ( -

Tap to claim your reward.

+

{t('tapToClaim')}

) return { id: `perk-${perk.id}`, - title: ( -

- +${perk.amountUsd} reward ready! -

- ), + title:

{t.rich('rewardReady', { amount: perk.amountUsd, b: (chunks) => {chunks} })}

, description, icon: 'gift' as IconName, iconContainerClassName: 'bg-primary-1', @@ -75,7 +71,7 @@ const HomeCarouselCTA = () => { iconSize: 16, } }) - }, [claimablePerks]) + }, [claimablePerks, t]) // Combine perk CTAs (first) with regular CTAs const allCTAs = useMemo(() => { diff --git a/src/components/Home/HomeHistory.tsx b/src/components/Home/HomeHistory.tsx index aa610bec28..a6c3fc93f8 100644 --- a/src/components/Home/HomeHistory.tsx +++ b/src/components/Home/HomeHistory.tsx @@ -11,6 +11,7 @@ import { useAuth } from '@/context/authContext' import { useQueryClient } from '@tanstack/react-query' import { TRANSACTIONS } from '@/constants/query.consts' import Link from 'next/link' +import { useTranslations } from 'next-intl' import { useCallback, useEffect, useMemo, useState } from 'react' import { twMerge } from 'tailwind-merge' import Card from '../Global/Card' @@ -56,6 +57,7 @@ const HomeHistory = ({ /** when true, hides the "No activity yet" empty state (pre-activation users) but still shows history if exists */ hideEmptyState?: boolean }) => { + const t = useTranslations('home.history') const { user } = useUserStore() const isLoggedIn = !!user?.user.userId || false // Only filter when user is requesting for some different user's history @@ -307,7 +309,7 @@ const HomeHistory = ({ if (isLoading) { return (
-

Activity

+

{t('activity')}

{Array.from({ length: 5 }).map((_, index) => ( @@ -331,11 +333,11 @@ const HomeHistory = ({ } return (
-

Activity

{' '} +

{t('activity')}

{' '}
) @@ -359,7 +361,7 @@ const HomeHistory = ({ if (!isLoading && !combinedEntries.length && !hasSourceEntries) { return (
-

Activity

+

{t('activity')}

{isViewingOwnHistory && user && (() => { @@ -371,18 +373,14 @@ const HomeHistory = ({ ) : ( ) })()} {!isViewingOwnHistory && ( - + )}
) @@ -393,7 +391,7 @@ const HomeHistory = ({ {/* link to the full history page */} {pendingRequests.length > 0 && ( <> -

Pending transactions

+

{t('pendingTransactions')}

{/* map over the latest entries and render transactioncard */} {pendingRequests.map((item, index) => { @@ -423,10 +421,10 @@ const HomeHistory = ({ )} {!isViewingOwnHistory ? ( -

Latest Transactions

+

{t('latestTransactions')}

) : ( triggerHaptic()}> -

Activity

+

{t('activity')}

)} diff --git a/src/components/Home/PerkClaimModal.tsx b/src/components/Home/PerkClaimModal.tsx index e263c307f6..5e2f548f68 100644 --- a/src/components/Home/PerkClaimModal.tsx +++ b/src/components/Home/PerkClaimModal.tsx @@ -1,6 +1,7 @@ 'use client' import { useState, useCallback, useRef, useEffect } from 'react' +import { useTranslations } from 'next-intl' import { useQueryClient } from '@tanstack/react-query' import { perksApi, type PendingPerk } from '@/services/perks' import { Icon } from '@/components/Global/Icons/Icon' @@ -23,18 +24,6 @@ type ClaimPhase = 'idle' | 'holding' | 'opening' | 'revealed' | 'exiting' const SURPRISE_CLAIM_COUNT_KEY = 'rewards_surprise_claim_count' -// Approved copy — see notion: notifs-copy-33083811757980638a27effc79a033f3 -const SURPRISE_COPY = { - first: { - title: (amount: number) => `You just earned $${amount}!`, - description: 'Check out your rewards now and earn more.', - }, - subsequent: { - title: (amount: number) => `You just earned $${amount}!`, - description: 'Check out your rewards and how to earn more.', - }, -} as const - interface PerkClaimModalProps { perk: PendingPerk visible: boolean @@ -187,6 +176,8 @@ interface SuccessModalProps { * Uses icon/title/description props for standard vertical centered layout. */ function SuccessModal({ perk, claimPhase, onClose, onDismiss }: SuccessModalProps) { + const t = useTranslations('home.perk') + const tCommon = useTranslations('common') const inviteeName = perk.inviteeName ?? extractInviteeName(perk.reason) const { triggerHaptic } = useHaptic() const router = useRouter() @@ -215,7 +206,6 @@ function SuccessModal({ perk, claimPhase, onClose, onDismiss }: SuccessModalProp }, []) // eslint-disable-line react-hooks/exhaustive-deps — triggerHaptic is stable const isSurpriseMoment = claimCount < 2 - const surpriseCopy = SURPRISE_COPY[claimCount === 0 ? 'first' : 'subsequent'] return ( <> @@ -233,19 +223,24 @@ function SuccessModal({ perk, claimPhase, onClose, onDismiss }: SuccessModalProp

+${perk.amountUsd}

{isSurpriseMoment ? ( <> + {/* Approved copy — see notion: notifs-copy-33083811757980638a27effc79a033f3 */}

- {surpriseCopy.title(perk.amountUsd)} + {t('surpriseTitle', { amount: perk.amountUsd })} +

+

+ {claimCount === 0 ? t('surpriseDescriptionFirst') : t('surpriseDescriptionNext')}

-

{surpriseCopy.description}

) : inviteeName ? (

- {inviteeName} - used Peanut + {t.rich('usedPeanut', { + inviteeName, + name: (chunks) => {chunks}, + })}

) : ( -

Reward claimed!

+

{t('rewardClaimed')}

)}
} @@ -268,16 +263,16 @@ function SuccessModal({ perk, claimPhase, onClose, onDismiss }: SuccessModalProp setIsInviteModalOpen(true) }} > - Share & earn + {t('shareAndEarn')} ) : ( <>

- Invite friends to earn more + {t('inviteFriendsToEarnMore')}

)} @@ -317,6 +312,7 @@ interface GiftBoxContentProps { * Gift box with hold-to-claim interaction */ function GiftBoxContent({ perk, onHoldComplete, claimPhase }: GiftBoxContentProps) { + const t = useTranslations('home.perk') const { holdProgress, isShaking, shakeIntensity, buttonProps } = useHoldToClaim({ onComplete: onHoldComplete, disabled: claimPhase !== 'idle', @@ -347,7 +343,10 @@ function GiftBoxContent({ perk, onHoldComplete, claimPhase }: GiftBoxContentProp {/* Title */}

- {inviteeName} used Peanut + {t.rich('usedPeanut', { + inviteeName: inviteeName ?? '', + name: (chunks) => {chunks}, + })}

{/* Gift box wrapper - only this shakes */} @@ -470,7 +469,7 @@ function GiftBoxContent({ perk, onHoldComplete, claimPhase }: GiftBoxContentProp
{/* Instructions */} -

Hold to unwrap your reward

+

{t('holdToUnwrap')}

) } diff --git a/src/components/Home/WelcomeUnlockModal/index.tsx b/src/components/Home/WelcomeUnlockModal/index.tsx index b1f2df47f1..774ad7b2f7 100644 --- a/src/components/Home/WelcomeUnlockModal/index.tsx +++ b/src/components/Home/WelcomeUnlockModal/index.tsx @@ -1,5 +1,6 @@ 'use client' import React, { useEffect, useMemo, useRef, useState } from 'react' +import { useTranslations } from 'next-intl' import ActionModal from '@/components/Global/ActionModal' import type { IconName } from '@/components/Global/Icons/Icon' import InfoCard from '@/components/Global/InfoCard' @@ -11,6 +12,7 @@ import { ANALYTICS_EVENTS, MODAL_TYPES } from '@/constants/analytics.consts' /** A feature unlocked by identity verification, tagged by which channel unlocked it. */ type UnlockItem = { + id: string title: React.ReactNode | string /** `bank` = shown when bank rails are enabled (US/EU/MX users). `qr` = shown * when QR-pay is enabled (LATAM users). Bullets in both groups display when @@ -18,57 +20,8 @@ type UnlockItem = { type: 'bank' | 'qr' } -/** - * Static list of unlocked-feature bullets. `countryTitle` personalizes the - * LATAM bank-transfer bullet (the user's verified ID country). - */ -const getUnlockItems = (countryTitle?: string): UnlockItem[] => [ - { - title: ( -

- QR Payments in Argentina and Brazil -

- ), - type: 'bank', - }, - { - title: ( -

- United States ACH and Wire transfers -

- ), - type: 'bank', - }, - { - title: ( -

- Europe SEPA transfers (+30 countries) -

- ), - type: 'bank', - }, - { - title: ( -

- Mexico SPEI transfers -

- ), - type: 'bank', - }, - { - // Important: this uses the user's verified ID country, not their selected country. - // Example: user picks Argentina but has Brazil ID → they get QR in Argentina - // but bank transfers only work in Brazil (their verified country). - title: `Bank transfers to your own accounts in ${countryTitle || 'your country'}`, - type: 'qr', - }, - { - title: 'QR Payments in Brazil and Argentina', - type: 'qr', - }, -] - const WelcomeUnlockModal = ({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) => { + const t = useTranslations('home.welcomeUnlock') const [approvedCountryData, setApprovedCountryData] = useState(null) // Provider-blind: the celebration modal splits its copy by what CHANNEL the @@ -97,9 +50,27 @@ const WelcomeUnlockModal = ({ isOpen, onClose }: { isOpen: boolean; onClose: () return 'none' }, [hasBankUnlock, hasQrUnlock]) - const items = useMemo(() => { - return getUnlockItems(approvedCountryData?.title) - }, [approvedCountryData?.title]) + /** + * Unlocked-feature bullets. The verified-ID country personalizes the LATAM + * bank-transfer bullet — the user's verified ID country, not their selected + * country. Example: user picks Argentina but has Brazil ID → they get QR in + * Argentina but bank transfers only work in Brazil (their verified country). + */ + const items = useMemo((): UnlockItem[] => { + const b = (chunks: React.ReactNode) => {chunks} + return [ + { id: 'qrArBr', title:

{t.rich('items.qrArBr', { b })}

, type: 'bank' }, + { id: 'usTransfers', title:

{t.rich('items.usTransfers', { b })}

, type: 'bank' }, + { id: 'euTransfers', title:

{t.rich('items.euTransfers', { b })}

, type: 'bank' }, + { id: 'mxTransfers', title:

{t.rich('items.mxTransfers', { b })}

, type: 'bank' }, + { + id: 'ownCountryTransfers', + title: t('items.ownCountryTransfers', { country: approvedCountryData?.title || t('yourCountry') }), + type: 'qr', + }, + { id: 'qrBrAr', title: t('items.qrBrAr'), type: 'qr' }, + ] + }, [t, approvedCountryData?.title]) // Personalize the "Bank transfers to your own accounts in {country}" bullet // off the user's first enabled qr-only-or-pay-capable LATAM rail. Provider- @@ -133,10 +104,10 @@ const WelcomeUnlockModal = ({ isOpen, onClose }: { isOpen: boolean; onClose: () onClose={onClose} icon={'globe-lock' as IconName} iconContainerClassName="bg-primary-1 text-black" - title="🎉 You're unlocked" + title={t('title')} ctas={[ { - text: 'Start sending money', + text: t('cta'), onClick: () => { posthog.capture(ANALYTICS_EVENTS.MODAL_CTA_CLICKED, { modal_type: MODAL_TYPES.KYC_COMPLETED, @@ -151,7 +122,7 @@ const WelcomeUnlockModal = ({ isOpen, onClose }: { isOpen: boolean; onClose: () ]} content={
-

You can now:

+

{t('youCanNow')}

( + + {children} + +) +const render = (ui: React.ReactElement) => rtlRender(ui, { wrapper: IntlWrapper }) let mockRails: Array<{ id: string diff --git a/src/components/Home/__tests__/EnableAutoBalanceBanner.test.tsx b/src/components/Home/__tests__/EnableAutoBalanceBanner.test.tsx index 29dd0e95d8..846ff53392 100644 --- a/src/components/Home/__tests__/EnableAutoBalanceBanner.test.tsx +++ b/src/components/Home/__tests__/EnableAutoBalanceBanner.test.tsx @@ -15,7 +15,16 @@ * stuck-after-success escape + Sentry/console signal. */ import React from 'react' -import { render, screen, fireEvent, act } from '@testing-library/react' +import { render as rtlRender, screen, fireEvent, act } from '@testing-library/react' +import { NextIntlClientProvider } from 'next-intl' +import en from '@/i18n/app/messages/en.json' + +const IntlWrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + +) +const render = (ui: React.ReactElement) => rtlRender(ui, { wrapper: IntlWrapper }) import type { GrantSessionKeyError } from '@/hooks/wallet/useGrantSessionKey' const mockGrant = jest.fn, []>() diff --git a/src/constants/general.consts.ts b/src/constants/general.consts.ts index ffd5d134f2..b58fd04b45 100644 --- a/src/constants/general.consts.ts +++ b/src/constants/general.consts.ts @@ -230,17 +230,6 @@ export const nativeCurrencyAddresses: string[] = [ '0x0000000000000000000000000000000000000000', ] -export const pathTitles: { [key: string]: string } = { - '/home': 'Dashboard', - '/send': 'Send', - '/request/create': 'Request', - '/request/pay': 'Pay', - '/cashout': 'Cashout', - '/history': 'History', - '/support': 'Support', - '/claim': 'Claim', -} - export const STABLE_COINS = ['USDC', 'USDT', 'DAI', 'BUSD'] export const ROUTE_NOT_FOUND_ERROR = diff --git a/src/hooks/useHomeCarouselCTAs.tsx b/src/hooks/useHomeCarouselCTAs.tsx index 095aea254c..ff94c3cb02 100644 --- a/src/hooks/useHomeCarouselCTAs.tsx +++ b/src/hooks/useHomeCarouselCTAs.tsx @@ -2,6 +2,7 @@ import { type IconName } from '@/components/Global/Icons/Icon' import { useAuth } from '@/context/authContext' +import { useTranslations } from 'next-intl' import { useEffect, useMemo, useState, useCallback, useRef } from 'react' import { getUserPreferences, updateUserPreferences } from '@/utils/general.utils' import { useNotifications } from './useNotifications' @@ -71,6 +72,7 @@ const getDismissedCTAs = (userId: string | undefined): Map => { } export const useHomeCarouselCTAs = () => { + const t = useTranslations('home.carousel') const [carouselCTAs, setCarouselCTAs] = useState([]) const { user } = useAuth() const dismissedRef = useRef>(new Map()) @@ -132,6 +134,7 @@ export const useHomeCarouselCTAs = () => { const generateCarouselCTAs = useCallback(() => { const _carouselCTAs: CarouselCTA[] = [] + const b = (chunks: React.ReactNode) => {chunks} // Home CTAs gate on "user can do a bank deposit or a pay" — provider-blind. // Rain (card) does NOT count; a card-only user must still see the verify CTA. @@ -146,16 +149,8 @@ export const useHomeCarouselCTAs = () => { if (!underMaintenanceConfig.disableCardPioneers && hasCardAccessGranted === false) { _carouselCTAs.push({ id: 'card-pioneer', - title: ( - - Get your Peanut Card - - ), - description: ( - - Closed beta. Badges skip the line. $10 unlocks on your first $100 spend. - - ), + title: {t.rich('card.title', { b })}, + description: {t.rich('card.description', { b })}, iconContainerClassName: 'bg-purple-1', icon: 'credit-card', onClick: () => { @@ -169,8 +164,8 @@ export const useHomeCarouselCTAs = () => { if (!isLatamUser && isActivated && !hasSentInvites) { _carouselCTAs.push({ id: 'invite-friends', - title: 'Invite friends. Earn rewards', - description: 'Earn rewards every time your friends use Peanut.', + title: t('invite.title'), + description: t('invite.description'), icon: 'invite-heart', logo: STAR_STRAIGHT_ICON, logoSize: 30, @@ -184,8 +179,8 @@ export const useHomeCarouselCTAs = () => { if (oneSignalInitialized && !isPermissionGranted && !isPushOptedIn && (isPwa || isCapacitor())) { _carouselCTAs.push({ id: 'notification-prompt', - title: 'Stay in the loop!', - description: 'Turn on notifications and get alerts for all your wallet activity.', + title: t('notifications.title'), + description: t('notifications.description'), icon: 'bell', onClick: async () => { // On the web PWA a denied browser permission can't be re-prompted — @@ -201,7 +196,7 @@ export const useHomeCarouselCTAs = () => { // 'default' = browser suppressed prompt (policy/Shields) or // user dismissed it — calling again won't help this session. if (result === 'default') { - toast.warning('Notifications blocked by your browser. Enable them in site settings and reload.') + toast.warning(t('notifications.blockedToast')) dismissCTA('notification-prompt') } }, @@ -211,8 +206,8 @@ export const useHomeCarouselCTAs = () => { if (deviceType === DeviceType.IOS && !isPwa && !isCapacitor()) { _carouselCTAs.push({ id: 'ios-pwa-install', - title: 'Add Peanut to your home screen', - description: 'Follow a quick guide to add the app to your home screen, no download needed.', + title: t('iosPwa.title'), + description: t('iosPwa.description'), iconContainerClassName: 'bg-secondary-1', icon: 'mobile-install', onClick: () => { @@ -227,16 +222,8 @@ export const useHomeCarouselCTAs = () => { if (hasKycApproval && hasMadeQrPayment === false) { _carouselCTAs.push({ id: 'qr-payment', - title: ( - - Pay with QR code payments - - ), - description: ( - - Get the best exchange rate, pay like a local and earn rewards. - - ), + title: {t.rich('qrPay.title', { b })}, + description: {t.rich('qrPay.description', { b })}, iconContainerClassName: 'bg-secondary-1', icon: 'qr-code', onClick: () => { @@ -252,16 +239,8 @@ export const useHomeCarouselCTAs = () => { if (isLatamUser && isActivated && !hasSentInvites) { _carouselCTAs.push({ id: 'latam-cashback-invite', - title: ( - - Earn rewards on QR payments - - ), - description: ( - - Invite friends to earn more rewards. The more they use, the more you earn! - - ), + title: {t.rich('latamInvite.title', { b })}, + description: {t.rich('latamInvite.description', { b })}, iconContainerClassName: 'bg-secondary-1', icon: 'gift', onClick: () => { @@ -279,12 +258,8 @@ export const useHomeCarouselCTAs = () => { if (isActivated && !hasSupportSurvivorBadge) { _carouselCTAs.push({ id: 'bug-bounty', - title: ( - - Help us improve and get $5! - - ), - description: 'Report a bug. Get rewarded! No questions asked.', + title: {t.rich('bugBounty.title', { b })}, + description: t('bugBounty.description'), iconContainerClassName: 'bg-primary-1', icon: 'bug', iconSize: 20, @@ -305,16 +280,8 @@ export const useHomeCarouselCTAs = () => { if (!hasKycApproval && !isInFlight && hasCardAccessGranted === false) { _carouselCTAs.push({ id: 'kyc-prompt', - title: ( - - Unlock QR code payments - - ), - description: ( - - Confirm your ID to pay with Mercado Pago and PIX QR codes - - ), + title: {t.rich('kyc.title', { b })}, + description: {t.rich('kyc.description', { b })}, iconContainerClassName: 'bg-secondary-1', icon: 'qr-code', iconSize: 16, @@ -326,6 +293,7 @@ export const useHomeCarouselCTAs = () => { setCarouselCTAs(_carouselCTAs.filter((cta) => !dismissedRef.current.has(cta.id))) }, [ + t, user?.user?.userId, isPermissionGranted, isPermissionDenied, diff --git a/src/i18n/app/messages/en.json b/src/i18n/app/messages/en.json index b2f4c06c6c..413e38e11c 100644 --- a/src/i18n/app/messages/en.json +++ b/src/i18n/app/messages/en.json @@ -10,7 +10,160 @@ "close": "Close", "loading": "Loading...", "somethingWentWrong": "Something went wrong", - "copiedToClipboard": "Copied to clipboard" + "copiedToClipboard": "Copied to clipboard", + "maybeLater": "Maybe later", + "tryAgain": "Try again", + "skipForNow": "Skip for now" + }, + "navigation": { + "home": "Home", + "send": "Send", + "request": "Request", + "add": "Add", + "withdraw": "Withdraw", + "history": "History", + "docs": "Docs", + "support": "Support", + "logout": "Logout", + "loggingOut": "Logging out...", + "dashboard": "Dashboard", + "pay": "Pay", + "cashout": "Cashout", + "claim": "Claim", + "peanutLogoAlt": "Peanut Logo" + }, + "home": { + "rewards": "Rewards", + "activation": { + "steps": { + "verify": { + "title": "Unlock payments", + "description": "Bank deposits, QR codes, and local payment methods", + "cta": "Unlock now" + }, + "deposit": { + "title": "Deposit", + "description": "Add money to make your first payment", + "cta": "Add money" + }, + "card": { + "title": "Spend anywhere Visa is accepted", + "description": "Use your balance at 150M+ merchants. Online, contactless.", + "cta": "Get your card" + }, + "outbound": { + "title": "Make your first payment", + "description": "Start paying to Pix and MercadoPago QR codes", + "cta": "Start Spending" + } + }, + "addEmail": { + "title": "Add your email", + "description": "We need an email address to finish setting up your account.", + "cta": "Add email" + }, + "completeSetup": { + "title": "Complete your setup", + "description": "We need an updated document before you can add money.", + "cta": "Upload document" + }, + "verificationIssue": { + "title": "Verification issue", + "description": "Contact support for help with your verification.", + "cta": "Contact support" + } + }, + "autoBalance": { + "title": "Finish setting up your card", + "description": "One passkey tap to start using your card.", + "descriptionError": "We couldn't finish setting up your card. Please try again, or skip for now — we'll prompt you again on your first card payment.", + "working": "Working…" + }, + "cardLaunch": { + "title": "shhhh", + "subtitle": "Tap to find out if you're in", + "cta": "Try the door →", + "dismissAriaLabel": "Dismiss launch announcement" + }, + "history": { + "activity": "Activity", + "pendingTransactions": "Pending transactions", + "latestTransactions": "Latest Transactions", + "networkErrorTitle": "Couldn't load activity", + "networkErrorDescription": "Check your connection and try again.", + "errorTitle": "Error loading activity!", + "errorDescription": "Please contact Support.", + "noActivityTitle": "No activity yet!", + "noTransactionsTitle": "No transactions yet!", + "emptyDescription": "Start by sending or requesting money" + }, + "welcomeUnlock": { + "title": "🎉 You're unlocked", + "cta": "Start sending money", + "youCanNow": "You can now:", + "yourCountry": "your country", + "items": { + "qrArBr": "QR Payments in Argentina and Brazil", + "usTransfers": "United States ACH and Wire transfers", + "euTransfers": "Europe SEPA transfers (+30 countries)", + "mxTransfers": "Mexico SPEI transfers", + "ownCountryTransfers": "Bank transfers to your own accounts in {country}", + "qrBrAr": "QR Payments in Brazil and Argentina" + } + }, + "perk": { + "surpriseTitle": "You just earned ${amount}!", + "surpriseDescriptionFirst": "Check out your rewards now and earn more.", + "surpriseDescriptionNext": "Check out your rewards and how to earn more.", + "usedPeanut": "{inviteeName} used Peanut", + "rewardClaimed": "Reward claimed!", + "shareAndEarn": "Share & earn", + "inviteFriendsToEarnMore": "Invite friends to earn more", + "holdToUnwrap": "Hold to unwrap your reward" + }, + "carousel": { + "rewardReady": "+${amount} reward ready!", + "usedPeanutTapToClaim": "{inviteeName} used Peanut. Tap to claim.", + "tapToClaim": "Tap to claim your reward.", + "card": { + "title": "Get your Peanut Card", + "description": "Closed beta. Badges skip the line. $10 unlocks on your first $100 spend." + }, + "invite": { + "title": "Invite friends. Earn rewards", + "description": "Earn rewards every time your friends use Peanut." + }, + "notifications": { + "title": "Stay in the loop!", + "description": "Turn on notifications and get alerts for all your wallet activity.", + "blockedToast": "Notifications blocked by your browser. Enable them in site settings and reload." + }, + "iosPwa": { + "title": "Add Peanut to your home screen", + "description": "Follow a quick guide to add the app to your home screen, no download needed." + }, + "qrPay": { + "title": "Pay with QR code payments", + "description": "Get the best exchange rate, pay like a local and earn rewards." + }, + "latamInvite": { + "title": "Earn rewards on QR payments", + "description": "Invite friends to earn more rewards. The more they use, the more you earn!" + }, + "bugBounty": { + "title": "Help us improve and get $5!", + "description": "Report a bug. Get rewarded! No questions asked." + }, + "kyc": { + "title": "Unlock QR code payments", + "description": "Confirm your ID to pay with Mercado Pago and PIX QR codes" + }, + "closePrompt": "Close prompt", + "closeTitled": "Close {title}", + "closeVerificationPrompt": "Close verification prompt", + "closeNotificationPrompt": "Close notification prompt", + "claimablePerk": "Claimable perk" + } }, "profile": { "language": "Language" diff --git a/src/i18n/app/messages/es-419.json b/src/i18n/app/messages/es-419.json index 44b5c4ef83..d016a0e1ca 100644 --- a/src/i18n/app/messages/es-419.json +++ b/src/i18n/app/messages/es-419.json @@ -10,7 +10,160 @@ "close": "Cerrar", "loading": "Cargando...", "somethingWentWrong": "Algo salió mal", - "copiedToClipboard": "Copiado al portapapeles" + "copiedToClipboard": "Copiado al portapapeles", + "maybeLater": "Quizás más tarde", + "tryAgain": "Intentar de nuevo", + "skipForNow": "Omitir por ahora" + }, + "navigation": { + "home": "Inicio", + "send": "Enviar", + "request": "Solicitar", + "add": "Agregar", + "withdraw": "Retirar", + "history": "Historial", + "docs": "Docs", + "support": "Soporte", + "logout": "Cerrar sesión", + "loggingOut": "Cerrando sesión...", + "dashboard": "Panel", + "pay": "Pagar", + "cashout": "Retiro", + "claim": "Reclamar", + "peanutLogoAlt": "Logo de Peanut" + }, + "home": { + "rewards": "Recompensas", + "activation": { + "steps": { + "verify": { + "title": "Desbloquea pagos", + "description": "Depósitos bancarios, códigos QR y métodos de pago locales", + "cta": "Desbloquear ahora" + }, + "deposit": { + "title": "Deposita", + "description": "Agrega dinero para hacer tu primer pago", + "cta": "Agregar dinero" + }, + "card": { + "title": "Gasta donde acepten Visa", + "description": "Usa tu saldo en más de 150M de comercios. En línea y sin contacto.", + "cta": "Consigue tu tarjeta" + }, + "outbound": { + "title": "Haz tu primer pago", + "description": "Empieza a pagar códigos QR de Pix y MercadoPago", + "cta": "Empezar a gastar" + } + }, + "addEmail": { + "title": "Agrega tu email", + "description": "Necesitamos un email para terminar de configurar tu cuenta.", + "cta": "Agregar email" + }, + "completeSetup": { + "title": "Completa tu configuración", + "description": "Necesitamos un documento actualizado antes de que puedas agregar dinero.", + "cta": "Subir documento" + }, + "verificationIssue": { + "title": "Problema de verificación", + "description": "Contacta a soporte para recibir ayuda con tu verificación.", + "cta": "Contactar soporte" + } + }, + "autoBalance": { + "title": "Termina de configurar tu tarjeta", + "description": "Un toque con tu passkey para empezar a usar tu tarjeta.", + "descriptionError": "No pudimos terminar de configurar tu tarjeta. Intenta de nuevo u omítelo por ahora: te lo volveremos a pedir en tu primer pago con tarjeta.", + "working": "Procesando…" + }, + "cardLaunch": { + "title": "shhhh", + "subtitle": "Toca para saber si estás dentro", + "cta": "Prueba la puerta →", + "dismissAriaLabel": "Cerrar anuncio de lanzamiento" + }, + "history": { + "activity": "Actividad", + "pendingTransactions": "Transacciones pendientes", + "latestTransactions": "Últimas transacciones", + "networkErrorTitle": "No pudimos cargar la actividad", + "networkErrorDescription": "Revisa tu conexión e intenta de nuevo.", + "errorTitle": "¡Error al cargar la actividad!", + "errorDescription": "Por favor contacta a Soporte.", + "noActivityTitle": "¡Aún no hay actividad!", + "noTransactionsTitle": "¡Aún no hay transacciones!", + "emptyDescription": "Empieza enviando o solicitando dinero" + }, + "welcomeUnlock": { + "title": "🎉 Ya tienes acceso", + "cta": "Empieza a enviar dinero", + "youCanNow": "Ahora puedes:", + "yourCountry": "tu país", + "items": { + "qrArBr": "Pagos QR en Argentina y Brasil", + "usTransfers": "Transferencias ACH y Wire en Estados Unidos", + "euTransfers": "Transferencias SEPA en Europa (+30 países)", + "mxTransfers": "Transferencias SPEI en México", + "ownCountryTransfers": "Transferencias bancarias a tus propias cuentas en {country}", + "qrBrAr": "Pagos QR en Brasil y Argentina" + } + }, + "perk": { + "surpriseTitle": "¡Acabas de ganar ${amount}!", + "surpriseDescriptionFirst": "Mira tus recompensas ahora y gana más.", + "surpriseDescriptionNext": "Mira tus recompensas y cómo ganar más.", + "usedPeanut": "{inviteeName} usó Peanut", + "rewardClaimed": "¡Recompensa reclamada!", + "shareAndEarn": "Comparte y gana", + "inviteFriendsToEarnMore": "Invita amigos para ganar más", + "holdToUnwrap": "Mantén presionado para abrir tu recompensa" + }, + "carousel": { + "rewardReady": "¡Recompensa de +${amount} lista!", + "usedPeanutTapToClaim": "{inviteeName} usó Peanut. Toca para reclamar.", + "tapToClaim": "Toca para reclamar tu recompensa.", + "card": { + "title": "Consigue tu Peanut Card", + "description": "Beta cerrada. Las insignias saltan la fila. Desbloquea $10 con tu primer gasto de $100." + }, + "invite": { + "title": "Invita amigos. Gana recompensas", + "description": "Gana recompensas cada vez que tus amigos usan Peanut." + }, + "notifications": { + "title": "¡Mantente al tanto!", + "description": "Activa las notificaciones y recibe alertas de toda la actividad de tu billetera.", + "blockedToast": "Tu navegador bloqueó las notificaciones. Actívalas en la configuración del sitio y recarga." + }, + "iosPwa": { + "title": "Agrega Peanut a tu pantalla de inicio", + "description": "Sigue una guía rápida para agregar la app a tu pantalla de inicio, sin descargas." + }, + "qrPay": { + "title": "Paga con códigos QR", + "description": "Obtén el mejor tipo de cambio, paga como un local y gana recompensas." + }, + "latamInvite": { + "title": "Gana recompensas en pagos QR", + "description": "Invita amigos para ganar más recompensas. ¡Cuanto más usan, más ganas!" + }, + "bugBounty": { + "title": "Ayúdanos a mejorar y ¡gana $5!", + "description": "Reporta un error. ¡Recibe una recompensa! Sin preguntas." + }, + "kyc": { + "title": "Desbloquea pagos con QR", + "description": "Confirma tu identidad para pagar códigos QR de Mercado Pago y PIX" + }, + "closePrompt": "Cerrar aviso", + "closeTitled": "Cerrar {title}", + "closeVerificationPrompt": "Cerrar aviso de verificación", + "closeNotificationPrompt": "Cerrar aviso de notificaciones", + "claimablePerk": "Recompensa disponible" + } }, "profile": { "language": "Idioma" diff --git a/src/i18n/app/messages/pt-BR.json b/src/i18n/app/messages/pt-BR.json index 5be6ec1afa..154d607f98 100644 --- a/src/i18n/app/messages/pt-BR.json +++ b/src/i18n/app/messages/pt-BR.json @@ -10,7 +10,160 @@ "close": "Fechar", "loading": "Carregando...", "somethingWentWrong": "Algo deu errado", - "copiedToClipboard": "Copiado para a área de transferência" + "copiedToClipboard": "Copiado para a área de transferência", + "maybeLater": "Talvez depois", + "tryAgain": "Tentar de novo", + "skipForNow": "Pular por enquanto" + }, + "navigation": { + "home": "Início", + "send": "Enviar", + "request": "Cobrar", + "add": "Adicionar", + "withdraw": "Sacar", + "history": "Histórico", + "docs": "Docs", + "support": "Suporte", + "logout": "Sair", + "loggingOut": "Saindo...", + "dashboard": "Painel", + "pay": "Pagar", + "cashout": "Saque", + "claim": "Resgatar", + "peanutLogoAlt": "Logo da Peanut" + }, + "home": { + "rewards": "Recompensas", + "activation": { + "steps": { + "verify": { + "title": "Desbloqueie pagamentos", + "description": "Depósitos bancários, códigos QR e métodos de pagamento locais", + "cta": "Desbloquear agora" + }, + "deposit": { + "title": "Deposite", + "description": "Adicione dinheiro para fazer seu primeiro pagamento", + "cta": "Adicionar dinheiro" + }, + "card": { + "title": "Gaste onde a Visa é aceita", + "description": "Use seu saldo em mais de 150M de estabelecimentos. Online e por aproximação.", + "cta": "Peça seu cartão" + }, + "outbound": { + "title": "Faça seu primeiro pagamento", + "description": "Comece a pagar códigos QR do Pix e do MercadoPago", + "cta": "Começar a gastar" + } + }, + "addEmail": { + "title": "Adicione seu e-mail", + "description": "Precisamos de um e-mail para terminar de configurar sua conta.", + "cta": "Adicionar e-mail" + }, + "completeSetup": { + "title": "Complete sua configuração", + "description": "Precisamos de um documento atualizado antes que você possa adicionar dinheiro.", + "cta": "Enviar documento" + }, + "verificationIssue": { + "title": "Problema na verificação", + "description": "Fale com o suporte para receber ajuda com sua verificação.", + "cta": "Falar com o suporte" + } + }, + "autoBalance": { + "title": "Termine de configurar seu cartão", + "description": "Um toque com sua passkey para começar a usar seu cartão.", + "descriptionError": "Não conseguimos terminar de configurar seu cartão. Tente de novo ou pule por enquanto — vamos pedir de novo no seu primeiro pagamento com o cartão.", + "working": "Processando…" + }, + "cardLaunch": { + "title": "shhhh", + "subtitle": "Toque para saber se você está dentro", + "cta": "Tente a porta →", + "dismissAriaLabel": "Fechar anúncio de lançamento" + }, + "history": { + "activity": "Atividade", + "pendingTransactions": "Transações pendentes", + "latestTransactions": "Últimas transações", + "networkErrorTitle": "Não foi possível carregar a atividade", + "networkErrorDescription": "Verifique sua conexão e tente de novo.", + "errorTitle": "Erro ao carregar a atividade!", + "errorDescription": "Por favor, fale com o Suporte.", + "noActivityTitle": "Ainda não há atividade!", + "noTransactionsTitle": "Ainda não há transações!", + "emptyDescription": "Comece enviando ou cobrando dinheiro" + }, + "welcomeUnlock": { + "title": "🎉 Acesso liberado", + "cta": "Comece a enviar dinheiro", + "youCanNow": "Agora você pode:", + "yourCountry": "seu país", + "items": { + "qrArBr": "Pagamentos QR na Argentina e no Brasil", + "usTransfers": "Transferências ACH e Wire nos Estados Unidos", + "euTransfers": "Transferências SEPA na Europa (+30 países)", + "mxTransfers": "Transferências SPEI no México", + "ownCountryTransfers": "Transferências bancárias para suas próprias contas em {country}", + "qrBrAr": "Pagamentos QR no Brasil e na Argentina" + } + }, + "perk": { + "surpriseTitle": "Você acabou de ganhar ${amount}!", + "surpriseDescriptionFirst": "Confira suas recompensas agora e ganhe mais.", + "surpriseDescriptionNext": "Confira suas recompensas e como ganhar mais.", + "usedPeanut": "{inviteeName} usou o Peanut", + "rewardClaimed": "Recompensa resgatada!", + "shareAndEarn": "Compartilhe e ganhe", + "inviteFriendsToEarnMore": "Convide amigos para ganhar mais", + "holdToUnwrap": "Segure para abrir sua recompensa" + }, + "carousel": { + "rewardReady": "Recompensa de +${amount} pronta!", + "usedPeanutTapToClaim": "{inviteeName} usou o Peanut. Toque para resgatar.", + "tapToClaim": "Toque para resgatar sua recompensa.", + "card": { + "title": "Peça seu Peanut Card", + "description": "Beta fechado. Emblemas furam a fila. Desbloqueie $10 no seu primeiro gasto de $100." + }, + "invite": { + "title": "Convide amigos. Ganhe recompensas", + "description": "Ganhe recompensas sempre que seus amigos usarem o Peanut." + }, + "notifications": { + "title": "Fique por dentro!", + "description": "Ative as notificações e receba alertas de toda a atividade da sua carteira.", + "blockedToast": "Seu navegador bloqueou as notificações. Ative nas configurações do site e recarregue." + }, + "iosPwa": { + "title": "Adicione o Peanut à sua tela inicial", + "description": "Siga um guia rápido para adicionar o app à sua tela inicial, sem precisar baixar." + }, + "qrPay": { + "title": "Pague com códigos QR", + "description": "Tenha a melhor taxa de câmbio, pague como um local e ganhe recompensas." + }, + "latamInvite": { + "title": "Ganhe recompensas em pagamentos QR", + "description": "Convide amigos para ganhar mais recompensas. Quanto mais eles usam, mais você ganha!" + }, + "bugBounty": { + "title": "Ajude a gente a melhorar e ganhe $5!", + "description": "Relate um bug. Ganhe uma recompensa! Sem perguntas." + }, + "kyc": { + "title": "Desbloqueie pagamentos com QR", + "description": "Confirme sua identidade para pagar códigos QR do Mercado Pago e do PIX" + }, + "closePrompt": "Fechar aviso", + "closeTitled": "Fechar {title}", + "closeVerificationPrompt": "Fechar aviso de verificação", + "closeNotificationPrompt": "Fechar aviso de notificações", + "claimablePerk": "Recompensa disponível" + } }, "profile": { "language": "Idioma" diff --git a/src/utils/general.utils.ts b/src/utils/general.utils.ts index 982a9aeebf..d17af9866a 100644 --- a/src/utils/general.utils.ts +++ b/src/utils/general.utils.ts @@ -1,9 +1,4 @@ -import { - nativeCurrencyAddresses, - supportedPeanutChains, - peanutTokenDetails, - pathTitles, -} from '@/constants/general.consts' +import { nativeCurrencyAddresses, supportedPeanutChains, peanutTokenDetails } from '@/constants/general.consts' import { STABLE_COINS, ENS_NAME_REGEX } from '@/constants/general.consts' import { shareableUrl } from '@/utils/url.utils' import * as Sentry from '@sentry/nextjs' @@ -659,10 +654,6 @@ export function getChainName(chainId: string): string | undefined { return chain?.name ?? undefined } -export const getHeaderTitle = (pathname: string) => { - return pathTitles[pathname] || 'Peanut' // default title if path not found -} - /** * Formats a number to use K, M, B, T suffixes if it exceeds 6 characters in length. * Uses the formatAmount function to format the number before adding a suffix. From 6708f777a1d4d1f149adc5a6a0e7e53e52ccff23 Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Thu, 16 Jul 2026 13:53:54 +0100 Subject: [PATCH 11/44] feat(i18n): extract profile, settings, limits, exchange-rate strings ExchangeRateWidget (marketing-shared) takes an optional labels prop with English defaults; product callers pass translated labels. --- src/app/(mobile-ui)/profile/backup/page.tsx | 92 ++++--- .../profile/exchange-rate/page.tsx | 21 +- src/app/(mobile-ui)/settings/page.tsx | 4 +- .../Global/ExchangeRateWidget/index.tsx | 50 +++- .../Profile/components/ProfileEditField.tsx | 2 +- .../Profile/components/PublicProfile.tsx | 61 ++--- src/components/Profile/index.tsx | 41 ++- .../Profile/views/ProfileEdit.view.tsx | 42 +-- .../Profile/views/UnlockedRegions.view.tsx | 49 ++-- .../Settings/DeleteAccountButton.tsx | 24 +- .../__tests__/DeleteAccountButton.test.tsx | 11 +- .../limits/components/CryptoLimitsSection.tsx | 8 +- .../components/FiatLimitsLockedCard.tsx | 12 +- .../components/IncreaseLimitsButton.tsx | 6 +- .../limits/components/LimitsDocsLink.tsx | 6 +- .../limits/components/LimitsError.tsx | 11 +- .../limits/components/LimitsWarningCard.tsx | 9 +- .../limits/components/PeriodToggle.tsx | 8 +- .../limits/views/BridgeLimitsView.tsx | 41 +-- src/features/limits/views/LimitsPageView.tsx | 31 ++- .../limits/views/MantecaLimitsView.tsx | 16 +- src/i18n/app/messages/en.json | 243 +++++++++++++++++- src/i18n/app/messages/es-419.json | 243 +++++++++++++++++- src/i18n/app/messages/pt-BR.json | 243 +++++++++++++++++- 24 files changed, 1034 insertions(+), 240 deletions(-) diff --git a/src/app/(mobile-ui)/profile/backup/page.tsx b/src/app/(mobile-ui)/profile/backup/page.tsx index 0f0b3911e3..b76ac1c48c 100644 --- a/src/app/(mobile-ui)/profile/backup/page.tsx +++ b/src/app/(mobile-ui)/profile/backup/page.tsx @@ -8,29 +8,31 @@ import InfoCard from '@/components/Global/InfoCard' import NavHeader from '@/components/Global/NavHeader' import NavigationArrow from '@/components/Global/NavigationArrow' import { useDeviceType } from '@/hooks/useGetDeviceType' +import { useTranslations } from 'next-intl' import { useState } from 'react' import { useSafeBack } from '@/hooks/useSafeBack' type FaqModal = 'lose-phone' | 'change-phone' | 'export-keys' | null export default function BackupPage() { + const t = useTranslations('profile.backup') const onBack = useSafeBack('/profile', { replace: true }) const { deviceType } = useDeviceType() const [activeModal, setActiveModal] = useState(null) const isAndroid = deviceType === 'android' - const accountType = isAndroid ? 'Google account' : 'Apple ID' + const platform = isAndroid ? 'android' : 'ios' const backupSteps = isAndroid ? [ - { title: 'Open Settings', description: 'Google → All services' }, - { title: 'Find Password Manager', description: 'Autofill → Google Password Manager → Turn ON sync' }, - { title: 'Verify in Chrome', description: 'Settings → Passwords → Search "Peanut"' }, + { title: t('steps.android.step1.title'), description: t('steps.android.step1.description') }, + { title: t('steps.android.step2.title'), description: t('steps.android.step2.description') }, + { title: t('steps.android.step3.title'), description: t('steps.android.step3.description') }, ] : [ - { title: 'Go to Settings', description: '[Your Name] → iCloud' }, - { title: 'Find Passwords & Keychain', description: 'Turn it on' }, - { title: 'Verify it worked', description: 'Settings → Passwords → Search "Peanut"' }, + { title: t('steps.ios.step1.title'), description: t('steps.ios.step1.description') }, + { title: t('steps.ios.step2.title'), description: t('steps.ios.step2.description') }, + { title: t('steps.ios.step3.title'), description: t('steps.ios.step3.description') }, ] const closeModal = () => setActiveModal(null) @@ -38,16 +40,16 @@ export default function BackupPage() { return (
- +
-

Enable backup now

+

{t('enableNow')}

    {backupSteps.map((step, index) => ( @@ -60,29 +62,29 @@ export default function BackupPage() {
-

Common questions

+

{t('faqHeading')}

setActiveModal('lose-phone')}>
-

What if I lose my phone?

+

{t('faq.losePhone')}

setActiveModal('change-phone')}>
-

What if I change phone?

+

{t('faq.changePhone')}

setActiveModal('export-keys')}>
-

Why can't I export my private key?

+

{t('faq.exportKeys')}

@@ -94,7 +96,7 @@ export default function BackupPage() { visible={activeModal === 'lose-phone'} onClose={closeModal} icon="info" - title="What if I lose my phone?" + title={t('faq.losePhone')} titleClassName="font-extrabold text-xl" content={
@@ -102,14 +104,14 @@ export default function BackupPage() { variant="success" icon="check" iconClassName="text-success" - title="Backup is enabled" - description={`Sign into your new phone with your ${accountType}. Download Peanut. Your wallet restores automatically`} + title={t('losePhoneModal.enabledTitle')} + description={t('losePhoneModal.enabledDescription', { platform })} />
} @@ -120,34 +122,34 @@ export default function BackupPage() { visible={activeModal === 'change-phone'} onClose={closeModal} icon="info" - title="What if I change phone?" + title={t('faq.changePhone')} titleClassName="font-extrabold text-xl" content={
    -
  1. Verify backup is working (check step 3 above)
  2. -
  3. Know your {accountType} password
  4. -
  5. Keep old phone until new one works
  6. +
  7. {t('changePhoneModal.step1')}
  8. +
  9. {t('changePhoneModal.step2', { platform })}
  10. +
  11. {t('changePhoneModal.step3')}
} @@ -158,35 +160,29 @@ export default function BackupPage() { visible={activeModal === 'export-keys'} onClose={closeModal} icon="info" - title="Why can't I export my private key?" + title={t('faq.exportKeys')} titleClassName="font-extrabold text-xl" content={
-

Passkeys are safer by design

-

- Unlike traditional private keys that can be copied as text, passkeys are cryptographic - credentials that can't be: -

+

{t('exportKeysModal.saferTitle')}

+

{t('exportKeysModal.saferIntro')}

    -
  • Screenshot by accident
  • -
  • Sent in a text message
  • -
  • Saved in an unsafe note app
  • -
  • Stolen by malware
  • +
  • {t('exportKeysModal.bullets.screenshot')}
  • +
  • {t('exportKeysModal.bullets.textMessage')}
  • +
  • {t('exportKeysModal.bullets.noteApp')}
  • +
  • {t('exportKeysModal.bullets.malware')}
-

The tradeoff

-

- You get better security, but passkeys are locked to your device ecosystem (Apple or - Google). That's why cloud backup is critical. -

+

{t('exportKeysModal.tradeoffTitle')}

+

{t('exportKeysModal.tradeoffDescription')}

i -

We're exploring advanced export options for power users in future updates.

+

{t('exportKeysModal.futureNote')}

} diff --git a/src/app/(mobile-ui)/profile/exchange-rate/page.tsx b/src/app/(mobile-ui)/profile/exchange-rate/page.tsx index d5dafe327d..a57962e956 100644 --- a/src/app/(mobile-ui)/profile/exchange-rate/page.tsx +++ b/src/app/(mobile-ui)/profile/exchange-rate/page.tsx @@ -8,11 +8,13 @@ import { printableUsdc } from '@/utils/balance.utils' import { getExchangeRateWidgetRedirectRoute } from '@/utils/exchangeRateWidget.utils' import { useCapabilities } from '@/hooks/useCapabilities' import { deriveRegionAccess } from '@/utils/regions.utils' +import { useTranslations } from 'next-intl' import { useRouter } from 'next/navigation' import { useSafeBack } from '@/hooks/useSafeBack' import { useMemo } from 'react' export default function ExchangeRatePage() { + const t = useTranslations('exchangeRate') const router = useRouter() const onBack = useSafeBack('/profile', { replace: true }) const { balance } = useWallet() @@ -36,9 +38,24 @@ export default function ExchangeRatePage() { return ( - +
- +
) diff --git a/src/app/(mobile-ui)/settings/page.tsx b/src/app/(mobile-ui)/settings/page.tsx index c0a4e80eeb..7e0ae8ea5f 100644 --- a/src/app/(mobile-ui)/settings/page.tsx +++ b/src/app/(mobile-ui)/settings/page.tsx @@ -3,10 +3,12 @@ import { Button } from '@/components/0_Bruddle/Button' import { useAuth } from '@/context/authContext' import { useQueryClient } from '@tanstack/react-query' +import { useTranslations } from 'next-intl' const SettingsPage = () => { const { logoutUser } = useAuth() const queryClient = useQueryClient() + const t = useTranslations('navigation') return (
@@ -16,7 +18,7 @@ const SettingsPage = () => { queryClient.invalidateQueries() }} > - Logout + {t('logout')}
) diff --git a/src/components/Global/ExchangeRateWidget/index.tsx b/src/components/Global/ExchangeRateWidget/index.tsx index 5fe592ff0b..03565527cf 100644 --- a/src/components/Global/ExchangeRateWidget/index.tsx +++ b/src/components/Global/ExchangeRateWidget/index.tsx @@ -9,13 +9,41 @@ import { type FC, useCallback, useEffect, useMemo, useRef, useState } from 'reac import { Icon, type IconName } from '../Icons/Icon' import { Button } from '@/components/0_Bruddle/Button' +export interface ExchangeRateWidgetLabels { + youSend: string + recipientGets: string + swapCurrencies: string + rateUnavailable: string + bankFee: string + peanutFee: string + free: string + arrivesHours: string + arrivesMinutes: string +} + +// English defaults keep marketing callers (landing page, MDX) unchanged; +// product-UI callers pass translated labels. +const DEFAULT_LABELS: ExchangeRateWidgetLabels = { + youSend: 'You Send', + recipientGets: 'Recipient Gets', + swapCurrencies: 'Swap currencies', + rateUnavailable: 'Rate currently unavailable', + bankFee: 'Bank fee', + peanutFee: 'Peanut fee', + free: 'Free!', + arrivesHours: 'Should arrive in hours.', + arrivesMinutes: 'Should arrive in minutes.', +} + interface IExchangeRateWidgetProps { ctaLabel: string ctaIcon: IconName ctaAction: (sourceCurrency: string, destinationCurrency: string) => void + labels?: Partial } -const ExchangeRateWidget: FC = ({ ctaLabel, ctaIcon, ctaAction }) => { +const ExchangeRateWidget: FC = ({ ctaLabel, ctaIcon, ctaAction, labels }) => { + const l = { ...DEFAULT_LABELS, ...labels } // shallow + history:'replace' uses window.history.replaceState — bypasses // Next.js navigation so URL updates don't (occasionally) scroll the page // to the top through the parent Suspense boundary. @@ -163,14 +191,12 @@ const ExchangeRateWidget: FC = ({ ctaLabel, ctaIcon, c ) // Determine delivery time text based on destination currency - const deliveryTimeText = useMemo(() => { - return destinationCurrency === 'USD' ? 'Should arrive in hours.' : 'Should arrive in minutes.' - }, [destinationCurrency]) + const deliveryTimeText = destinationCurrency === 'USD' ? l.arrivesHours : l.arrivesMinutes return (
-

You Send

+

{l.youSend}

{showLoading ? (
@@ -218,13 +244,13 @@ const ExchangeRateWidget: FC = ({ ctaLabel, ctaIcon, c
-

Recipient Gets

+

{l.recipientGets}

{showLoading ? (
@@ -278,7 +304,7 @@ const ExchangeRateWidget: FC = ({ ctaLabel, ctaIcon, c {showLoading ? (
) : isError ? ( - Rate currently unavailable + {l.rateUnavailable} ) : ( <> 1 {sourceCurrency} = {exchangeRate.toFixed(4)} {destinationCurrency} @@ -289,13 +315,13 @@ const ExchangeRateWidget: FC = ({ ctaLabel, ctaIcon, c {typeof destinationAmount === 'number' && destinationAmount > 0 && (
-

Bank fee

-

Free!

+

{l.bankFee}

+

{l.free}

-

Peanut fee

-

Free!

+

{l.peanutFee}

+

{l.free}

)} diff --git a/src/components/Profile/components/ProfileEditField.tsx b/src/components/Profile/components/ProfileEditField.tsx index e0abaef750..ee180841c7 100644 --- a/src/components/Profile/components/ProfileEditField.tsx +++ b/src/components/Profile/components/ProfileEditField.tsx @@ -26,7 +26,7 @@ const ProfileEditField: React.FC = ({
- {badge && } + {badge && }
= ({ username, isLoggedIn = false, onSendClick }) => { + const t = useTranslations('profile.publicProfile') + const tNav = useTranslations('navigation') const [totalSentByLoggedInUser, setTotalSentByLoggedInUser] = useState('0') const [fullName, setFullName] = useState(username) const [showFullName, setShowFullName] = useState(false) @@ -76,8 +79,8 @@ const PublicProfile: React.FC = ({ username, isLoggedIn = fa
{!isLoggedIn ? (
- Peanut mascot - Peanut Text + {t('peanutMascotAlt')} + {t('peanutLogoTextAlt')}
) : ( = ({ username, isLoggedIn = fa className="flex w-1/2 items-center justify-center gap-2 rounded-full py-3" > - Send + {tNav('send')}
)} @@ -147,40 +150,39 @@ const PublicProfile: React.FC = ({ username, isLoggedIn = fa {isLoggedIn ? ( <> -

You're all set

-

- Now send or request money to get started. -

+

{t('allSetTitle')}

+

{t('allSetDescription')}

) : (
- Join Peanut -

Join Peanut!

+ {t('joinPeanutAlt')} +

{t('joinPeanut')}

Join Peanut

- Peanut is invite-only. + {t('inviteOnlyLine1')}
- Go beg your friend for an invite link! + {t('inviteOnlyLine2')}

- Promise.resolve( - `Bro… I’m on my knees. Peanut is invite-only and I’m locked outside. Save my life and send me your invite` - ) - } - title="Beg for an invite" + generateText={() => Promise.resolve(t('begShareText'))} + title={t('begForInvite')} > - Beg for an invite + {t('begForInvite')}
)} @@ -209,9 +211,7 @@ const PublicProfile: React.FC = ({ username, isLoggedIn = fa {isSelfProfile && (
-

- Activity is only visible for you, it is not public. -

+

{t('activityPrivateNote')}

)}
@@ -219,22 +219,15 @@ const PublicProfile: React.FC = ({ username, isLoggedIn = fa { setShowInviteModal(false) }} content={ - - Promise.resolve( - `Bro… I’m on my knees. Peanut is invite-only and I’m locked outside. Save my life and send me your invite` - ) - } - title="Beg for an invite" - > - Beg for an invite + Promise.resolve(t('begShareText'))} title={t('begForInvite')}> + {t('begForInvite')} } /> diff --git a/src/components/Profile/index.tsx b/src/components/Profile/index.tsx index 269059fcb0..c8b28fae31 100644 --- a/src/components/Profile/index.tsx +++ b/src/components/Profile/index.tsx @@ -51,7 +51,7 @@ export const Profile = () => {
setIsInviteFriendsModalOpen(true)} href="/dummy" // Dummy link, wont be called position="single" @@ -67,31 +67,46 @@ export const Profile = () => { the safe default (never 404s; the gated /card route would). */} - } - label="Points" + icon="achievements" + label={t('menu.yourBadges')} + href="/badges" + position="middle" + /> + } + label={t('menu.points')} href="/rewards" position="last" />
- + - + {
- Show my full name + {t('menu.showMyFullName')}
@@ -115,7 +130,7 @@ export const Profile = () => { router.push('/profile/backup')} position="last" @@ -132,7 +147,7 @@ export const Profile = () => { {/* Menu Items - Second Group */} { onClick={logout} > - Log out + {t('logOut')}
diff --git a/src/components/Profile/views/ProfileEdit.view.tsx b/src/components/Profile/views/ProfileEdit.view.tsx index 6fdfe1f99e..71ffd8f831 100644 --- a/src/components/Profile/views/ProfileEdit.view.tsx +++ b/src/components/Profile/views/ProfileEdit.view.tsx @@ -5,6 +5,7 @@ import ErrorAlert from '@/components/Global/ErrorAlert' import NavHeader from '@/components/Global/NavHeader' import { useAuth } from '@/context/authContext' import * as Sentry from '@sentry/nextjs' +import { useTranslations } from 'next-intl' import { useRouter } from 'next/navigation' import { useCallback, useEffect, useState } from 'react' import ProfileEditField from '../components/ProfileEditField' @@ -13,6 +14,7 @@ import { useIdentityVerification } from '@/hooks/useIdentityVerification' import { useSafeBack } from '@/hooks/useSafeBack' export const ProfileEditView = () => { + const t = useTranslations('profile.edit') const router = useRouter() const onBack = useSafeBack('/profile') const { user, fetchUser } = useAuth() @@ -83,7 +85,7 @@ export const ProfileEditView = () => { // trap users whose fullName is empty at load (can't type, can't // save) when all they want is to set their email. if (canEditName && !formData.name?.trim()) { - setErrorMessage('Please provide your name.') + setErrorMessage(t('errors.nameRequired')) return } @@ -128,74 +130,74 @@ export const ProfileEditView = () => { router.replace('/profile') } catch (error) { console.error('Error updating profile:', error) - setErrorMessage('Something went wrong. Please try again or contact support.') + setErrorMessage(t('errors.generic')) Sentry.captureException(error) } finally { setIsLoading(false) } - }, [formData, user, fetchUser, router, isEmailSet, canEditName]) + }, [formData, user, fetchUser, router, isEmailSet, canEditName, t]) const fullName = user?.user.fullName || user?.user?.username || '' const username = user?.user.username || '' return (
- +
handleChange('name', value)} - placeholder="Add your name" + placeholder={t('placeholders.name')} disabled={!canEditName} /> handleChange('surname', value)} - placeholder="Add your surname" + placeholder={t('placeholders.surname')} disabled={!canEditName} /> handleChange('bio', value)} - placeholder="Add a bio" - badge="Soon!" + placeholder={t('placeholders.bio')} + badge={t('soonBadge')} disabled /> handleChange('email', value)} - placeholder="Add your email" + placeholder={t('placeholders.email')} type="email" disabled={isEmailSet} /> handleChange('phone', value)} - placeholder="Add your number" + placeholder={t('placeholders.phone')} type="tel" - badge="Soon!" + badge={t('soonBadge')} disabled /> handleChange('website', value)} - placeholder="Add your website" + placeholder={t('placeholders.website')} type="url" - badge="Soon!" + badge={t('soonBadge')} disabled /> @@ -207,7 +209,7 @@ export const ProfileEditView = () => { shadowSize="4" loading={isLoading} > - Save Changes + {t('saveChanges')} {errorMessage && } diff --git a/src/components/Profile/views/UnlockedRegions.view.tsx b/src/components/Profile/views/UnlockedRegions.view.tsx index 7660728c59..6c3f6f71cd 100644 --- a/src/components/Profile/views/UnlockedRegions.view.tsx +++ b/src/components/Profile/views/UnlockedRegions.view.tsx @@ -17,6 +17,7 @@ import { useCapabilities } from '@/hooks/useCapabilities' import { deriveProviderRejection } from '@/utils/provider-rejection.utils' import { type RailCapability } from '@/types/capabilities' import { useMultiPhaseKycFlow } from '@/hooks/useMultiPhaseKycFlow' +import { useTranslations } from 'next-intl' import Image from 'next/image' import { useSafeBack } from '@/hooks/useSafeBack' import { useState, useCallback, useRef, useMemo } from 'react' @@ -62,6 +63,8 @@ function getModalVariant(rail: RailCapability | undefined, hasSumsubAction: bool } const UnlockedRegions = () => { + const t = useTranslations('profile.regions') + const tCommon = useTranslations('common') const onBack = useSafeBack('/profile', { replace: true }) const router = useRouter() // Card-priority guard: an eligible user (skip badge / admin grant → @@ -208,17 +211,15 @@ const UnlockedRegions = () => { return (
- +
-

Unlocked regions

-

- Transfer to and receive from any bank account and use supported payments methods. -

+

{t('title')}

+

{t('description')}

{unlockedRegions.length === 0 && ( @@ -228,8 +229,8 @@ const UnlockedRegions = () => { {lockedRegions.length > 0 && ( <> -

Locked regions

-

Where do you want to send and receive money?

+

{t('lockedTitle')}

+

{t('lockedDescription')}

@@ -269,26 +270,24 @@ const UnlockedRegions = () => { onClose={handleModalClose} title={ providerRejectionForRegion.state === 'fixable' - ? 'We need an updated document' + ? t('providerRejection.fixableTitle') : providerRejectionForRegion.state === 'restart-identity' - ? 'Verify with a different document' - : 'Region unavailable' + ? t('providerRejection.restartTitle') + : t('providerRejection.unavailableTitle') } description={ providerRejectionForRegion.state === 'fixable' - ? providerRejectionForRegion.userMessage || - 'Please upload a clearer photo of your ID to unlock this region.' + ? providerRejectionForRegion.userMessage || t('providerRejection.fixableDescription') : providerRejectionForRegion.state === 'restart-identity' - ? providerRejectionForRegion.userMessage || - 'This region needs a document from a supported country. You can verify with a different ID.' - : 'This region is not available for your account. Contact support for help.' + ? providerRejectionForRegion.userMessage || t('providerRejection.restartDescription') + : t('providerRejection.unavailableDescription') } icon="alert" iconContainerClassName="bg-yellow-1" ctas={[ providerRejectionForRegion.state === 'fixable' ? { - text: 'Upload document', + text: t('providerRejection.uploadDocument'), onClick: () => { handleModalClose() flow.handleSelfHealResubmit(providerRejectionForRegion.provider) @@ -298,7 +297,7 @@ const UnlockedRegions = () => { } : providerRejectionForRegion.state === 'restart-identity' ? { - text: 'Verify with a different document', + text: t('providerRejection.restartTitle'), onClick: () => { handleModalClose() flow.handleRestartIdentity() @@ -307,7 +306,7 @@ const UnlockedRegions = () => { shadowSize: '4' as const, } : { - text: 'Contact support', + text: tCommon('contactSupport'), onClick: () => { handleModalClose() setIsSupportModalOpen(true) @@ -321,15 +320,15 @@ const UnlockedRegions = () => { setErrorAcknowledged(true)} - title={failedRegionRetriable ? "Verification couldn't start" : 'Not available yet'} - description={flow.error || 'Something went wrong. Please try again or contact support.'} + title={failedRegionRetriable ? t('initError.retriableTitle') : t('initError.notAvailableTitle')} + description={flow.error || t('initError.fallbackDescription')} icon="alert" iconContainerClassName="bg-yellow-1" ctas={ failedRegionRetriable ? [ { - text: 'Try again', + text: tCommon('tryAgain'), variant: 'purple', shadowSize: '4', disabled: flow.isLoading, @@ -338,7 +337,7 @@ const UnlockedRegions = () => { }, }, { - text: 'Contact support', + text: tCommon('contactSupport'), variant: 'stroke', onClick: () => { setErrorAcknowledged(true) @@ -348,7 +347,7 @@ const UnlockedRegions = () => { ] : [ { - text: 'Got it', + text: tCommon('gotIt'), variant: 'purple', shadowSize: '4', onClick: () => setErrorAcknowledged(true), diff --git a/src/components/Settings/DeleteAccountButton.tsx b/src/components/Settings/DeleteAccountButton.tsx index 1dc60322d3..a2b164a297 100644 --- a/src/components/Settings/DeleteAccountButton.tsx +++ b/src/components/Settings/DeleteAccountButton.tsx @@ -1,6 +1,7 @@ 'use client' import { type FC, useState } from 'react' +import { useTranslations } from 'next-intl' import Image from 'next/image' import posthog from 'posthog-js' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' @@ -19,6 +20,7 @@ const Mascot: FC<{ src: string; alt: string }> = ({ src, alt }) => ( ) const DeleteAccountButton: FC = () => { + const t = useTranslations('settings.deleteAccount') const { logoutUser } = useAuth() const toast = useToast() const [modalState, setModalState] = useState('closed') @@ -42,7 +44,7 @@ const DeleteAccountButton: FC = () => { setModalState('done') } catch { posthog.capture(ANALYTICS_EVENTS.DELETE_ACCOUNT_FAILED) - toast.error('Could not delete your account. Please try again.') + toast.error(t('error')) } finally { setIsSubmitting(false) } @@ -60,7 +62,7 @@ const DeleteAccountButton: FC = () => { const confirmCtas: ActionModalButtonProps[] = [ { - text: 'Yes, delete it', + text: t('confirmCta'), variant: 'purple', shadowSize: '4', loading: isSubmitting, @@ -68,7 +70,7 @@ const DeleteAccountButton: FC = () => { onClick: confirmDelete, }, { - text: "Never mind, I'll stay", + text: t('cancelCta'), variant: 'stroke', shadowSize: '4', disabled: isSubmitting, @@ -78,7 +80,7 @@ const DeleteAccountButton: FC = () => { const doneCtas: ActionModalButtonProps[] = [ { - text: 'Goodbye', + text: t('doneCta'), variant: 'purple', shadowSize: '4', onClick: finish, @@ -94,7 +96,7 @@ const DeleteAccountButton: FC = () => { onClick={open} className="w-full text-center text-sm font-semibold text-error underline underline-offset-2" > - Delete My Account + {t('button')} { hideModalCloseButton={lockModal} icon={ isDone ? ( - + ) : ( - + ) } iconContainerClassName="size-32 rounded-none bg-transparent" - title={isDone ? "We'll miss you" : "Aw, you're leaving?"} - description={ - isDone - ? 'Your account is off and your data will be gone within 30 days. Thanks for cracking open Peanut with us — take care out there!' - : "Deleting your account is permanent. We'll switch it off right away and wipe your data within 30 days. This little guy would really rather you stayed." - } + title={isDone ? t('doneTitle') : t('confirmTitle')} + description={isDone ? t('doneDescription') : t('confirmDescription')} ctas={isDone ? doneCtas : confirmCtas} /> diff --git a/src/components/Settings/__tests__/DeleteAccountButton.test.tsx b/src/components/Settings/__tests__/DeleteAccountButton.test.tsx index c6ec8d9dd3..a63d3c4795 100644 --- a/src/components/Settings/__tests__/DeleteAccountButton.test.tsx +++ b/src/components/Settings/__tests__/DeleteAccountButton.test.tsx @@ -5,10 +5,19 @@ * can drive confirm -> loading -> done -> logout and the error-toast branch. */ import React from 'react' -import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { render as rtlRender, screen, fireEvent, waitFor } from '@testing-library/react' +import { NextIntlClientProvider } from 'next-intl' +import en from '@/i18n/app/messages/en.json' import DeleteAccountButton from '@/components/Settings/DeleteAccountButton' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' +const IntlWrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + +) +const render = (ui: React.ReactElement) => rtlRender(ui, { wrapper: IntlWrapper }) + const mockLogout = jest.fn() const mockToastError = jest.fn() const mockRequestDeletion = jest.fn() diff --git a/src/features/limits/components/CryptoLimitsSection.tsx b/src/features/limits/components/CryptoLimitsSection.tsx index 6d306db20c..b95e637bf1 100644 --- a/src/features/limits/components/CryptoLimitsSection.tsx +++ b/src/features/limits/components/CryptoLimitsSection.tsx @@ -3,18 +3,20 @@ import Card from '@/components/Global/Card' import { Icon } from '@/components/Global/Icons/Icon' import { Tooltip } from '@/components/Tooltip' +import { useTranslations } from 'next-intl' /** * displays crypto limits section - crypto transactions have no limits */ export default function CryptoLimitsSection() { + const t = useTranslations('limits.crypto') return (
-

Crypto limits

+

{t('title')}

- No limits - + {t('noLimits')} +
diff --git a/src/features/limits/components/FiatLimitsLockedCard.tsx b/src/features/limits/components/FiatLimitsLockedCard.tsx index d423080d3c..bb7a1833db 100644 --- a/src/features/limits/components/FiatLimitsLockedCard.tsx +++ b/src/features/limits/components/FiatLimitsLockedCard.tsx @@ -3,25 +3,25 @@ import Card from '@/components/Global/Card' import { Icon } from '@/components/Global/Icons/Icon' import { Button } from '@/components/0_Bruddle/Button' +import { useTranslations } from 'next-intl' import { useRouter } from 'next/navigation' /** Prompt to unlock fiat payments (bank deposits/withdraws + QR pay). */ export default function FiatLimitsLockedCard() { + const t = useTranslations('limits.fiatLocked') const router = useRouter() return (
-

Unlock fiat payments

+

{t('title')}

-
Locked
-
- Confirm your ID to unlock fiat payments and see your limits. -
+
{t('locked')}
+
{t('description')}
diff --git a/src/features/limits/components/IncreaseLimitsButton.tsx b/src/features/limits/components/IncreaseLimitsButton.tsx index d363874643..695d2d785f 100644 --- a/src/features/limits/components/IncreaseLimitsButton.tsx +++ b/src/features/limits/components/IncreaseLimitsButton.tsx @@ -1,6 +1,7 @@ 'use client' import { useCallback } from 'react' +import { useTranslations } from 'next-intl' import { Button } from '@/components/0_Bruddle/Button' import { useModalsContext } from '@/context/ModalsContext' import { useLimits } from '@/hooks/useLimits' @@ -18,6 +19,7 @@ const INCREASE_LIMITS_MESSAGE = 'Hi, I would like to increase my payment limits. * - Everyone else: opens support chat with prefilled message */ export default function IncreaseLimitsButton() { + const t = useTranslations('limits.increase') const { openSupportWithMessage } = useModalsContext() const { mantecaLimits, isLoading: isLimitsLoading, refetch } = useLimits() @@ -40,7 +42,7 @@ export default function IncreaseLimitsButton() { if (actionFlow.isComplete) { return (
- Document submitted! Your limits will be updated shortly. + {t('submitted')}
) } @@ -54,7 +56,7 @@ export default function IncreaseLimitsButton() { loading={actionFlow.isLoading || isLimitsLoading} disabled={actionFlow.isLoading || isLimitsLoading} > - Increase my limits + {t('cta')} {actionFlow.error &&

{actionFlow.error}

} diff --git a/src/features/limits/components/LimitsDocsLink.tsx b/src/features/limits/components/LimitsDocsLink.tsx index a3182a7fff..d56ee67a54 100644 --- a/src/features/limits/components/LimitsDocsLink.tsx +++ b/src/features/limits/components/LimitsDocsLink.tsx @@ -1,9 +1,13 @@ +'use client' + import DocsLink from '@/components/Global/DocsLink' +import { useTranslations } from 'next-intl' export default function LimitsDocsLink() { + const t = useTranslations('limits') return ( - See more about limits + {t('docsLink')} ) } diff --git a/src/features/limits/components/LimitsError.tsx b/src/features/limits/components/LimitsError.tsx index 33733e3c67..5374810cc5 100644 --- a/src/features/limits/components/LimitsError.tsx +++ b/src/features/limits/components/LimitsError.tsx @@ -1,20 +1,19 @@ 'use client' import { Button } from '@/components/0_Bruddle/Button' import EmptyState from '@/components/Global/EmptyStates/EmptyState' +import { useTranslations } from 'next-intl' import { useRouter } from 'next/navigation' export default function LimitsError() { + const t = useTranslations('limits') + const tCommon = useTranslations('common') const router = useRouter() return (
- +
diff --git a/src/features/limits/components/LimitsWarningCard.tsx b/src/features/limits/components/LimitsWarningCard.tsx index b11210c012..097b542468 100644 --- a/src/features/limits/components/LimitsWarningCard.tsx +++ b/src/features/limits/components/LimitsWarningCard.tsx @@ -2,6 +2,7 @@ import InfoCard from '@/components/Global/InfoCard' import { Icon } from '@/components/Global/Icons/Icon' +import { useTranslations } from 'next-intl' import Link from 'next/link' import { useModalsContext } from '@/context/ModalsContext' import { twMerge } from 'tailwind-merge' @@ -34,6 +35,8 @@ export default function LimitsWarningCard({ isIncreaseLimitsLoading, className, }: LimitsWarningCardProps) { + const t = useTranslations('limits') + const tCommon = useTranslations('common') const { openSupportWithMessage } = useModalsContext() return ( @@ -72,7 +75,7 @@ export default function LimitsWarningCard({ > - {isIncreaseLimitsLoading ? 'Loading...' : 'Increase my limits'} + {isIncreaseLimitsLoading ? tCommon('loading') : t('increase.cta')} @@ -84,9 +87,7 @@ export default function LimitsWarningCard({ className="flex items-center gap-1 text-xs md:text-sm" > - - Need higher limits? Contact support. - + {t('needHigherLimits')} ) : null} diff --git a/src/features/limits/components/PeriodToggle.tsx b/src/features/limits/components/PeriodToggle.tsx index cfe8dbf328..9092a08383 100644 --- a/src/features/limits/components/PeriodToggle.tsx +++ b/src/features/limits/components/PeriodToggle.tsx @@ -1,6 +1,7 @@ 'use client' import { Root, List, Trigger } from '@radix-ui/react-tabs' +import { useTranslations } from 'next-intl' type Period = 'monthly' | 'yearly' @@ -15,20 +16,21 @@ interface PeriodToggleProps { * uses radix tabs for accessibility */ export default function PeriodToggle({ value, onChange, className }: PeriodToggleProps) { + const t = useTranslations('limits.period') return ( onChange(v as Period)} className={className}> - + - Monthly + {t('monthly')} - Yearly + {t('yearly')} diff --git a/src/features/limits/views/BridgeLimitsView.tsx b/src/features/limits/views/BridgeLimitsView.tsx index 3aca45981c..02a79f4326 100644 --- a/src/features/limits/views/BridgeLimitsView.tsx +++ b/src/features/limits/views/BridgeLimitsView.tsx @@ -6,6 +6,7 @@ import { Icon } from '@/components/Global/Icons/Icon' import { useLimits } from '@/hooks/useLimits' import { useSafeBack } from '@/hooks/useSafeBack' import { MAX_QR_PAYMENT_AMOUNT_FOREIGN } from '@/constants/payment.consts' +import { useTranslations } from 'next-intl' import Image from 'next/image' import * as Accordion from '@radix-ui/react-accordion' import { useQueryState, parseAsStringEnum } from 'nuqs' @@ -23,6 +24,7 @@ import EmptyState from '@/components/Global/EmptyStates/EmptyState' * url state: ?region=us|mexico|europe|argentina|brazil (persists source region) */ const BridgeLimitsView = () => { + const t = useTranslations('limits.provider') const onBack = useSafeBack('/limits') const { bridgeLimits, isLoading, error, hasMantecaLimits } = useLimits() @@ -46,7 +48,7 @@ const BridgeLimitsView = () => { return (
- + {isLoading && } @@ -57,29 +59,31 @@ const BridgeLimitsView = () => { {/* main limits card - only for bank transfer regions */} {showBankTransferLimits && (
-

Fiat limits:

+

{t('fiatLimits')}

- Add money: up to{' '} - {formatAmountWithCurrency( - parseFloat(bridgeLimits.onRampPerTransaction), - bridgeLimits.asset - )}{' '} - per transaction + {t.rich('addMoneyLimit', { + medium: (chunks) => {chunks}, + amount: formatAmountWithCurrency( + parseFloat(bridgeLimits.onRampPerTransaction), + bridgeLimits.asset + ), + })}
- Withdrawing: up to{' '} - {formatAmountWithCurrency( - parseFloat(bridgeLimits.offRampPerTransaction), - bridgeLimits.asset - )}{' '} - per transaction + {t.rich('withdrawLimit', { + medium: (chunks) => {chunks}, + amount: formatAmountWithCurrency( + parseFloat(bridgeLimits.offRampPerTransaction), + bridgeLimits.asset + ), + })}
@@ -90,7 +94,7 @@ const BridgeLimitsView = () => { {/* qr payment limits accordion - for bridge users without manteca kyc */} {!hasMantecaLimits && (
-

QR payment limits:

+

{t('qrPaymentLimits')}

{
- Paying with QR: up to $ - {MAX_QR_PAYMENT_AMOUNT_FOREIGN.toLocaleString()} per transaction + {t('qrPayLimit', { + amount: `$${MAX_QR_PAYMENT_AMOUNT_FOREIGN.toLocaleString()}`, + })}
@@ -143,7 +148,7 @@ const BridgeLimitsView = () => { )} - {!isLoading && !error && !bridgeLimits && } + {!isLoading && !error && !bridgeLimits && }
) } diff --git a/src/features/limits/views/LimitsPageView.tsx b/src/features/limits/views/LimitsPageView.tsx index 9f7fbd2d91..8eefcee546 100644 --- a/src/features/limits/views/LimitsPageView.tsx +++ b/src/features/limits/views/LimitsPageView.tsx @@ -10,6 +10,7 @@ import { deriveRegionAccess, pendingBankRailRegionPaths, type Region } from '@/u import { useCapabilities } from '@/hooks/useCapabilities' import { useLimits } from '@/hooks/useLimits' import { useRainCardOverview } from '@/hooks/useRainCardOverview' +import { useTranslations } from 'next-intl' import Image from 'next/image' import { useRouter } from 'next/navigation' import { useMemo } from 'react' @@ -21,6 +22,8 @@ import InfoCard from '@/components/Global/InfoCard' import { getProviderRoute } from '../utils' const LimitsPageView = () => { + const t = useTranslations('limits') + const tCommon = useTranslations('common') const router = useRouter() const goBack = useSafeBack('/profile', { replace: true }) const { isKycApproved, rails } = useCapabilities() @@ -47,7 +50,7 @@ const LimitsPageView = () => { // rest of world region config (static) const restOfWorldRegion: Region = { path: 'rest-of-the-world', - name: 'Rest of the world', + name: t('restOfWorld'), icon: REST_OF_WORLD_GLOBE_ICON, } @@ -67,13 +70,10 @@ const LimitsPageView = () => { return (
- + {/* page description */} - + {/* fiat limits section */} {!hasAnyKyc && } @@ -91,7 +91,7 @@ const LimitsPageView = () => { {/* rest of world - always shown with coming soon */} {hasRestOfWorld && (
-

Other regions

+

{t('otherRegions')}

{ title={restOfWorldRegion.name} onClick={() => {}} isDisabled={true} - rightContent={} + rightContent={} />
)} @@ -114,12 +114,12 @@ const LimitsPageView = () => { {/* card limits — separate from KYC/region limits; managed per-card via Rain */} {activeCard && (
-

Card limits

+

{t('cardLimits.title')}

} - title="Manage card limits" - description="Transaction cap for your Peanut card." + title={t('cardLimits.manage')} + description={t('cardLimits.description')} onClick={() => router.push('/card/limit')} />
@@ -139,11 +139,12 @@ interface UnlockedRegionsListProps { } const UnlockedRegionsList = ({ regions, hasMantecaKyc }: UnlockedRegionsListProps) => { + const t = useTranslations('limits') const router = useRouter() return (
- {regions.length > 0 &&

Unlocked regions

} + {regions.length > 0 &&

{t('unlockedRegions')}

} {regions.map((region, index) => ( { + const t = useTranslations('limits') + const tCommon = useTranslations('common') const router = useRouter() // a region shows pending only when one of ITS bank rails is mid-flight @@ -183,7 +186,7 @@ const LockedRegionsList = ({ regions, pendingRegionPaths }: LockedRegionsListPro return (
- {regions.length > 0 &&

Locked regions

} + {regions.length > 0 &&

{t('lockedRegions')}

} {regions.map((region, index) => { const isPending = isPendingRegion(region.path) return ( @@ -208,7 +211,7 @@ const LockedRegionsList = ({ regions, pendingRegionPaths }: LockedRegionsListPro isDisabled={isPending} description={region.description} descriptionClassName="text-xs" - rightContent={isPending && } + rightContent={isPending && } /> ) })} diff --git a/src/features/limits/views/MantecaLimitsView.tsx b/src/features/limits/views/MantecaLimitsView.tsx index e1e0ca0764..4feea5ce1e 100644 --- a/src/features/limits/views/MantecaLimitsView.tsx +++ b/src/features/limits/views/MantecaLimitsView.tsx @@ -5,6 +5,7 @@ import Card from '@/components/Global/Card' import { Icon } from '@/components/Global/Icons/Icon' import { useLimits } from '@/hooks/useLimits' import { useSafeBack } from '@/hooks/useSafeBack' +import { useTranslations } from 'next-intl' import { useState } from 'react' import PeriodToggle from '../components/PeriodToggle' import LimitsProgressBar from '../components/LimitsProgressBar' @@ -27,13 +28,14 @@ import EmptyState from '@/components/Global/EmptyStates/EmptyState' * shows monthly/yearly limits per currency with remaining amounts */ const MantecaLimitsView = () => { + const t = useTranslations('limits.provider') const onBack = useSafeBack('/limits') const { mantecaLimits, isLoading, error } = useLimits() const [period, setPeriod] = useState('monthly') return (
- + {isLoading && } @@ -65,7 +67,9 @@ const MantecaLimitsView = () => { className="size-5 rounded-full object-cover" /> )} - {limit.asset} total allowed + + {t('totalAllowed', { asset: limit.asset })} +
@@ -77,9 +81,7 @@ const MantecaLimitsView = () => {
- - Remaining this {period === 'monthly' ? 'month' : 'year'} - + {t('remaining', { period })} {formatAmountWithCurrency(limitData.remaining, limit.asset)} @@ -90,7 +92,7 @@ const MantecaLimitsView = () => { {/* info text */}
-

Applies to adding money, withdraws and QR payments

+

{t('appliesTo')}

@@ -101,7 +103,7 @@ const MantecaLimitsView = () => { )} {!isLoading && !error && (!mantecaLimits || mantecaLimits.length === 0) && ( - + )}
) diff --git a/src/i18n/app/messages/en.json b/src/i18n/app/messages/en.json index 413e38e11c..a397af8016 100644 --- a/src/i18n/app/messages/en.json +++ b/src/i18n/app/messages/en.json @@ -13,7 +13,11 @@ "copiedToClipboard": "Copied to clipboard", "maybeLater": "Maybe later", "tryAgain": "Try again", - "skipForNow": "Skip for now" + "skipForNow": "Skip for now", + "gotIt": "Got it", + "contactSupport": "Contact support", + "pending": "Pending", + "comingSoon": "Coming soon" }, "navigation": { "home": "Home", @@ -166,11 +170,246 @@ } }, "profile": { - "language": "Language" + "language": "Language", + "menu": { + "inviteFriends": "Invite friends to Peanut", + "yourCard": "Your Card", + "peanutCard": "Peanut Card", + "newBadge": "New!", + "yourBadges": "Your Badges", + "points": "Points", + "personalDetails": "Personal details", + "unlockedRegions": "Unlocked regions", + "paymentLimits": "Payment limits", + "showMyFullName": "Show my full name", + "backup": "Backup", + "exchangeRatesAndFees": "Exchange rates and fees", + "starAlt": "star" + }, + "logOut": "Log out", + "edit": { + "title": "Edit Profile", + "fields": { + "name": "Name", + "surname": "Surname", + "bio": "Bio", + "email": "Email", + "phoneNumber": "Phone number", + "website": "Website" + }, + "placeholders": { + "name": "Add your name", + "surname": "Add your surname", + "bio": "Add a bio", + "email": "Add your email", + "phone": "Add your number", + "website": "Add your website" + }, + "soonBadge": "Soon!", + "saveChanges": "Save Changes", + "errors": { + "nameRequired": "Please provide your name.", + "generic": "Something went wrong. Please try again or contact support." + } + }, + "regions": { + "title": "Unlocked regions", + "description": "Transfer to and receive from any bank account and use supported payments methods.", + "empty": { + "title": "No regions unlocked yet", + "description": "Tap a region below to confirm your ID and unlock payments there." + }, + "lockedTitle": "Locked regions", + "lockedDescription": "Where do you want to send and receive money?", + "providerRejection": { + "fixableTitle": "We need an updated document", + "restartTitle": "Verify with a different document", + "unavailableTitle": "Region unavailable", + "fixableDescription": "Please upload a clearer photo of your ID to unlock this region.", + "restartDescription": "This region needs a document from a supported country. You can verify with a different ID.", + "unavailableDescription": "This region is not available for your account. Contact support for help.", + "uploadDocument": "Upload document" + }, + "initError": { + "retriableTitle": "Verification couldn't start", + "notAvailableTitle": "Not available yet", + "fallbackDescription": "Something went wrong. Please try again or contact support." + } + }, + "backup": { + "title": "Backup", + "nonCustodial": { + "title": "Non-custodial wallet", + "description": "Only you control this wallet. We can't recover it if you lose access. Your backup is your responsibility." + }, + "enableNow": "Enable backup now", + "steps": { + "android": { + "step1": { + "title": "Open Settings", + "description": "Google → All services" + }, + "step2": { + "title": "Find Password Manager", + "description": "Autofill → Google Password Manager → Turn ON sync" + }, + "step3": { + "title": "Verify in Chrome", + "description": "Settings → Passwords → Search \"Peanut\"" + } + }, + "ios": { + "step1": { + "title": "Go to Settings", + "description": "[Your Name] → iCloud" + }, + "step2": { + "title": "Find Passwords & Keychain", + "description": "Turn it on" + }, + "step3": { + "title": "Verify it worked", + "description": "Settings → Passwords → Search \"Peanut\"" + } + } + }, + "noBackupWarning": { + "title": "No backup = No recovery", + "description": "If you lose your phone without backup enabled, your money is gone forever." + }, + "faqHeading": "Common questions", + "faq": { + "losePhone": "What if I lose my phone?", + "changePhone": "What if I change phone?", + "exportKeys": "Why can't I export my private key?" + }, + "losePhoneModal": { + "enabledTitle": "Backup is enabled", + "enabledDescription": "Sign into your new phone with your {platform, select, android {Google account} other {Apple ID}}. Download Peanut. Your wallet restores automatically", + "noBackupTitle": "No backup", + "noBackupDescription": "Your funds are permanently lost, we can't recover your wallet. This is how self-custody works." + }, + "changePhoneModal": { + "step1": "Verify backup is working (check step 3 above)", + "step2": "{platform, select, android {Know your Google account password} other {Know your Apple ID password}}", + "step3": "Keep old phone until new one works", + "iphoneToIphoneTitle": "iPhone → iPhone", + "iphoneToIphoneDescription": "Just sign in. Everything transfers.", + "androidToAndroidTitle": "Android → Android", + "androidToAndroidDescription": "Sign into Google. Your wallet follows.", + "crossPlatformTitle": "iPhone ↔ Android", + "crossPlatformDescription": "Create new wallet on new device. Transfer your funds. Passkeys don't work cross-platform unless you are using a third party password manager such as 1Password." + }, + "exportKeysModal": { + "saferTitle": "Passkeys are safer by design", + "saferIntro": "Unlike traditional private keys that can be copied as text, passkeys are cryptographic credentials that can't be:", + "bullets": { + "screenshot": "Screenshot by accident", + "textMessage": "Sent in a text message", + "noteApp": "Saved in an unsafe note app", + "malware": "Stolen by malware" + }, + "tradeoffTitle": "The tradeoff", + "tradeoffDescription": "You get better security, but passkeys are locked to your device ecosystem (Apple or Google). That's why cloud backup is critical.", + "futureNote": "We're exploring advanced export options for power users in future updates." + } + }, + "publicProfile": { + "peanutMascotAlt": "Peanut mascot", + "peanutLogoTextAlt": "Peanut Text", + "joinPeanutAlt": "Join Peanut", + "allSetTitle": "You're all set", + "allSetDescription": "Now send or request money to get started.", + "joinPeanut": "Join Peanut!", + "inviteOnlyLine1": "Peanut is invite-only.", + "inviteOnlyLine2": "Go beg your friend for an invite link!", + "begShareText": "Bro… I’m on my knees. Peanut is invite-only and I’m locked outside. Save my life and send me your invite", + "begForInvite": "Beg for an invite", + "activityPrivateNote": "Activity is only visible for you, it is not public.", + "noInviteTitle": "No invite, no Peanut" + } }, "settings": { "language": { "title": "Language" + }, + "deleteAccount": { + "button": "Delete My Account", + "confirmTitle": "Aw, you're leaving?", + "confirmDescription": "Deleting your account is permanent. We'll switch it off right away and wipe your data within 30 days. This little guy would really rather you stayed.", + "confirmCta": "Yes, delete it", + "cancelCta": "Never mind, I'll stay", + "doneTitle": "We'll miss you", + "doneDescription": "Your account is off and your data will be gone within 30 days. Thanks for cracking open Peanut with us — take care out there!", + "doneCta": "Goodbye", + "error": "Could not delete your account. Please try again.", + "sadPeanutAlt": "Sad peanut", + "cryingPeanutAlt": "Crying peanut" + } + }, + "limits": { + "title": "Payment limits", + "pageDescription": "Payment limits control how much you can send and receive. Limits vary by region and reset monthly or yearly.", + "unlockedRegions": "Unlocked regions", + "lockedRegions": "Locked regions", + "otherRegions": "Other regions", + "restOfWorld": "Rest of the world", + "cardLimits": { + "title": "Card limits", + "manage": "Manage card limits", + "description": "Transaction cap for your Peanut card." + }, + "fiatLocked": { + "title": "Unlock fiat payments", + "locked": "Locked", + "description": "Confirm your ID to unlock fiat payments and see your limits.", + "cta": "Unlock now" + }, + "crypto": { + "title": "Crypto limits", + "noLimits": "No limits", + "tooltip": "Crypto transactions are global and are not affected by regions." + }, + "provider": { + "title": "Limits", + "fiatLimits": "Fiat limits:", + "addMoneyLimit": "Add money: up to {amount} per transaction", + "withdrawLimit": "Withdrawing: up to {amount} per transaction", + "qrPaymentLimits": "QR payment limits:", + "qrPayLimit": "Paying with QR: up to {amount} per transaction", + "noData": "Limits data not available", + "totalAllowed": "{asset} total allowed", + "remaining": "{period, select, monthly {Remaining this month} other {Remaining this year}}", + "appliesTo": "Applies to adding money, withdraws and QR payments" + }, + "period": { + "monthly": "Monthly", + "yearly": "Yearly", + "selectAriaLabel": "Select period" + }, + "increase": { + "cta": "Increase my limits", + "submitted": "Document submitted! Your limits will be updated shortly." + }, + "error": { + "description": "Failed to load limits. Please try again." + }, + "docsLink": "See more about limits", + "needHigherLimits": "Need higher limits? Contact support." + }, + "exchangeRate": { + "title": "Exchange rate & fees", + "tryIt": "Try it!", + "widget": { + "youSend": "You Send", + "recipientGets": "Recipient Gets", + "swapCurrencies": "Swap currencies", + "rateUnavailable": "Rate currently unavailable", + "bankFee": "Bank fee", + "peanutFee": "Peanut fee", + "free": "Free!", + "arrivesHours": "Should arrive in hours.", + "arrivesMinutes": "Should arrive in minutes." } }, "setup": { diff --git a/src/i18n/app/messages/es-419.json b/src/i18n/app/messages/es-419.json index d016a0e1ca..726e8ccbc4 100644 --- a/src/i18n/app/messages/es-419.json +++ b/src/i18n/app/messages/es-419.json @@ -13,7 +13,11 @@ "copiedToClipboard": "Copiado al portapapeles", "maybeLater": "Quizás más tarde", "tryAgain": "Intentar de nuevo", - "skipForNow": "Omitir por ahora" + "skipForNow": "Omitir por ahora", + "gotIt": "Entendido", + "contactSupport": "Contactar soporte", + "pending": "Pendiente", + "comingSoon": "Próximamente" }, "navigation": { "home": "Inicio", @@ -166,11 +170,246 @@ } }, "profile": { - "language": "Idioma" + "language": "Idioma", + "menu": { + "inviteFriends": "Invita amigos a Peanut", + "yourCard": "Tu tarjeta", + "peanutCard": "Tarjeta Peanut", + "newBadge": "¡Nuevo!", + "yourBadges": "Tus insignias", + "points": "Puntos", + "personalDetails": "Datos personales", + "unlockedRegions": "Regiones desbloqueadas", + "paymentLimits": "Límites de pago", + "showMyFullName": "Mostrar mi nombre completo", + "backup": "Respaldo", + "exchangeRatesAndFees": "Tipos de cambio y comisiones", + "starAlt": "estrella" + }, + "logOut": "Cerrar sesión", + "edit": { + "title": "Editar perfil", + "fields": { + "name": "Nombre", + "surname": "Apellido", + "bio": "Bio", + "email": "Email", + "phoneNumber": "Número de teléfono", + "website": "Sitio web" + }, + "placeholders": { + "name": "Agrega tu nombre", + "surname": "Agrega tu apellido", + "bio": "Agrega una bio", + "email": "Agrega tu email", + "phone": "Agrega tu número", + "website": "Agrega tu sitio web" + }, + "soonBadge": "¡Pronto!", + "saveChanges": "Guardar cambios", + "errors": { + "nameRequired": "Por favor ingresa tu nombre.", + "generic": "Algo salió mal. Intenta de nuevo o contacta soporte." + } + }, + "regions": { + "title": "Regiones desbloqueadas", + "description": "Transfiere y recibe desde cualquier cuenta bancaria y usa los métodos de pago disponibles.", + "empty": { + "title": "Aún no tienes regiones desbloqueadas", + "description": "Toca una región abajo para confirmar tu identidad y desbloquear pagos ahí." + }, + "lockedTitle": "Regiones bloqueadas", + "lockedDescription": "¿A dónde quieres enviar y recibir dinero?", + "providerRejection": { + "fixableTitle": "Necesitamos un documento actualizado", + "restartTitle": "Verifícate con otro documento", + "unavailableTitle": "Región no disponible", + "fixableDescription": "Sube una foto más clara de tu documento para desbloquear esta región.", + "restartDescription": "Esta región necesita un documento de un país compatible. Puedes verificarte con otro documento.", + "unavailableDescription": "Esta región no está disponible para tu cuenta. Contacta soporte para recibir ayuda.", + "uploadDocument": "Subir documento" + }, + "initError": { + "retriableTitle": "No se pudo iniciar la verificación", + "notAvailableTitle": "Aún no disponible", + "fallbackDescription": "Algo salió mal. Intenta de nuevo o contacta soporte." + } + }, + "backup": { + "title": "Respaldo", + "nonCustodial": { + "title": "Billetera sin custodia", + "description": "Solo tú controlas esta billetera. No podemos recuperarla si pierdes el acceso. El respaldo es tu responsabilidad." + }, + "enableNow": "Activa el respaldo ahora", + "steps": { + "android": { + "step1": { + "title": "Abre Configuración", + "description": "Google → Todos los servicios" + }, + "step2": { + "title": "Busca el Administrador de contraseñas", + "description": "Autocompletar → Administrador de contraseñas de Google → Activa la sincronización" + }, + "step3": { + "title": "Verifica en Chrome", + "description": "Configuración → Contraseñas → Busca \"Peanut\"" + } + }, + "ios": { + "step1": { + "title": "Ve a Configuración", + "description": "[Tu nombre] → iCloud" + }, + "step2": { + "title": "Busca Contraseñas y llavero", + "description": "Actívalo" + }, + "step3": { + "title": "Verifica que funcionó", + "description": "Configuración → Contraseñas → Busca \"Peanut\"" + } + } + }, + "noBackupWarning": { + "title": "Sin respaldo = Sin recuperación", + "description": "Si pierdes tu teléfono sin el respaldo activado, tu dinero se pierde para siempre." + }, + "faqHeading": "Preguntas frecuentes", + "faq": { + "losePhone": "¿Qué pasa si pierdo mi teléfono?", + "changePhone": "¿Qué pasa si cambio de teléfono?", + "exportKeys": "¿Por qué no puedo exportar mi llave privada?" + }, + "losePhoneModal": { + "enabledTitle": "Respaldo activado", + "enabledDescription": "Inicia sesión en tu nuevo teléfono con tu {platform, select, android {cuenta de Google} other {Apple ID}}. Descarga Peanut. Tu billetera se restaura automáticamente", + "noBackupTitle": "Sin respaldo", + "noBackupDescription": "Tus fondos se pierden para siempre, no podemos recuperar tu billetera. Así funciona la autocustodia." + }, + "changePhoneModal": { + "step1": "Verifica que el respaldo funcione (revisa el paso 3 de arriba)", + "step2": "Recuerda la contraseña de tu {platform, select, android {cuenta de Google} other {Apple ID}}", + "step3": "Conserva tu teléfono viejo hasta que el nuevo funcione", + "iphoneToIphoneTitle": "iPhone → iPhone", + "iphoneToIphoneDescription": "Solo inicia sesión. Todo se transfiere.", + "androidToAndroidTitle": "Android → Android", + "androidToAndroidDescription": "Inicia sesión en Google. Tu billetera te sigue.", + "crossPlatformTitle": "iPhone ↔ Android", + "crossPlatformDescription": "Crea una billetera nueva en el nuevo dispositivo. Transfiere tus fondos. Las passkeys no funcionan entre plataformas a menos que uses un administrador de contraseñas externo como 1Password." + }, + "exportKeysModal": { + "saferTitle": "Las passkeys son más seguras por diseño", + "saferIntro": "A diferencia de las llaves privadas tradicionales que se pueden copiar como texto, las passkeys son credenciales criptográficas que no pueden ser:", + "bullets": { + "screenshot": "Capturadas en pantalla por accidente", + "textMessage": "Enviadas por mensaje de texto", + "noteApp": "Guardadas en una app de notas insegura", + "malware": "Robadas por malware" + }, + "tradeoffTitle": "La desventaja", + "tradeoffDescription": "Obtienes mejor seguridad, pero las passkeys quedan ligadas al ecosistema de tu dispositivo (Apple o Google). Por eso el respaldo en la nube es clave.", + "futureNote": "Estamos explorando opciones avanzadas de exportación para usuarios expertos en futuras actualizaciones." + } + }, + "publicProfile": { + "peanutMascotAlt": "Mascota de Peanut", + "peanutLogoTextAlt": "Logo de Peanut", + "joinPeanutAlt": "Únete a Peanut", + "allSetTitle": "Todo listo", + "allSetDescription": "Ahora envía o solicita dinero para empezar.", + "joinPeanut": "¡Únete a Peanut!", + "inviteOnlyLine1": "Peanut es solo por invitación.", + "inviteOnlyLine2": "¡Ve y ruégale a tu amigo por un enlace de invitación!", + "begShareText": "Bro… estoy de rodillas. Peanut es solo por invitación y me quedé afuera. Sálvame la vida y mándame tu invitación", + "begForInvite": "Rogar por una invitación", + "activityPrivateNote": "La actividad solo es visible para ti, no es pública.", + "noInviteTitle": "Sin invitación no hay Peanut" + } }, "settings": { "language": { "title": "Idioma" + }, + "deleteAccount": { + "button": "Eliminar mi cuenta", + "confirmTitle": "Oh, ¿te vas?", + "confirmDescription": "Eliminar tu cuenta es permanente. La desactivaremos de inmediato y borraremos tus datos en un plazo de 30 días. Este amiguito preferiría que te quedaras.", + "confirmCta": "Sí, eliminarla", + "cancelCta": "Mejor no, me quedo", + "doneTitle": "Te vamos a extrañar", + "doneDescription": "Tu cuenta está desactivada y tus datos desaparecerán en un plazo de 30 días. Gracias por abrir Peanut con nosotros — ¡cuídate mucho!", + "doneCta": "Adiós", + "error": "No pudimos eliminar tu cuenta. Intenta de nuevo.", + "sadPeanutAlt": "Peanut triste", + "cryingPeanutAlt": "Peanut llorando" + } + }, + "limits": { + "title": "Límites de pago", + "pageDescription": "Los límites de pago controlan cuánto puedes enviar y recibir. Varían según la región y se reinician cada mes o cada año.", + "unlockedRegions": "Regiones desbloqueadas", + "lockedRegions": "Regiones bloqueadas", + "otherRegions": "Otras regiones", + "restOfWorld": "Resto del mundo", + "cardLimits": { + "title": "Límites de la tarjeta", + "manage": "Administrar límites de la tarjeta", + "description": "Tope por transacción para tu tarjeta Peanut." + }, + "fiatLocked": { + "title": "Desbloquea pagos fiat", + "locked": "Bloqueado", + "description": "Confirma tu identidad para desbloquear pagos fiat y ver tus límites.", + "cta": "Desbloquear ahora" + }, + "crypto": { + "title": "Límites cripto", + "noLimits": "Sin límites", + "tooltip": "Las transacciones cripto son globales y no dependen de regiones." + }, + "provider": { + "title": "Límites", + "fiatLimits": "Límites fiat:", + "addMoneyLimit": "Agregar dinero: hasta {amount} por transacción", + "withdrawLimit": "Retiros: hasta {amount} por transacción", + "qrPaymentLimits": "Límites de pagos QR:", + "qrPayLimit": "Pagos con QR: hasta {amount} por transacción", + "noData": "Datos de límites no disponibles", + "totalAllowed": "{asset} total permitido", + "remaining": "{period, select, monthly {Restante este mes} other {Restante este año}}", + "appliesTo": "Aplica a agregar dinero, retiros y pagos QR" + }, + "period": { + "monthly": "Mensual", + "yearly": "Anual", + "selectAriaLabel": "Seleccionar período" + }, + "increase": { + "cta": "Aumentar mis límites", + "submitted": "¡Documento enviado! Tus límites se actualizarán pronto." + }, + "error": { + "description": "No se pudieron cargar los límites. Intenta de nuevo." + }, + "docsLink": "Ver más sobre límites", + "needHigherLimits": "¿Necesitas límites más altos? Contacta soporte." + }, + "exchangeRate": { + "title": "Tipo de cambio y comisiones", + "tryIt": "¡Pruébalo!", + "widget": { + "youSend": "Tú envías", + "recipientGets": "El destinatario recibe", + "swapCurrencies": "Intercambiar monedas", + "rateUnavailable": "Tipo de cambio no disponible por ahora", + "bankFee": "Comisión bancaria", + "peanutFee": "Comisión de Peanut", + "free": "¡Gratis!", + "arrivesHours": "Debería llegar en horas.", + "arrivesMinutes": "Debería llegar en minutos." } }, "setup": { diff --git a/src/i18n/app/messages/pt-BR.json b/src/i18n/app/messages/pt-BR.json index 154d607f98..433ab9b344 100644 --- a/src/i18n/app/messages/pt-BR.json +++ b/src/i18n/app/messages/pt-BR.json @@ -13,7 +13,11 @@ "copiedToClipboard": "Copiado para a área de transferência", "maybeLater": "Talvez depois", "tryAgain": "Tentar de novo", - "skipForNow": "Pular por enquanto" + "skipForNow": "Pular por enquanto", + "gotIt": "Entendi", + "contactSupport": "Falar com o suporte", + "pending": "Pendente", + "comingSoon": "Em breve" }, "navigation": { "home": "Início", @@ -166,11 +170,246 @@ } }, "profile": { - "language": "Idioma" + "language": "Idioma", + "menu": { + "inviteFriends": "Convide amigos para o Peanut", + "yourCard": "Seu cartão", + "peanutCard": "Cartão Peanut", + "newBadge": "Novo!", + "yourBadges": "Seus emblemas", + "points": "Pontos", + "personalDetails": "Dados pessoais", + "unlockedRegions": "Regiões desbloqueadas", + "paymentLimits": "Limites de pagamento", + "showMyFullName": "Mostrar meu nome completo", + "backup": "Backup", + "exchangeRatesAndFees": "Câmbio e taxas", + "starAlt": "estrela" + }, + "logOut": "Sair", + "edit": { + "title": "Editar perfil", + "fields": { + "name": "Nome", + "surname": "Sobrenome", + "bio": "Bio", + "email": "Email", + "phoneNumber": "Número de telefone", + "website": "Site" + }, + "placeholders": { + "name": "Adicione seu nome", + "surname": "Adicione seu sobrenome", + "bio": "Adicione uma bio", + "email": "Adicione seu email", + "phone": "Adicione seu número", + "website": "Adicione seu site" + }, + "soonBadge": "Em breve!", + "saveChanges": "Salvar alterações", + "errors": { + "nameRequired": "Informe seu nome.", + "generic": "Algo deu errado. Tente de novo ou fale com o suporte." + } + }, + "regions": { + "title": "Regiões desbloqueadas", + "description": "Transfira e receba de qualquer conta bancária e use os métodos de pagamento disponíveis.", + "empty": { + "title": "Nenhuma região desbloqueada ainda", + "description": "Toque em uma região abaixo para confirmar sua identidade e desbloquear pagamentos lá." + }, + "lockedTitle": "Regiões bloqueadas", + "lockedDescription": "Para onde você quer enviar e receber dinheiro?", + "providerRejection": { + "fixableTitle": "Precisamos de um documento atualizado", + "restartTitle": "Verifique com outro documento", + "unavailableTitle": "Região indisponível", + "fixableDescription": "Envie uma foto mais nítida do seu documento para desbloquear esta região.", + "restartDescription": "Esta região exige um documento de um país compatível. Você pode verificar com outro documento.", + "unavailableDescription": "Esta região não está disponível para a sua conta. Fale com o suporte para receber ajuda.", + "uploadDocument": "Enviar documento" + }, + "initError": { + "retriableTitle": "Não foi possível iniciar a verificação", + "notAvailableTitle": "Ainda não disponível", + "fallbackDescription": "Algo deu errado. Tente de novo ou fale com o suporte." + } + }, + "backup": { + "title": "Backup", + "nonCustodial": { + "title": "Carteira sem custódia", + "description": "Só você controla esta carteira. Não podemos recuperá-la se você perder o acesso. O backup é sua responsabilidade." + }, + "enableNow": "Ative o backup agora", + "steps": { + "android": { + "step1": { + "title": "Abra as Configurações", + "description": "Google → Todos os serviços" + }, + "step2": { + "title": "Encontre o Gerenciador de senhas", + "description": "Preenchimento automático → Gerenciador de senhas do Google → Ative a sincronização" + }, + "step3": { + "title": "Confira no Chrome", + "description": "Configurações → Senhas → Busque \"Peanut\"" + } + }, + "ios": { + "step1": { + "title": "Vá em Ajustes", + "description": "[Seu nome] → iCloud" + }, + "step2": { + "title": "Encontre Senhas e Chaves", + "description": "Ative a opção" + }, + "step3": { + "title": "Confira se funcionou", + "description": "Ajustes → Senhas → Busque \"Peanut\"" + } + } + }, + "noBackupWarning": { + "title": "Sem backup = Sem recuperação", + "description": "Se você perder o celular sem o backup ativado, seu dinheiro se perde para sempre." + }, + "faqHeading": "Perguntas frequentes", + "faq": { + "losePhone": "E se eu perder meu celular?", + "changePhone": "E se eu trocar de celular?", + "exportKeys": "Por que não consigo exportar minha chave privada?" + }, + "losePhoneModal": { + "enabledTitle": "Backup ativado", + "enabledDescription": "{platform, select, android {Entre no novo celular com a sua conta do Google.} other {Entre no novo celular com o seu ID Apple.}} Baixe o Peanut. Sua carteira é restaurada automaticamente", + "noBackupTitle": "Sem backup", + "noBackupDescription": "Seus fundos se perdem para sempre, não podemos recuperar sua carteira. É assim que a autocustódia funciona." + }, + "changePhoneModal": { + "step1": "Confira se o backup está funcionando (veja o passo 3 acima)", + "step2": "{platform, select, android {Saiba a senha da sua conta do Google} other {Saiba a senha do seu ID Apple}}", + "step3": "Fique com o celular antigo até o novo funcionar", + "iphoneToIphoneTitle": "iPhone → iPhone", + "iphoneToIphoneDescription": "É só entrar. Tudo é transferido.", + "androidToAndroidTitle": "Android → Android", + "androidToAndroidDescription": "Entre no Google. Sua carteira vai junto.", + "crossPlatformTitle": "iPhone ↔ Android", + "crossPlatformDescription": "Crie uma carteira nova no novo aparelho. Transfira seus fundos. Passkeys não funcionam entre plataformas, a menos que você use um gerenciador de senhas de terceiros como o 1Password." + }, + "exportKeysModal": { + "saferTitle": "Passkeys são mais seguras por design", + "saferIntro": "Diferente das chaves privadas tradicionais, que podem ser copiadas como texto, passkeys são credenciais criptográficas que não podem ser:", + "bullets": { + "screenshot": "Capturadas por acidente em um print", + "textMessage": "Enviadas por mensagem de texto", + "noteApp": "Salvas em um app de notas inseguro", + "malware": "Roubadas por malware" + }, + "tradeoffTitle": "O outro lado", + "tradeoffDescription": "Você ganha mais segurança, mas as passkeys ficam presas ao ecossistema do seu aparelho (Apple ou Google). Por isso o backup na nuvem é essencial.", + "futureNote": "Estamos explorando opções avançadas de exportação para usuários avançados em atualizações futuras." + } + }, + "publicProfile": { + "peanutMascotAlt": "Mascote do Peanut", + "peanutLogoTextAlt": "Logo do Peanut", + "joinPeanutAlt": "Entre no Peanut", + "allSetTitle": "Tudo pronto", + "allSetDescription": "Agora envie ou solicite dinheiro para começar.", + "joinPeanut": "Entre no Peanut!", + "inviteOnlyLine1": "O Peanut é só por convite.", + "inviteOnlyLine2": "Vá implorar um link de convite para o seu amigo!", + "begShareText": "Mano… estou de joelhos. O Peanut é só por convite e fiquei de fora. Salva minha vida e me manda seu convite", + "begForInvite": "Implorar por um convite", + "activityPrivateNote": "A atividade é visível só para você, não é pública.", + "noInviteTitle": "Sem convite, sem Peanut" + } }, "settings": { "language": { "title": "Idioma" + }, + "deleteAccount": { + "button": "Excluir minha conta", + "confirmTitle": "Ah, você vai embora?", + "confirmDescription": "Excluir sua conta é permanente. Vamos desativá-la imediatamente e apagar seus dados em até 30 dias. Este carinha preferia que você ficasse.", + "confirmCta": "Sim, excluir", + "cancelCta": "Deixa pra lá, vou ficar", + "doneTitle": "Vamos sentir sua falta", + "doneDescription": "Sua conta foi desativada e seus dados serão apagados em até 30 dias. Obrigado por abrir o Peanut com a gente — se cuida!", + "doneCta": "Tchau", + "error": "Não foi possível excluir sua conta. Tente de novo.", + "sadPeanutAlt": "Peanut triste", + "cryingPeanutAlt": "Peanut chorando" + } + }, + "limits": { + "title": "Limites de pagamento", + "pageDescription": "Os limites de pagamento controlam quanto você pode enviar e receber. Variam por região e são renovados todo mês ou ano.", + "unlockedRegions": "Regiões desbloqueadas", + "lockedRegions": "Regiões bloqueadas", + "otherRegions": "Outras regiões", + "restOfWorld": "Resto do mundo", + "cardLimits": { + "title": "Limites do cartão", + "manage": "Gerenciar limites do cartão", + "description": "Teto por transação do seu cartão Peanut." + }, + "fiatLocked": { + "title": "Desbloqueie pagamentos fiat", + "locked": "Bloqueado", + "description": "Confirme sua identidade para desbloquear pagamentos fiat e ver seus limites.", + "cta": "Desbloquear agora" + }, + "crypto": { + "title": "Limites cripto", + "noLimits": "Sem limites", + "tooltip": "Transações cripto são globais e não dependem de regiões." + }, + "provider": { + "title": "Limites", + "fiatLimits": "Limites fiat:", + "addMoneyLimit": "Adicionar dinheiro: até {amount} por transação", + "withdrawLimit": "Saques: até {amount} por transação", + "qrPaymentLimits": "Limites de pagamento QR:", + "qrPayLimit": "Pagando com QR: até {amount} por transação", + "noData": "Dados de limites indisponíveis", + "totalAllowed": "{asset} total permitido", + "remaining": "{period, select, monthly {Restante neste mês} other {Restante neste ano}}", + "appliesTo": "Vale para adicionar dinheiro, saques e pagamentos QR" + }, + "period": { + "monthly": "Mensal", + "yearly": "Anual", + "selectAriaLabel": "Selecionar período" + }, + "increase": { + "cta": "Aumentar meus limites", + "submitted": "Documento enviado! Seus limites serão atualizados em breve." + }, + "error": { + "description": "Não foi possível carregar os limites. Tente de novo." + }, + "docsLink": "Saiba mais sobre limites", + "needHigherLimits": "Precisa de limites maiores? Fale com o suporte." + }, + "exchangeRate": { + "title": "Câmbio e taxas", + "tryIt": "Experimente!", + "widget": { + "youSend": "Você envia", + "recipientGets": "O destinatário recebe", + "swapCurrencies": "Trocar moedas", + "rateUnavailable": "Câmbio indisponível no momento", + "bankFee": "Taxa do banco", + "peanutFee": "Taxa do Peanut", + "free": "Grátis!", + "arrivesHours": "Deve chegar em horas.", + "arrivesMinutes": "Deve chegar em minutos." } }, "setup": { From e99cf4118a5385af06c15da44f7750e1d3d7ff9c Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Thu, 16 Jul 2026 14:46:06 +0100 Subject: [PATCH 12/44] feat(i18n): extract send, request, payment and qr-pay strings Limits warning-card items now carry a kind discriminator so render sites can map them to translated copy; qr-pay uses it. --- .../qr-pay/__tests__/qr-pay-states.test.tsx | 14 +- src/app/(mobile-ui)/qr-pay/page.tsx | 234 ++++++++++-------- src/app/(mobile-ui)/qr/[code]/page.tsx | 38 +-- .../(mobile-ui)/qr/[code]/success/page.tsx | 29 ++- src/app/[...recipient]/error.tsx | 13 +- .../Payment/Views/Error.validation.view.tsx | 10 +- .../Request/__tests__/request-states.test.tsx | 32 ++- .../views/Initial.direct.request.view.tsx | 54 ++-- .../link/views/Create.request.link.view.tsx | 41 +-- .../Send/__tests__/send-states.test.tsx | 10 +- .../Send/link/LinkSendFlowManager.tsx | 4 +- .../link/views/Initial.link.send.view.tsx | 10 +- .../link/views/Success.link.send.view.tsx | 35 ++- src/components/Send/views/Contacts.view.tsx | 32 ++- src/components/Send/views/SendRouter.view.tsx | 32 ++- src/features/limits/utils.ts | 21 +- src/i18n/app/messages/en.json | 190 +++++++++++++- src/i18n/app/messages/es-419.json | 190 +++++++++++++- src/i18n/app/messages/pt-BR.json | 190 +++++++++++++- 19 files changed, 918 insertions(+), 261 deletions(-) diff --git a/src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx b/src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx index 0729f3a52a..3bad1aa2cd 100644 --- a/src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx +++ b/src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx @@ -11,6 +11,8 @@ import React from 'react' import posthog from 'posthog-js' import { render, screen, fireEvent, waitFor, act } from '@testing-library/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { NextIntlClientProvider } from 'next-intl' +import en from '@/i18n/app/messages/en.json' import { parseUnits } from 'viem' import type { RailCapability } from '@/types/capabilities' @@ -530,11 +532,13 @@ function renderQrPay(params: Record = {}) { } return render( - - - - - + + + + + + + ) } diff --git a/src/app/(mobile-ui)/qr-pay/page.tsx b/src/app/(mobile-ui)/qr-pay/page.tsx index 3c7d7a6464..f2db75c8f7 100644 --- a/src/app/(mobile-ui)/qr-pay/page.tsx +++ b/src/app/(mobile-ui)/qr-pay/page.tsx @@ -1,6 +1,7 @@ 'use client' import { useSearchParams, useRouter } from 'next/navigation' +import { useTranslations } from 'next-intl' import { useState, useCallback, useMemo, useEffect, useContext, useRef } from 'react' import { useSafeBack } from '@/hooks/useSafeBack' import { PeanutDoesntStoreAnyPersonalInformation } from '@/components/Kyc/PeanutDoesntStoreAnyPersonalInformation' @@ -86,19 +87,20 @@ const NON_RETRYABLE_QR_PAY_ERRORS = [ 'PIX_RECURRING_NOT_SUPPORTED', ] -// Shown wherever the backend rejects a Pix payment below the rail minimum -// (typed 400 PIX_MIN_AMOUNT — fires at lock-init for merchant-encoded amounts -// and at re-init for user-entered amounts on open-amount QRs). -const PIX_MIN_AMOUNT_ERROR_MESSAGE = `This Pix charge is below the ${MIN_PIX_AMOUNT_BRL} BRL minimum for Pix payments.` - -// PIX Automático (recurring) codes — rejected at the entry guard for scanned/pasted -// deep links, and mapped from the backend's typed 400 PIX_RECURRING_NOT_SUPPORTED. -const PIX_RECURRING_ERROR_MESSAGE = - "This QR code is for a recurring payment (PIX Automático). Peanut doesn't support recurring PIX payments — please ask for a regular PIX QR code instead." - type PaymentProcessor = 'MANTECA' export default function QRPayPage() { + const t = useTranslations('qrPay') + const tNav = useTranslations('navigation') + const tCommon = useTranslations('common') + const tLimits = useTranslations('limits') + // Shown wherever the backend rejects a Pix payment below the rail minimum + // (typed 400 PIX_MIN_AMOUNT — fires at lock-init for merchant-encoded amounts + // and at re-init for user-entered amounts on open-amount QRs). + const pixMinAmountErrorMessage = t('errors.pixMinAmount', { amount: MIN_PIX_AMOUNT_BRL }) + // PIX Automático (recurring) codes — rejected at the entry guard for scanned/pasted + // deep links, and mapped from the backend's typed 400 PIX_RECURRING_NOT_SUPPORTED. + const pixRecurringErrorMessage = t('errors.pixRecurring') const searchParams = useSearchParams() const router = useRouter() // QR-pay screens are terminal — leaving /qr-pay in history would let browser back from @@ -370,12 +372,12 @@ export default function QRPayPage() { // Before isPaymentProcessorQR: recurrence codes can match PIX_REGEX, and the // specific message must win over the generic "Invalid QR code scanned". if (qrCode && isPixRecurringCode(qrCode)) { - setErrorInitiatingPayment(PIX_RECURRING_ERROR_MESSAGE) + setErrorInitiatingPayment(pixRecurringErrorMessage) return } if (!qrCode || !isPaymentProcessorQR(qrCode)) { - setErrorInitiatingPayment('Invalid QR code scanned') + setErrorInitiatingPayment(t('errors.invalidQr')) return } @@ -424,10 +426,10 @@ export default function QRPayPage() { // button enabled so the user can retry, don't dead-end it like a hard error. return ( !!errorMessage && - errorMessage !== 'Please confirm the transaction.' && + errorMessage !== t('errors.confirmTransaction') && errorMessage !== BALANCE_SETTLING_MESSAGE ) - }, [errorMessage]) + }, [errorMessage, t]) const usdAmount = useMemo(() => { if (!paymentLock) return null @@ -561,25 +563,21 @@ export default function QRPayPage() { // Pix has no fallback rail in Brazil — ask the merchant to regenerate. // For Argentina (MERCADO_PAGO and ARGENTINA_QR3), MP is the dominant // rail, so suggesting an MP QR is the most useful fallback. - setErrorInitiatingPayment( - qrType === EQrType.PIX - ? 'We could not decode this Pix QR code. Please ask the merchant to generate a new one.' - : 'We could not decode this particular QR code. Please ask the Merchant if they can generate a Mercado Pago QR' - ) + setErrorInitiatingPayment(qrType === EQrType.PIX ? t('errors.pixDecode') : t('errors.genericDecode')) posthog.capture(ANALYTICS_EVENTS.QR_DECODING_ERROR_SHOWN, { qr_type: qrType }) setWaitingForMerchantAmount(false) } else if (error.message.includes('PIX_MIN_AMOUNT')) { // Deterministic rejection — the merchant-encoded amount is below // the rail minimum, so there's no merchant amount to wait for. setWaitingForMerchantAmount(false) - setErrorInitiatingPayment(PIX_MIN_AMOUNT_ERROR_MESSAGE) + setErrorInitiatingPayment(pixMinAmountErrorMessage) } else if (error.message.includes('PIX_RECURRING_NOT_SUPPORTED')) { setWaitingForMerchantAmount(false) - setErrorInitiatingPayment(PIX_RECURRING_ERROR_MESSAGE) + setErrorInitiatingPayment(pixRecurringErrorMessage) } else { // Network/timeout errors after all retries exhausted setErrorInitiatingPayment( - `We are currently experiencing issues with ${qrType ? NAME_BY_QR_TYPE[qrType as QrType] : 'QR'} payments. We are working to fix it as soon as possible` + t('errors.providerIssues', { method: (qrType && NAME_BY_QR_TYPE[qrType as QrType]) || 'QR' }) ) setWaitingForMerchantAmount(false) } @@ -593,6 +591,9 @@ export default function QRPayPage() { qrType, paymentProcessor, setLoadingState, + t, + pixMinAmountErrorMessage, + pixRecurringErrorMessage, ]) const merchantName = useMemo(() => { @@ -617,10 +618,10 @@ export default function QRPayPage() { if (error instanceof Error && error.message.includes('PIX_MIN_AMOUNT')) { // Deterministic rejection (user-entered amount below the rail // minimum) — actionable copy, not a Sentry-worthy surprise. - setErrorMessage(PIX_MIN_AMOUNT_ERROR_MESSAGE) + setErrorMessage(pixMinAmountErrorMessage) } else { captureException(error) - setErrorMessage('Could not initiate payment due to unexpected error. Please contact support') + setErrorMessage(t('errors.initiateUnexpected')) } setIsSuccess(false) setLoadingState('Idle') @@ -628,7 +629,7 @@ export default function QRPayPage() { } } if (finalPaymentLock.code === '') { - setErrorMessage('Could not fetch qr payment details') + setErrorMessage(t('errors.fetchDetails')) setIsSuccess(false) setLoadingState('Idle') return @@ -656,14 +657,14 @@ export default function QRPayPage() { if (error instanceof InsufficientSpendableError) { setErrorMessage(BALANCE_SETTLING_MESSAGE) } else if (error instanceof SessionKeyGrantRequiredError) { - setErrorMessage("One-time card authorization needed. You'll be asked to confirm once.") + setErrorMessage(t('errors.cardAuthNeeded')) } else if (rainMsg) { setErrorMessage(rainMsg) } else if ((error as Error).toString().includes('not allowed')) { - setErrorMessage('Please confirm the transaction.') + setErrorMessage(t('errors.confirmTransaction')) } else { captureException(error) - setErrorMessage('Could not sign the transaction.') + setErrorMessage(t('errors.signFailed')) } setIsSuccess(false) setLoadingState('Idle') @@ -737,19 +738,13 @@ export default function QRPayPage() { // Handle specific error cases if (errorMsg.toLowerCase().includes('nonce')) { - setErrorMessage( - 'Transaction failed due to account state change. Please try again. If the problem persists, contact support.' - ) + setErrorMessage(t('errors.accountStateChanged')) } else if (errorMsg.toLowerCase().includes('expired') || errorMsg.toLowerCase().includes('stale')) { - setErrorMessage('Payment session expired. Please scan the QR code again.') + setErrorMessage(t('errors.sessionExpired')) } else if (qrType === EQrType.PIX) { - setErrorMessage( - 'This specific QR code or merchant is not supported. Please try again with a different QR code.' - ) + setErrorMessage(t('errors.merchantNotSupported')) } else { - setErrorMessage( - 'Could not complete payment. Please scan the QR code again. If problem persists contact support' - ) + setErrorMessage(t('errors.completeFailed')) } setIsSuccess(false) } finally { @@ -765,6 +760,8 @@ export default function QRPayPage() { setLoadingState, qrType, handleStaleSession, + t, + pixMinAmountErrorMessage, ]) const payQR = useCallback(async () => { @@ -936,21 +933,21 @@ export default function QRPayPage() { // Manteca-specific validation (PIX, MercadoPago, QR3) if (paymentProcessor === 'MANTECA') { if (paymentAmount < parseUnits(MIN_MANTECA_QR_PAYMENT_AMOUNT.toString(), PEANUT_WALLET_TOKEN_DECIMALS)) { - setBalanceErrorMessage(`Payment amount must be at least $${MIN_MANTECA_QR_PAYMENT_AMOUNT}`) + setBalanceErrorMessage(t('errors.minMantecaAmount', { amount: MIN_MANTECA_QR_PAYMENT_AMOUNT })) return } // PIX rail enforces a 1 BRL minimum, stricter than the USD floor above if (currency?.code === 'BRL' && currencyAmount && parseFloat(currencyAmount) < MIN_PIX_AMOUNT_BRL) { - setBalanceErrorMessage(`Minimum PIX amount is ${MIN_PIX_AMOUNT_BRL} BRL`) + setBalanceErrorMessage(t('errors.minPixAmountBrl', { amount: MIN_PIX_AMOUNT_BRL })) return } } // Common validations for all payment processors if (paymentAmount > parseUnits(MAX_QR_PAYMENT_AMOUNT, PEANUT_WALLET_TOKEN_DECIMALS)) { - setBalanceErrorMessage(`QR payment amount exceeds maximum limit of $${MAX_QR_PAYMENT_AMOUNT}`) + setBalanceErrorMessage(t('errors.maxQrAmount', { amount: MAX_QR_PAYMENT_AMOUNT })) } else if (paymentAmount < parseUnits(MIN_QR_PAYMENT_AMOUNT, PEANUT_WALLET_TOKEN_DECIMALS)) { - setBalanceErrorMessage(`QR payment amount must be at least $${MIN_QR_PAYMENT_AMOUNT}`) + setBalanceErrorMessage(t('errors.minQrAmount', { amount: MIN_QR_PAYMENT_AMOUNT })) } else if (!isAmountWithinBalance(usdAmount, balance)) { // gate on the displayed total; an in-transit shortfall passes here and // fails late with the settling message at execution. @@ -958,7 +955,7 @@ export default function QRPayPage() { } else { setBalanceErrorMessage(null) } - }, [usdAmount, balance, paymentProcessor, currency?.code, currencyAmount]) + }, [usdAmount, balance, paymentProcessor, currency?.code, currencyAmount, t]) // Use points confetti hook for animation - must be called unconditionally usePointsConfetti(isSuccess && pointsData?.estimatedPoints ? pointsData.estimatedPoints : undefined, pointsDivRef) @@ -1016,35 +1013,33 @@ export default function QRPayPage() { const isRestartIdentity = kycGateState === QrKycState.PROVIDER_RESTART_IDENTITY return (
- + + {t('paymentMethodAlt')} ) : undefined } ctas={[ isFixable ? { - text: 'Upload document', + text: t('kyc.uploadDocument'), onClick: () => sumsubFlow.handleSelfHealResubmit('MANTECA'), variant: 'purple' as const, shadowSize: '4' as const, @@ -1052,14 +1047,14 @@ export default function QRPayPage() { } : isRestartIdentity ? { - text: 'Verify with a different document', + text: t('kyc.restartTitle'), onClick: () => sumsubFlow.handleRestartIdentity(), variant: 'purple' as const, shadowSize: '4' as const, icon: 'upload', } : { - text: 'Contact support', + text: tCommon('contactSupport'), onClick: () => setIsSupportModalOpen(true), variant: 'stroke' as const, }, @@ -1078,20 +1073,20 @@ export default function QRPayPage() { if (needsKycVerification) { return (
- + + {t('paymentMethodAlt')} ) : undefined } ctas={[ { - text: 'Unlock now', + text: t('kyc.unlockCta'), onClick: () => sumsubFlow.handleInitiateKyc( 'LATAM', @@ -1109,12 +1104,12 @@ export default function QRPayPage() { sumsubFlow.handleInitiateKyc( 'LATAM', @@ -1127,7 +1122,7 @@ export default function QRPayPage() { icon: 'check-circle', }, { - text: 'Not now', + text: t('kyc.notNow'), onClick: onBack, variant: 'transparent', className: 'underline text-sm font-medium w-full h-fit mt-3', @@ -1147,21 +1142,20 @@ export default function QRPayPage() {
- Service Temporarily Unavailable + {t('maintenance.title')}

- We're experiencing issues with {paymentMethodName} payments due to an external provider outage. - We're working to restore service as soon as possible. + {t('maintenance.description', { method: paymentMethodName })}

) @@ -1174,13 +1168,10 @@ export default function QRPayPage() {
-

- {' '} - {errorInitiatingPayment || 'An error occurred while getting the QR details.'} -

+

{errorInitiatingPayment || t('errors.genericQrDetails')}

@@ -1191,7 +1182,7 @@ export default function QRPayPage() { const isLoadingPaymentData = isFirstLoad || (paymentProcessor === 'MANTECA' && !paymentLock) || !currency if (waitingForMerchantAmount) { - return + return } if (showOrderNotReadyModal) { @@ -1201,10 +1192,8 @@ export default function QRPayPage() {
- We couldn't get the amount -

- Ask the merchant to enter it and scan the QR again. -

+ {t('orderNotReady.title')} +

{t('orderNotReady.description')}

) @@ -1248,7 +1237,7 @@ export default function QRPayPage() { return (
- +
{/* Only show payment card if reward was not claimed */} {!perkClaimed && !qrPayment?.perk?.claimed && ( @@ -1265,7 +1254,10 @@ export default function QRPayPage() {

- You paid {qrPayment?.details.merchant.name ?? paymentLock?.paymentRecipientName} + {t('success.youPaid', { + merchant: + qrPayment?.details.merchant.name ?? paymentLock?.paymentRecipientName ?? '', + })}

{currency.symbol}{' '} @@ -1292,15 +1284,17 @@ export default function QRPayPage() { star
-

You earned a reward!

+

{t('success.earnedRewardTitle')}

{(() => { const amountSponsored = qrPayment?.perk?.amountSponsored if (amountSponsored && typeof amountSponsored === 'number') { - return `You earned $${amountSponsored.toFixed(2)}! Hold to claim your reward.` + return t('success.earnedHoldToClaim', { + amount: amountSponsored.toFixed(2), + }) } - return 'You earned a reward! Hold to claim.' + return t('success.holdToClaim') })()}

@@ -1314,16 +1308,18 @@ export default function QRPayPage() { star
-

You earned a reward!

+

{t('success.earnedRewardTitle')}

{(() => { const amountSponsored = qrPayment?.perk?.amountSponsored if (amountSponsored && typeof amountSponsored === 'number') { - return `You earned $${amountSponsored.toFixed(2)}! Invite friends to earn even more.` + return t('success.earnedInviteFriends', { + amount: amountSponsored.toFixed(2), + }) } - return 'Invite friends to earn even more rewards.' + return t('success.inviteFriends') })()}

@@ -1374,7 +1370,7 @@ export default function QRPayPage() { }} /> {(() => { - const label = 'Claim Reward' + const label = t('success.claimReward') return ( <> {label} @@ -1393,7 +1389,7 @@ export default function QRPayPage() { {/* after claiming a reward, primary CTA is "Done" — not "Split this bill" */} {perkClaimed || qrPayment?.perk?.claimed ? ( ) : ( )} )} @@ -1472,7 +1468,7 @@ export default function QRPayPage() { isMultiLevel />
- + {/* Payment Content */}
@@ -1482,7 +1478,7 @@ export default function QRPayPage() {
Payment method

- You're paying + {t('youArePaying')}

{merchantName}

@@ -1536,6 +1532,29 @@ export default function QRPayPage() { return ( { + switch (item.kind) { + case 'limit-amount': + return { + ...item, + text: tLimits('warningCard.payUpTo', { amount: item.amount ?? '' }), + } + case 'reset-days': + return { + ...item, + text: tLimits('warningCard.resetsInDays', { days: item.days ?? 0 }), + } + case 'check-limits': + return { ...item, text: tLimits('warningCard.checkLimits') } + default: + return item + } + })} onIncreaseLimits={ isBrQrEligible && limitsValidation.isBlocking ? qrLimitIncreaseFlow.handleInitiate @@ -1549,9 +1568,9 @@ export default function QRPayPage() { {/* Information Card */} {(() => { if (!hasCardMarkupComparison(currency.code)) return null @@ -1560,17 +1579,21 @@ export default function QRPayPage() { const savingsUsd = (savingsInCents / 100).toFixed(2) return ( ) })()} - + {/* Send Button */} @@ -1590,7 +1613,7 @@ export default function QRPayPage() { limitsValidation.isBlocking } > - {isLoading ? 'Loading...' : 'Pay'} + {isLoading ? tCommon('loading') : tNav('pay')} {/* Error State */} @@ -1602,13 +1625,14 @@ export default function QRPayPage() { } const QrPayPageLoading = ({ message }: { message: string }) => { + const t = useTranslations('qrPay') return (
Peanut Man so the surrounding // column shakes with the button — same scope as the eligibility-check. @@ -126,7 +130,7 @@ export default function RedirectQrClaimPage() { if (isCheckingStatus || (redirectQrData?.claimed && redirectQrData?.redirectUrl)) { return (
- +
@@ -139,7 +143,7 @@ export default function RedirectQrClaimPage() { if (!user) { return (
- +
@@ -155,7 +159,7 @@ export default function RedirectQrClaimPage() { } return (
- +
@@ -164,16 +168,14 @@ export default function RedirectQrClaimPage() {
-

QR Code Unavailable

+

{t('claim.unavailableTitle')}

- {redirectQrData?.claimed - ? 'This QR code has already been claimed by another user.' - : 'This QR code is not available for claiming.'} + {redirectQrData?.claimed ? t('claim.alreadyClaimed') : t('claim.notAvailable')}

@@ -182,7 +184,7 @@ export default function RedirectQrClaimPage() { return (
- +
{/* QR Code Visual */} @@ -192,10 +194,8 @@ export default function RedirectQrClaimPage() {
-

Your Invite QR

-

- Share anywhere. Points keep coming from the activity of friends & their friends. -

+

{t('claim.inviteQrTitle')}

+

{t('claim.inviteQrDescription')}

@@ -204,7 +204,7 @@ export default function RedirectQrClaimPage() {

- Note: Permanent once claimed. + {t.rich('claim.permanentNote', { strong: (chunks) => {chunks} })}

@@ -217,7 +217,7 @@ export default function RedirectQrClaimPage() { loading={isLoading} onShakeChange={(on, intensity) => setShake({ on, intensity })} > - {isLoading ? 'Claiming...' : 'Hold to make it yours forever'} + {isLoading ? t('claim.claiming') : t('claim.holdToClaim')} {error && } diff --git a/src/app/(mobile-ui)/qr/[code]/success/page.tsx b/src/app/(mobile-ui)/qr/[code]/success/page.tsx index f9e86cc469..255cc53e44 100644 --- a/src/app/(mobile-ui)/qr/[code]/success/page.tsx +++ b/src/app/(mobile-ui)/qr/[code]/success/page.tsx @@ -4,6 +4,7 @@ import { Button } from '@/components/0_Bruddle/Button' import Card from '@/components/Global/Card' import NavHeader from '@/components/Global/NavHeader' import { useRouter, useParams, useSearchParams } from 'next/navigation' +import { useTranslations } from 'next-intl' import { useEffect } from 'react' import PeanutLoading from '@/components/Global/PeanutLoading' import { Icon } from '@/components/Global/Icons/Icon' @@ -14,6 +15,8 @@ import { useToast } from '@/components/0_Bruddle/Toast' import { BASE_URL } from '@/constants/general.consts' export default function RedirectQrSuccessPage() { + const t = useTranslations('qrPay') + const tCommon = useTranslations('common') const router = useRouter() const params = useParams() const searchParams = useSearchParams() @@ -37,7 +40,7 @@ export default function RedirectQrSuccessPage() { if (isLoading || !redirectQrData) { return (
- +
@@ -47,12 +50,12 @@ export default function RedirectQrSuccessPage() { return (
- +
{/* Title */}
-

This QR is now yours!

-

Your sticker is now linked to your profile forever.

+

{t('claimSuccess.title')}

+

{t('claimSuccess.description')}

{/* QR Code Display */} @@ -65,12 +68,8 @@ export default function RedirectQrSuccessPage() {
-

Put it anywhere!

-

- Stick it on your laptop, water bottle, or anywhere you want people to find you. Anyone - who scans will be able to join Peanut with your invite and contribute towards your - points forever. -

+

{t('claimSuccess.putItAnywhere')}

+

{t('claimSuccess.stickerDescription')}

@@ -84,7 +83,7 @@ export default function RedirectQrSuccessPage() { className="w-full" icon="arrow-up-right" > - Go to Home + {tCommon('goToHome')}
diff --git a/src/app/[...recipient]/error.tsx b/src/app/[...recipient]/error.tsx index e2157ebba2..c64595e1fb 100644 --- a/src/app/[...recipient]/error.tsx +++ b/src/app/[...recipient]/error.tsx @@ -1,12 +1,15 @@ 'use client' import { useEffect } from 'react' +import { useTranslations } from 'next-intl' import { useModalsContext } from '@/context/ModalsContext' import { Button } from '@/components/0_Bruddle/Button' import { Card } from '@/components/0_Bruddle/Card' import { recoverFromChunkError } from '@/utils/chunk-error-recovery' export default function PaymentError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) { + const t = useTranslations('payment') + const tCommon = useTranslations('common') const { setIsSupportModalOpen } = useModalsContext() useEffect(() => { @@ -19,17 +22,15 @@ export default function PaymentError({ error, reset }: { error: Error & { digest return ( - Something went wrong! - - {error.message || 'An error occurred while loading the payment page.'} - + {t('errorPage.title')} + {error.message || t('errorPage.description')} diff --git a/src/components/Payment/Views/Error.validation.view.tsx b/src/components/Payment/Views/Error.validation.view.tsx index 77154fe143..e839656abf 100644 --- a/src/components/Payment/Views/Error.validation.view.tsx +++ b/src/components/Payment/Views/Error.validation.view.tsx @@ -3,6 +3,7 @@ import { PeanutSad } from '@/assets/mascot' import { Button } from '@/components/0_Bruddle/Button' import Image from 'next/image' +import { useTranslations } from 'next-intl' import { useRouter } from 'next/navigation' import { useModalsContext } from '@/context/ModalsContext' import { useEffect, useState } from 'react' @@ -25,8 +26,9 @@ function ValidationErrorView({ redirectTo, showLearnMore = true, supportMessageTemplate, - supportButtonText = 'Talk to support', + supportButtonText, }: ValidationErrorViewProps) { + const t = useTranslations('payment') const router = useRouter() const { openSupportWithMessage } = useModalsContext() const [currentUrl, setCurrentUrl] = useState('') @@ -42,14 +44,14 @@ function ValidationErrorView({ return (
- Sad peanut 😢 + {t('validation.sadPeanutAlt')}

{title}

{message}

{showLearnMore && ( - Learn how to receive money through Peanut + {t('validation.learnHow')} )}
@@ -72,7 +74,7 @@ function ValidationErrorView({ variant="stroke" className="w-full" > - {supportButtonText} + {supportButtonText ?? t('validation.talkToSupport')} )}
diff --git a/src/components/Request/__tests__/request-states.test.tsx b/src/components/Request/__tests__/request-states.test.tsx index 00e5c5c97d..814083b228 100644 --- a/src/components/Request/__tests__/request-states.test.tsx +++ b/src/components/Request/__tests__/request-states.test.tsx @@ -10,6 +10,8 @@ import React from 'react' import { render, screen, fireEvent, waitFor, act } from '@testing-library/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { NextIntlClientProvider } from 'next-intl' +import en from '@/i18n/app/messages/en.json' // ---------- module-level mocks (must be before imports that depend on them) ---------- @@ -334,14 +336,22 @@ function createQueryClient() { }) } +const IntlWrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + +) + function renderCreateRequest(params: Record = {}) { setSearchParams(params) const queryClient = createQueryClient() return render( - - - + + + + + ) } @@ -350,9 +360,11 @@ function renderPayRequest(params: Record = {}) { const queryClient = createQueryClient() return render( - - - + + + + + ) } @@ -360,9 +372,11 @@ function renderDirectRequest() { const queryClient = createQueryClient() return render( - - - + + + + + ) } diff --git a/src/components/Request/direct-request/views/Initial.direct.request.view.tsx b/src/components/Request/direct-request/views/Initial.direct.request.view.tsx index 33b8b2b366..4642520f8b 100644 --- a/src/components/Request/direct-request/views/Initial.direct.request.view.tsx +++ b/src/components/Request/direct-request/views/Initial.direct.request.view.tsx @@ -16,6 +16,8 @@ import { type IAttachmentOptions } from '@/interfaces/attachment' import { usersApi } from '@/services/users' import { formatAmount } from '@/utils/general.utils' import { captureException } from '@sentry/nextjs' +import { useTranslations } from 'next-intl' +import { loadingStateKey } from '@/i18n/app/loading-states' import { useCallback, useContext, useEffect, useMemo, useState } from 'react' import { useUserInteractions } from '@/hooks/useUserInteractions' import { useUserByUsername } from '@/hooks/useUserByUsername' @@ -26,6 +28,10 @@ interface DirectRequestInitialViewProps { } const DirectRequestInitialView = ({ username }: DirectRequestInitialViewProps) => { + const t = useTranslations('request') + const tNav = useTranslations('navigation') + const tCommon = useTranslations('common') + const tLoading = useTranslations('loadingStates') const onBack = useSafeBack('/home') const { user: authUser } = useUserStore() const { spendableBalance: balance, formattedSpendableBalance, address } = useWallet() @@ -86,7 +92,7 @@ const DirectRequestInitialView = ({ username }: DirectRequestInitialViewProps) = const createRequestCharge = useCallback(async () => { if (isButtonDisabled) { - setErrorState({ showError: true, errorMessage: 'Username or amount is missing' }) + setErrorState({ showError: true, errorMessage: t('errors.missingUsernameOrAmount') }) return } setLoadingState('Requesting') @@ -109,7 +115,7 @@ const DirectRequestInitialView = ({ username }: DirectRequestInitialViewProps) = } catch (error: any) { console.error('Error creating request charge:', error) captureException(error) - setErrorState({ showError: true, errorMessage: error.message || 'Failed to create request.' }) + setErrorState({ showError: true, errorMessage: error.message || t('errors.createRequestFailed') }) setLoadingState('Idle') } }, [ @@ -122,6 +128,7 @@ const DirectRequestInitialView = ({ username }: DirectRequestInitialViewProps) = authUser, recipient.address, setErrorState, + t, ]) useEffect(() => { @@ -130,36 +137,33 @@ const DirectRequestInitialView = ({ username }: DirectRequestInitialViewProps) = } const getValidationErrorObject = ( - title: 'Invalid Recipient' | 'Missing Recipient', + kind: 'invalid' | 'missing', specificMessage?: string ): ValidationErrorViewProps => { - let message = specificMessage - if (!message) { - message = - title === 'Invalid Recipient' - ? 'The user you are trying to request from is not a valid Peanut user or does not exist. Please ensure you have the correct Peanut username.' - : 'No username provided in the URL. Please check the link and try again.' - } + const message = + specificMessage ?? + (kind === 'invalid' ? t('validation.invalidRecipientMessage') : t('validation.missingRecipientMessage')) return { - title, + title: + kind === 'invalid' ? t('validation.invalidRecipientTitle') : t('validation.missingRecipientTitle'), message, - buttonText: authUser?.user.userId ? 'Go to home' : 'Create your Peanut Wallet', + buttonText: authUser?.user.userId ? t('validation.goToHome') : t('validation.createWallet'), redirectTo: authUser?.user.userId ? '/home' : '/setup', } } if (!username) { - setValidationError(getValidationErrorObject('Missing Recipient')) + setValidationError(getValidationErrorObject('missing')) return } if (recipientUserError || !recipientUser) { - setValidationError(getValidationErrorObject('Invalid Recipient')) + setValidationError(getValidationErrorObject('invalid')) return } setValidationError(null) - }, [username, authUser, recipientUser, recipientUserError, isRecipientUserLoading]) + }, [username, authUser, recipientUser, recipientUserError, isRecipientUserLoading, t]) if (isRecipientUserLoading || authUser === undefined) { return ( @@ -172,7 +176,7 @@ const DirectRequestInitialView = ({ username }: DirectRequestInitialViewProps) = if (validationError) { return (
- {!!authUser?.user.userId ? : null} + {!!authUser?.user.userId ? : null}
@@ -185,9 +189,9 @@ const DirectRequestInitialView = ({ username }: DirectRequestInitialViewProps) = return (
{!!authUser?.user.userId ? ( - resetRequestState()} title="Request" /> + resetRequestState()} title={tNav('request')} /> ) : ( -
Request
+
{tNav('request')}
)}
@@ -206,9 +210,9 @@ const DirectRequestInitialView = ({ username }: DirectRequestInitialViewProps) = return (
{!!authUser?.user.userId ? ( - + ) : ( -
Request
+
{tNav('request')}
)}
@@ -232,14 +236,14 @@ const DirectRequestInitialView = ({ username }: DirectRequestInitialViewProps) = /> {!authUser?.user.userId && ( { setRecipient(update.recipient) @@ -257,7 +261,7 @@ const DirectRequestInitialView = ({ username }: DirectRequestInitialViewProps) = } else { setErrorState({ showError: true, - errorMessage: update.errorMessage || 'Validating recipient...', + errorMessage: update.errorMessage || t('errors.validatingRecipient'), }) } } @@ -279,7 +283,7 @@ const DirectRequestInitialView = ({ username }: DirectRequestInitialViewProps) = className="w-full" icon="retry" > - Reset + {tCommon('reset')} ) : ( )} diff --git a/src/components/Request/link/views/Create.request.link.view.tsx b/src/components/Request/link/views/Create.request.link.view.tsx index cf7a6a50f6..5e6864891f 100644 --- a/src/components/Request/link/views/Create.request.link.view.tsx +++ b/src/components/Request/link/views/Create.request.link.view.tsx @@ -25,12 +25,17 @@ import { fetchTokenSymbol, formatTokenAmount, getRequestLink, isNativeCurrency } import * as Sentry from '@sentry/nextjs' import * as peanutInterfaces from '@/interfaces/peanut-sdk-types' import { useQueryClient } from '@tanstack/react-query' +import { useTranslations } from 'next-intl' import { useSearchParams } from 'next/navigation' import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' import { Icon as IconComponent } from '@/components/Global/Icons/Icon' import { useSafeBack } from '@/hooks/useSafeBack' export const CreateRequestLinkView = () => { + const t = useTranslations('request') + const tNav = useTranslations('navigation') + const tCommon = useTranslations('common') + const tLoading = useTranslations('loadingStates') const toast = useToast() const onBack = useSafeBack('/home') const { address, isConnected, spendableBalance: balance, formattedSpendableBalance } = useWallet() @@ -47,7 +52,7 @@ export const CreateRequestLinkView = () => { return formatTokenAmount(paramsAmount, 2) ?? '' }, [paramsAmount]) const merchant = searchParams.get('merchant') - const merchantComment = merchant ? `Bill split for ${merchant}` : null + const merchantComment = merchant ? t('billSplitFor', { merchant }) : null // Core state const [tokenValue, setTokenValue] = useState(sanitizedAmount) @@ -126,7 +131,7 @@ export const CreateRequestLinkView = () => { if (!recipientAddress) { setErrorState({ showError: true, - errorMessage: 'Please enter a recipient address', + errorMessage: t('errors.enterRecipient'), }) return null } @@ -188,7 +193,7 @@ export const CreateRequestLinkView = () => { // Update the last saved state lastSavedAttachmentRef.current = { ...attachmentOptions } - toast.success('Link created successfully!') + toast.success(t('linkCreatedToast')) queryClient.invalidateQueries({ queryKey: [TRANSACTIONS] }) return link } catch (error) { @@ -197,11 +202,11 @@ export const CreateRequestLinkView = () => { } setErrorState({ showError: true, - errorMessage: 'Failed to create link', + errorMessage: t('errors.createFailed'), }) console.error('Failed to create link:', error) Sentry.captureException(error) - toast.error('Failed to create link') + toast.error(t('errors.createFailed')) return null } finally { setLoadingState('Idle') @@ -217,6 +222,7 @@ export const CreateRequestLinkView = () => { toast, queryClient, setLoadingState, + t, ] ) @@ -242,24 +248,24 @@ export const CreateRequestLinkView = () => { // Update the last saved state lastSavedAttachmentRef.current = { ...attachmentOptions } - toast.success('Request updated successfully!') + toast.success(t('requestUpdatedToast')) queryClient.invalidateQueries({ queryKey: [TRANSACTIONS] }) return generatedLink } catch (error) { setErrorState({ showError: true, - errorMessage: 'Failed to update request', + errorMessage: t('errors.updateFailed'), }) console.error('Failed to update request:', error) Sentry.captureException(error) - toast.error('Failed to update request') + toast.error(t('errors.updateFailed')) return null } finally { setLoadingState('Idle') setIsUpdatingRequest(false) } }, - [requestId, generatedLink, toast, queryClient, setLoadingState] + [requestId, generatedLink, toast, queryClient, setLoadingState, t] ) const hasUnsavedChanges = useMemo(() => { @@ -363,7 +369,7 @@ export const CreateRequestLinkView = () => { return (
- +
@@ -383,17 +389,14 @@ export const CreateRequestLinkView = () => { infoContent={
-

- {' '} - Leave empty to let payers choose amounts. -

+

{t('leaveEmptyHint')}

} /> @@ -405,7 +408,7 @@ export const CreateRequestLinkView = () => { onClick={generateLink} shadowSize="4" > - Create request + {t('createRequest')} )} @@ -413,14 +416,14 @@ export const CreateRequestLinkView = () => { (isCreatingLink || isUpdatingRequest ? ( ) : ( {!tokenValue || !parseFloat(tokenValue) || parseFloat(tokenValue) === 0 - ? 'Share open request' - : `Share $${tokenValue} request`} + ? t('shareOpenRequest') + : t('shareAmountRequest', { amount: tokenValue })} ))} diff --git a/src/components/Send/__tests__/send-states.test.tsx b/src/components/Send/__tests__/send-states.test.tsx index 31fcdcbd3b..b7953d387e 100644 --- a/src/components/Send/__tests__/send-states.test.tsx +++ b/src/components/Send/__tests__/send-states.test.tsx @@ -10,6 +10,8 @@ import React from 'react' import { render, screen, fireEvent } from '@testing-library/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { NextIntlClientProvider } from 'next-intl' +import en from '@/i18n/app/messages/en.json' // ---------- module-level mocks (must be before imports that depend on them) ---------- @@ -191,9 +193,11 @@ function renderSend(params: Record = {}) { setSearchParams(params) const queryClient = createQueryClient() return render( - - - + + + + + ) } diff --git a/src/components/Send/link/LinkSendFlowManager.tsx b/src/components/Send/link/LinkSendFlowManager.tsx index b46bba583c..40b4409412 100644 --- a/src/components/Send/link/LinkSendFlowManager.tsx +++ b/src/components/Send/link/LinkSendFlowManager.tsx @@ -3,6 +3,7 @@ import { tokenSelectorContext } from '@/context' import { LinkSendFlowProvider, useLinkSendFlow } from '@/context/LinkSendFlowContext' import { useContext, useEffect } from 'react' +import { useTranslations } from 'next-intl' import NavHeader from '../../Global/NavHeader' import LinkSendInitialView from './views/Initial.link.send.view' import LinkSendSuccessView from './views/Success.link.send.view' @@ -14,6 +15,7 @@ interface LinkSendFlowManagerProps { // inner component that uses the context const LinkSendFlowContent = ({ onPrev }: LinkSendFlowManagerProps) => { + const tNav = useTranslations('navigation') const { view } = useLinkSendFlow() const { resetTokenContextProvider, setSelectedChainID, setSelectedTokenAddress } = useContext(tokenSelectorContext) @@ -32,7 +34,7 @@ const LinkSendFlowContent = ({ onPrev }: LinkSendFlowManagerProps) => { <> {view === 'INITIAL' && (
- +
diff --git a/src/components/Send/link/views/Initial.link.send.view.tsx b/src/components/Send/link/views/Initial.link.send.view.tsx index 2e9bf40d19..2f655cc9bb 100644 --- a/src/components/Send/link/views/Initial.link.send.view.tsx +++ b/src/components/Send/link/views/Initial.link.send.view.tsx @@ -14,6 +14,7 @@ import { INSUFFICIENT_BALANCE_MESSAGE, isAmountWithinBalance } from '@/utils/bal import { captureException } from '@sentry/nextjs' import { useQueryClient } from '@tanstack/react-query' import { useCallback, useContext, useEffect, useMemo } from 'react' +import { useTranslations } from 'next-intl' import { parseUnits } from 'viem' import { Button } from '@/components/0_Bruddle/Button' import FileUploadInput from '../../../Global/FileUploadInput' @@ -23,6 +24,9 @@ import posthog from 'posthog-js' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' const LinkSendInitialView = () => { + const t = useTranslations('send') + const tCommon = useTranslations('common') + const tLoading = useTranslations('loadingStates') const { attachmentOptions, setAttachmentOptions, @@ -179,7 +183,7 @@ const LinkSendInitialView = () => { @@ -187,7 +191,7 @@ const LinkSendInitialView = () => {
{errorState?.showError ? ( ) : ( )} {errorState?.showError && } diff --git a/src/components/Send/link/views/Success.link.send.view.tsx b/src/components/Send/link/views/Success.link.send.view.tsx index 042e381106..d3c48f8129 100644 --- a/src/components/Send/link/views/Success.link.send.view.tsx +++ b/src/components/Send/link/views/Success.link.send.view.tsx @@ -13,6 +13,7 @@ import { useUserStore } from '@/redux/hooks' import { captureException } from '@sentry/nextjs' import { useQueryClient } from '@tanstack/react-query' import { useRouter } from 'next/navigation' +import { useTranslations } from 'next-intl' import { useEffect, useState } from 'react' import useClaimLink from '@/components/Claim/useClaimLink' import { useToast } from '@/components/0_Bruddle/Toast' @@ -21,6 +22,8 @@ import posthog from 'posthog-js' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' const LinkSendSuccessView = () => { + const t = useTranslations('send') + const tNav = useTranslations('navigation') const router = useRouter() const { link, attachmentOptions, tokenValue, resetLinkSendFlow } = useLinkSendFlow() const queryClient = useQueryClient() @@ -31,7 +34,13 @@ const LinkSendSuccessView = () => { const [isLoading, setIsLoading] = useState(false) const [showCancelLinkModal, setshowCancelLinkModal] = useState(false) - const [cancelLinkText, setCancelLinkText] = useState<'Cancelling' | 'Cancelled' | 'Cancel link'>('Cancel link') + const [cancelStatus, setCancelStatus] = useState<'idle' | 'cancelling' | 'cancelled'>('idle') + const cancelLinkText = + cancelStatus === 'cancelling' + ? t('link.cancelling') + : cancelStatus === 'cancelled' + ? t('link.cancelled') + : t('link.cancelLink') useEffect(() => { return () => { @@ -44,7 +53,7 @@ const LinkSendSuccessView = () => {
{ router.push('/home') resetLinkSendFlow() @@ -53,7 +62,7 @@ const LinkSendSuccessView = () => {
{link && ( {
{ posthog.capture(ANALYTICS_EVENTS.SEND_LINK_SHARED, { amount: tokenValue, }) }} > - Share link + {t('link.shareLink')} } /> @@ -118,7 +122,7 @@ export default function ContactsView() { return (
- + {hasContacts ? (
@@ -127,7 +131,7 @@ export default function ContactsView() { value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} onClear={() => setSearchQuery('')} - placeholder="Search contacts..." + placeholder={t('contacts.searchPlaceholder')} /> {/* contacts list or search results */} @@ -136,7 +140,7 @@ export default function ContactsView() { ) : contacts.length > 0 ? (
-

Your contacts

+

{t('contacts.yourContacts')}

{contacts.map((contact, index) => { const isVerified = contact.isVerified @@ -176,16 +180,18 @@ export default function ContactsView() { {/* infinite scroll loader */}
{isFetchingNextPage && ( -
Loading more...
+
+ {t('contacts.loadingMore')} +
)}
) : hasNoSearchResults ? ( // no search results - keep search input visible ) : null}
@@ -193,9 +199,9 @@ export default function ContactsView() { // empty state - no contacts at all (initial load with no contacts)
- Send via link + {t('linkCard.cta')} } /> diff --git a/src/components/Send/views/SendRouter.view.tsx b/src/components/Send/views/SendRouter.view.tsx index af553b412e..862ebb7743 100644 --- a/src/components/Send/views/SendRouter.view.tsx +++ b/src/components/Send/views/SendRouter.view.tsx @@ -24,6 +24,7 @@ import { ValidatedUsernameWrapper } from '@/components/Username/ValidatedUsernam import { DirectSendPageWrapper } from '@/features/payments/flows/direct-send/DirectSendPageWrapper' import { isAddress } from 'viem' import dynamic from 'next/dynamic' +import { useTranslations } from 'next-intl' // Lazy — only needed for address/ENS recipients, and pulls the heavy // TokenSelector → wagmi config graph. Static-importing it bloats the common @@ -37,6 +38,9 @@ const SemanticRequestPageWrapper = dynamic( ) export const SendRouterView = () => { + const t = useTranslations('send') + const tNav = useTranslations('navigation') + const tCommon = useTranslations('common') const router = useRouter() const searchParams = useSearchParams() const isSendingByLink = searchParams.get('view') === 'link' || searchParams.get('createLink') === 'true' @@ -155,6 +159,8 @@ export const SendRouterView = () => { case 'bank': return { ...method, + title: t('methods.bankTitle'), + description: t('methods.bankDescription'), identifierIcon: (
@@ -164,6 +170,8 @@ export const SendRouterView = () => { case 'exchange-or-wallet': return { ...method, + title: t('methods.exchangeOrWalletTitle'), + description: t('methods.exchangeOrWalletDescription'), identifierIcon: (
@@ -173,18 +181,20 @@ export const SendRouterView = () => { case 'mercadopago': return { ...method, + description: t('methods.instantTransfers'), identifierIcon: Mercado Pago, } case 'pix': return { ...method, + description: t('methods.instantTransfers'), identifierIcon: Pix, } default: return method } }) - }, []) + }, [t]) // filter send options based on geolocation const { filteredMethods: geoFilteredMethods } = useGeoFilteredPaymentOptions({ @@ -200,14 +210,14 @@ export const SendRouterView = () => {
), - title: 'Peanut contacts', - description: "Peanuts you've interacted with", + title: t('methods.contactsTitle'), + description: t('methods.contactsDescription'), icons: [], soon: false, } return [peanutContactsOption, ...geoFilteredMethods] - }, [geoFilteredMethods]) + }, [geoFilteredMethods, t]) // direct send to a specific recipient (native app uses /send?recipient=...). // Mirrors the web [...recipient] dispatch: an EVM address / ENS / a recipient @@ -242,7 +252,7 @@ export const SendRouterView = () => { return (
- +
@@ -251,17 +261,17 @@ export const SendRouterView = () => {
-
Send money with a link
-
No account needed to receive.
+
{t('linkCard.title')}
+
{t('linkCard.description')}
- +
{sendOptions.map((option) => { @@ -272,7 +282,9 @@ export const SendRouterView = () => { rightContent = contactsAvatars break case 'mercadopago': - rightContent = + rightContent = ( + + ) break default: rightContent = ( diff --git a/src/features/limits/utils.ts b/src/features/limits/utils.ts index 2a79e040f6..5b558a12c4 100644 --- a/src/features/limits/utils.ts +++ b/src/features/limits/utils.ts @@ -151,6 +151,11 @@ export interface LimitsWarningItem { isLink?: boolean href?: string icon?: IconName + // discriminator + values so components can map items to translated copy + // (this util can't call useTranslations; `text` stays the English fallback) + kind?: 'limit-amount' | 'reset-days' | 'check-limits' + amount?: string + days?: number } export interface LimitsWarningCardPropsResult { @@ -186,23 +191,28 @@ export function getLimitsWarningCardProps({ const limitCurrency = validation.limitCurrency ?? currency ?? 'USD' const currencySymbol = getCurrencySymbol(limitCurrency) const formattedLimit = formatExtendedNumber(validation.remainingLimit ?? 0) + const amountDisplay = currencySymbol === '$' ? `$${formattedLimit}` : `${currencySymbol} ${formattedLimit}` // build the limit message based on flow type let limitMessage = '' if (flowType === 'onramp') { - limitMessage = `You can add up to ${currencySymbol === '$' ? '' : currencySymbol + ' '}${currencySymbol === '$' ? '$' : ''}${formattedLimit}${validation.daysUntilReset ? '' : ' per transaction'}` + limitMessage = `You can add up to ${amountDisplay}${validation.daysUntilReset ? '' : ' per transaction'}` } else if (flowType === 'offramp') { - limitMessage = `You can withdraw up to ${currencySymbol === '$' ? '' : currencySymbol + ' '}${currencySymbol === '$' ? '$' : ''}${formattedLimit}${validation.daysUntilReset ? '' : ' per transaction'}` + limitMessage = `You can withdraw up to ${amountDisplay}${validation.daysUntilReset ? '' : ' per transaction'}` } else { // qr-payment - limitMessage = `You can pay up to ${currencySymbol === '$' ? '' : currencySymbol + ' '}${currencySymbol === '$' ? '$' : ''}${formattedLimit} per transaction` + limitMessage = `You can pay up to ${amountDisplay} per transaction` } - items.push({ text: limitMessage }) + items.push({ text: limitMessage, kind: 'limit-amount', amount: amountDisplay }) // add days until reset if applicable if (validation.daysUntilReset) { - items.push({ text: `Limit resets in ${validation.daysUntilReset} days.` }) + items.push({ + text: `Limit resets in ${validation.daysUntilReset} days.`, + kind: 'reset-days', + days: validation.daysUntilReset, + }) } // add check limits link @@ -211,6 +221,7 @@ export function getLimitsWarningCardProps({ isLink: true, href: '/limits', icon: 'external-link', + kind: 'check-limits', }) return { diff --git a/src/i18n/app/messages/en.json b/src/i18n/app/messages/en.json index a397af8016..d96c1cc3cc 100644 --- a/src/i18n/app/messages/en.json +++ b/src/i18n/app/messages/en.json @@ -17,7 +17,11 @@ "gotIt": "Got it", "contactSupport": "Contact support", "pending": "Pending", - "comingSoon": "Coming soon" + "comingSoon": "Coming soon", + "or": "or", + "comment": "Comment", + "goToHome": "Go to Home", + "reset": "Reset" }, "navigation": { "home": "Home", @@ -395,7 +399,14 @@ "description": "Failed to load limits. Please try again." }, "docsLink": "See more about limits", - "needHigherLimits": "Need higher limits? Contact support." + "needHigherLimits": "Need higher limits? Contact support.", + "warningCard": { + "blockingTitle": "This amount exceeds your limit.", + "warningTitle": "You're close to your limit.", + "payUpTo": "You can pay up to {amount} per transaction", + "resetsInDays": "Limit resets in {days, plural, one {# day} other {# days}}.", + "checkLimits": "Check my limits." + } }, "exchangeRate": { "title": "Exchange rate & fees", @@ -581,5 +592,180 @@ "loggingOut": "Logging out", "paying": "Paying", "withdrawing": "Withdrawing" + }, + "send": { + "linkCard": { + "title": "Send money with a link", + "description": "No account needed to receive.", + "cta": "Send via link" + }, + "methods": { + "contactsTitle": "Peanut contacts", + "contactsDescription": "Peanuts you've interacted with", + "bankTitle": "Bank", + "bankDescription": "EUR, USD, MXN, ARS & more", + "instantTransfers": "Instant transfers", + "exchangeOrWalletTitle": "Exchange or Wallet", + "exchangeOrWalletDescription": "Binance, Metamask and more", + "yourAccountsOnly": "YOUR ACCOUNTS ONLY" + }, + "contacts": { + "searchPlaceholder": "Search contacts...", + "yourContacts": "Your contacts", + "loadingMore": "Loading more...", + "errorTitle": "Failed to load contacts", + "errorDescription": "We couldn't load your contacts. Please try again.", + "noResultsTitle": "No contacts found", + "noResultsDescription": "Try searching for a different contact.", + "emptyTitle": "No contacts yet", + "emptyDescription": "Contacts appear when you send, request, or invite someone" + }, + "link": { + "createLink": "Create link", + "linkCreated": "Link created!", + "shareLink": "Share link", + "cancelLink": "Cancel link", + "cancelling": "Cancelling", + "cancelled": "Cancelled", + "cancelSuccess": "Link cancelled successfully!", + "cancelSuccessRefresh": "Link cancelled! Refresh to see updated balance.", + "cancelFailed": "Failed to cancel link. Please try again." + } + }, + "request": { + "leaveEmptyHint": "Leave empty to let payers choose amounts.", + "createRequest": "Create request", + "shareOpenRequest": "Share open request", + "shareAmountRequest": "Share ${amount} request", + "billSplitFor": "Bill split for {merchant}", + "linkCreatedToast": "Link created successfully!", + "requestUpdatedToast": "Request updated successfully!", + "recipientPlaceholder": "Enter a username, an address or ENS", + "errors": { + "createFailed": "Failed to create link", + "updateFailed": "Failed to update request", + "enterRecipient": "Please enter a recipient address", + "missingUsernameOrAmount": "Username or amount is missing", + "createRequestFailed": "Failed to create request.", + "validatingRecipient": "Validating recipient..." + }, + "validation": { + "invalidRecipientTitle": "Invalid Recipient", + "missingRecipientTitle": "Missing Recipient", + "invalidRecipientMessage": "The user you are trying to request from is not a valid Peanut user or does not exist. Please ensure you have the correct Peanut username.", + "missingRecipientMessage": "No username provided in the URL. Please check the link and try again.", + "goToHome": "Go to home", + "createWallet": "Create your Peanut Wallet" + } + }, + "payment": { + "validation": { + "talkToSupport": "Talk to support", + "learnHow": "Learn how to receive money through Peanut", + "sadPeanutAlt": "Sad peanut 😢" + }, + "errorPage": { + "title": "Something went wrong!", + "description": "An error occurred while loading the payment page.", + "contactSupport": "Contact Support" + } + }, + "qrPay": { + "youArePaying": "You're paying", + "paymentMethodAlt": "Payment method", + "peanutManAlt": "Peanut Man", + "waitingForMerchant": "Waiting for the merchant to set the amount", + "havingTrouble": "Having trouble?", + "orderNotReady": { + "title": "We couldn't get the amount", + "description": "Ask the merchant to enter it and scan the QR again.", + "cta": "Scan the code again" + }, + "maintenance": { + "title": "Service Temporarily Unavailable", + "description": "We're experiencing issues with {method} payments due to an external provider outage. We're working to restore service as soon as possible.", + "goBack": "Go Back" + }, + "kyc": { + "unlockTitle": "Unlock QR payments", + "unlockDescription": "Confirm your ID to pay with a QR code. Takes about a minute.", + "unlockCta": "Unlock now", + "inProgressTitle": "Almost there", + "inProgressDescription": "We're confirming your ID. Pick up where you left off and you'll be paying in no time.", + "notNow": "Not now", + "fixableTitle": "We need an updated document", + "restartTitle": "Verify with a different document", + "blockedTitle": "QR payments are not available", + "fixableDescription": "We need an updated document to enable QR payments. Please upload a clearer photo of your ID.", + "restartDescription": "QR payments need a document from a supported country. You can verify with a different ID.", + "blockedDescription": "QR payments are not available for your account. Contact support for help.", + "uploadDocument": "Upload document" + }, + "info": { + "exchangeRate": "Exchange Rate", + "exchangeRateTooltip": "Rate shown is current but may vary slightly (~$1-5 ARS) until payment is confirmed.", + "saveVsCard": "Save vs card", + "saveVsCardTooltipBrl": "Foreign cards pay IOF (~3.5%) plus a typical ~3% issuer FX fee. Peanut routes via PIX at the current market rate.", + "saveVsCardTooltipArs": "Foreign cards apply the official rate plus a typical ~3% issuer FX fee. Peanut routes via MercadoPago at the current market rate.", + "peanutFee": "Peanut fee", + "sponsoredByPeanut": "Sponsored by Peanut!" + }, + "success": { + "youPaid": "You paid {merchant}", + "earnedRewardTitle": "You earned a reward!", + "earnedHoldToClaim": "You earned ${amount}! Hold to claim your reward.", + "holdToClaim": "You earned a reward! Hold to claim.", + "earnedInviteFriends": "You earned ${amount}! Invite friends to earn even more.", + "inviteFriends": "Invite friends to earn even more rewards.", + "claimReward": "Claim Reward", + "splitThisBill": "Split this bill", + "seeReceipt": "See receipt" + }, + "errors": { + "invalidQr": "Invalid QR code scanned", + "pixMinAmount": "This Pix charge is below the {amount} BRL minimum for Pix payments.", + "pixRecurring": "This QR code is for a recurring payment (PIX Automático). Peanut doesn't support recurring PIX payments — please ask for a regular PIX QR code instead.", + "pixDecode": "We could not decode this Pix QR code. Please ask the merchant to generate a new one.", + "genericDecode": "We could not decode this particular QR code. Please ask the Merchant if they can generate a Mercado Pago QR", + "providerIssues": "We are currently experiencing issues with {method} payments. We are working to fix it as soon as possible", + "initiateUnexpected": "Could not initiate payment due to unexpected error. Please contact support", + "fetchDetails": "Could not fetch qr payment details", + "cardAuthNeeded": "One-time card authorization needed. You'll be asked to confirm once.", + "confirmTransaction": "Please confirm the transaction.", + "signFailed": "Could not sign the transaction.", + "accountStateChanged": "Transaction failed due to account state change. Please try again. If the problem persists, contact support.", + "sessionExpired": "Payment session expired. Please scan the QR code again.", + "merchantNotSupported": "This specific QR code or merchant is not supported. Please try again with a different QR code.", + "completeFailed": "Could not complete payment. Please scan the QR code again. If problem persists contact support", + "minMantecaAmount": "Payment amount must be at least ${amount}", + "minPixAmountBrl": "Minimum PIX amount is {amount} BRL", + "maxQrAmount": "QR payment amount exceeds maximum limit of ${amount}", + "minQrAmount": "QR payment amount must be at least ${amount}", + "genericQrDetails": "An error occurred while getting the QR details." + }, + "claim": { + "navTitle": "Claim QR Code", + "unavailableTitle": "QR Code Unavailable", + "alreadyClaimed": "This QR code has already been claimed by another user.", + "notAvailable": "This QR code is not available for claiming.", + "inviteQrTitle": "Your Invite QR", + "inviteQrDescription": "Share anywhere. Points keep coming from the activity of friends & their friends.", + "permanentNote": "Note: Permanent once claimed.", + "claiming": "Claiming...", + "holdToClaim": "Hold to make it yours forever", + "invalidDestination": "Invalid QR code destination.", + "claimFailed": "Failed to claim QR code. Please try again." + }, + "claimSuccess": { + "navTitle": "Success", + "title": "This QR is now yours!", + "description": "Your sticker is now linked to your profile forever.", + "putItAnywhere": "Put it anywhere!", + "stickerDescription": "Stick it on your laptop, water bottle, or anywhere you want people to find you. Anyone who scans will be able to join Peanut with your invite and contribute towards your points forever.", + "shareQr": "Share QR Code", + "linkCopied": "Link copied", + "shareTitle": "My Peanut QR Code", + "shareText": "Scan my QR code to connect with me on Peanut!" + } } } diff --git a/src/i18n/app/messages/es-419.json b/src/i18n/app/messages/es-419.json index 726e8ccbc4..40230f310c 100644 --- a/src/i18n/app/messages/es-419.json +++ b/src/i18n/app/messages/es-419.json @@ -17,7 +17,11 @@ "gotIt": "Entendido", "contactSupport": "Contactar soporte", "pending": "Pendiente", - "comingSoon": "Próximamente" + "comingSoon": "Próximamente", + "or": "o", + "comment": "Comentario", + "goToHome": "Ir al inicio", + "reset": "Restablecer" }, "navigation": { "home": "Inicio", @@ -395,7 +399,14 @@ "description": "No se pudieron cargar los límites. Intenta de nuevo." }, "docsLink": "Ver más sobre límites", - "needHigherLimits": "¿Necesitas límites más altos? Contacta soporte." + "needHigherLimits": "¿Necesitas límites más altos? Contacta soporte.", + "warningCard": { + "blockingTitle": "Este monto supera tu límite.", + "warningTitle": "Estás cerca de tu límite.", + "payUpTo": "Puedes pagar hasta {amount} por transacción", + "resetsInDays": "Tu límite se restablece en {days, plural, one {# día} other {# días}}.", + "checkLimits": "Ver mis límites." + } }, "exchangeRate": { "title": "Tipo de cambio y comisiones", @@ -581,5 +592,180 @@ "loggingOut": "Cerrando sesión", "paying": "Pagando", "withdrawing": "Retirando" + }, + "send": { + "linkCard": { + "title": "Envía dinero con un enlace", + "description": "No se necesita cuenta para recibir.", + "cta": "Enviar por enlace" + }, + "methods": { + "contactsTitle": "Contactos de Peanut", + "contactsDescription": "Peanuts con los que has interactuado", + "bankTitle": "Banco", + "bankDescription": "EUR, USD, MXN, ARS y más", + "instantTransfers": "Transferencias instantáneas", + "exchangeOrWalletTitle": "Exchange o billetera", + "exchangeOrWalletDescription": "Binance, Metamask y más", + "yourAccountsOnly": "SOLO TUS CUENTAS" + }, + "contacts": { + "searchPlaceholder": "Buscar contactos...", + "yourContacts": "Tus contactos", + "loadingMore": "Cargando más...", + "errorTitle": "No se pudieron cargar los contactos", + "errorDescription": "No pudimos cargar tus contactos. Inténtalo de nuevo.", + "noResultsTitle": "No se encontraron contactos", + "noResultsDescription": "Intenta buscar otro contacto.", + "emptyTitle": "Aún no tienes contactos", + "emptyDescription": "Los contactos aparecen cuando envías, solicitas o invitas a alguien" + }, + "link": { + "createLink": "Crear enlace", + "linkCreated": "¡Enlace creado!", + "shareLink": "Compartir enlace", + "cancelLink": "Cancelar enlace", + "cancelling": "Cancelando", + "cancelled": "Cancelado", + "cancelSuccess": "¡Enlace cancelado con éxito!", + "cancelSuccessRefresh": "¡Enlace cancelado! Actualiza para ver tu saldo al día.", + "cancelFailed": "No se pudo cancelar el enlace. Inténtalo de nuevo." + } + }, + "request": { + "leaveEmptyHint": "Déjalo vacío para que quien paga elija el monto.", + "createRequest": "Crear solicitud", + "shareOpenRequest": "Compartir solicitud abierta", + "shareAmountRequest": "Compartir solicitud de ${amount}", + "billSplitFor": "División de cuenta en {merchant}", + "linkCreatedToast": "¡Enlace creado con éxito!", + "requestUpdatedToast": "¡Solicitud actualizada con éxito!", + "recipientPlaceholder": "Ingresa un nombre de usuario, una dirección o ENS", + "errors": { + "createFailed": "No se pudo crear el enlace", + "updateFailed": "No se pudo actualizar la solicitud", + "enterRecipient": "Ingresa una dirección de destinatario", + "missingUsernameOrAmount": "Falta el nombre de usuario o el monto", + "createRequestFailed": "No se pudo crear la solicitud.", + "validatingRecipient": "Validando destinatario..." + }, + "validation": { + "invalidRecipientTitle": "Destinatario inválido", + "missingRecipientTitle": "Falta el destinatario", + "invalidRecipientMessage": "El usuario al que intentas solicitar no es un usuario válido de Peanut o no existe. Asegúrate de tener el nombre de usuario de Peanut correcto.", + "missingRecipientMessage": "No se incluyó un nombre de usuario en la URL. Revisa el enlace e inténtalo de nuevo.", + "goToHome": "Ir al inicio", + "createWallet": "Crea tu Peanut Wallet" + } + }, + "payment": { + "validation": { + "talkToSupport": "Hablar con soporte", + "learnHow": "Aprende cómo recibir dinero a través de Peanut", + "sadPeanutAlt": "Peanut triste 😢" + }, + "errorPage": { + "title": "¡Algo salió mal!", + "description": "Ocurrió un error al cargar la página de pago.", + "contactSupport": "Contactar a soporte" + } + }, + "qrPay": { + "youArePaying": "Estás pagando", + "paymentMethodAlt": "Método de pago", + "peanutManAlt": "Peanut Man", + "waitingForMerchant": "Esperando a que el comercio ingrese el monto", + "havingTrouble": "¿Tienes problemas?", + "orderNotReady": { + "title": "No pudimos obtener el monto", + "description": "Pídele al comercio que lo ingrese y escanea el QR de nuevo.", + "cta": "Escanear el código de nuevo" + }, + "maintenance": { + "title": "Servicio temporalmente no disponible", + "description": "Estamos teniendo problemas con los pagos de {method} por una falla de un proveedor externo. Estamos trabajando para restablecer el servicio lo antes posible.", + "goBack": "Volver" + }, + "kyc": { + "unlockTitle": "Desbloquea los pagos con QR", + "unlockDescription": "Confirma tu identidad para pagar con un código QR. Toma alrededor de un minuto.", + "unlockCta": "Desbloquear ahora", + "inProgressTitle": "Ya casi", + "inProgressDescription": "Estamos confirmando tu identidad. Retoma donde quedaste y estarás pagando en un momento.", + "notNow": "Ahora no", + "fixableTitle": "Necesitamos un documento actualizado", + "restartTitle": "Verifícate con otro documento", + "blockedTitle": "Los pagos con QR no están disponibles", + "fixableDescription": "Necesitamos un documento actualizado para habilitar los pagos con QR. Sube una foto más clara de tu identificación.", + "restartDescription": "Los pagos con QR necesitan un documento de un país compatible. Puedes verificarte con otra identificación.", + "blockedDescription": "Los pagos con QR no están disponibles para tu cuenta. Contacta a soporte para recibir ayuda.", + "uploadDocument": "Subir documento" + }, + "info": { + "exchangeRate": "Tipo de cambio", + "exchangeRateTooltip": "El tipo de cambio mostrado es actual, pero puede variar un poco (~$1-5 ARS) hasta que se confirme el pago.", + "saveVsCard": "Ahorro vs. tarjeta", + "saveVsCardTooltipBrl": "Las tarjetas extranjeras pagan IOF (~3.5%) más una comisión FX típica del emisor de ~3%. Peanut usa PIX al tipo de cambio actual del mercado.", + "saveVsCardTooltipArs": "Las tarjetas extranjeras aplican el tipo de cambio oficial más una comisión FX típica del emisor de ~3%. Peanut usa MercadoPago al tipo de cambio actual del mercado.", + "peanutFee": "Comisión de Peanut", + "sponsoredByPeanut": "¡Patrocinada por Peanut!" + }, + "success": { + "youPaid": "Pagaste a {merchant}", + "earnedRewardTitle": "¡Ganaste una recompensa!", + "earnedHoldToClaim": "¡Ganaste ${amount}! Mantén presionado para reclamar tu recompensa.", + "holdToClaim": "¡Ganaste una recompensa! Mantén presionado para reclamarla.", + "earnedInviteFriends": "¡Ganaste ${amount}! Invita amigos para ganar aún más.", + "inviteFriends": "Invita amigos para ganar aún más recompensas.", + "claimReward": "Reclamar recompensa", + "splitThisBill": "Dividir esta cuenta", + "seeReceipt": "Ver recibo" + }, + "errors": { + "invalidQr": "El código QR escaneado no es válido", + "pixMinAmount": "Este cobro de Pix está por debajo del mínimo de {amount} BRL para pagos con Pix.", + "pixRecurring": "Este código QR es de un pago recurrente (PIX Automático). Peanut no admite pagos PIX recurrentes — pide un código QR de PIX normal.", + "pixDecode": "No pudimos decodificar este código QR de Pix. Pídele al comercio que genere uno nuevo.", + "genericDecode": "No pudimos decodificar este código QR. Pregúntale al comercio si puede generar un QR de Mercado Pago", + "providerIssues": "Estamos teniendo problemas con los pagos de {method}. Estamos trabajando para solucionarlo lo antes posible", + "initiateUnexpected": "No se pudo iniciar el pago por un error inesperado. Contacta a soporte", + "fetchDetails": "No se pudieron obtener los detalles del pago QR", + "cardAuthNeeded": "Se necesita una autorización única de la tarjeta. Se te pedirá confirmar una vez.", + "confirmTransaction": "Confirma la transacción.", + "signFailed": "No se pudo firmar la transacción.", + "accountStateChanged": "La transacción falló por un cambio de estado de la cuenta. Inténtalo de nuevo. Si el problema persiste, contacta a soporte.", + "sessionExpired": "La sesión de pago expiró. Escanea el código QR de nuevo.", + "merchantNotSupported": "Este código QR o comercio no es compatible. Inténtalo de nuevo con otro código QR.", + "completeFailed": "No se pudo completar el pago. Escanea el código QR de nuevo. Si el problema persiste, contacta a soporte", + "minMantecaAmount": "El monto del pago debe ser de al menos ${amount}", + "minPixAmountBrl": "El monto mínimo de PIX es {amount} BRL", + "maxQrAmount": "El monto del pago QR supera el límite máximo de ${amount}", + "minQrAmount": "El monto del pago QR debe ser de al menos ${amount}", + "genericQrDetails": "Ocurrió un error al obtener los detalles del QR." + }, + "claim": { + "navTitle": "Reclamar código QR", + "unavailableTitle": "Código QR no disponible", + "alreadyClaimed": "Este código QR ya fue reclamado por otro usuario.", + "notAvailable": "Este código QR no está disponible para reclamar.", + "inviteQrTitle": "Tu QR de invitación", + "inviteQrDescription": "Compártelo donde sea. Los puntos siguen llegando por la actividad de tus amigos y sus amigos.", + "permanentNote": "Nota: es permanente una vez reclamado.", + "claiming": "Reclamando...", + "holdToClaim": "Mantén presionado para hacerlo tuyo para siempre", + "invalidDestination": "El destino del código QR no es válido.", + "claimFailed": "No se pudo reclamar el código QR. Inténtalo de nuevo." + }, + "claimSuccess": { + "navTitle": "Éxito", + "title": "¡Este QR ahora es tuyo!", + "description": "Tu sticker ahora está vinculado a tu perfil para siempre.", + "putItAnywhere": "¡Pégalo donde quieras!", + "stickerDescription": "Pégalo en tu laptop, botella de agua o donde quieras que la gente te encuentre. Cualquiera que lo escanee podrá unirse a Peanut con tu invitación y sumar a tus puntos para siempre.", + "shareQr": "Compartir código QR", + "linkCopied": "Enlace copiado", + "shareTitle": "Mi código QR de Peanut", + "shareText": "¡Escanea mi código QR para conectar conmigo en Peanut!" + } } } diff --git a/src/i18n/app/messages/pt-BR.json b/src/i18n/app/messages/pt-BR.json index 433ab9b344..ddb87e1c28 100644 --- a/src/i18n/app/messages/pt-BR.json +++ b/src/i18n/app/messages/pt-BR.json @@ -17,7 +17,11 @@ "gotIt": "Entendi", "contactSupport": "Falar com o suporte", "pending": "Pendente", - "comingSoon": "Em breve" + "comingSoon": "Em breve", + "or": "ou", + "comment": "Comentário", + "goToHome": "Ir para o início", + "reset": "Redefinir" }, "navigation": { "home": "Início", @@ -395,7 +399,14 @@ "description": "Não foi possível carregar os limites. Tente de novo." }, "docsLink": "Saiba mais sobre limites", - "needHigherLimits": "Precisa de limites maiores? Fale com o suporte." + "needHigherLimits": "Precisa de limites maiores? Fale com o suporte.", + "warningCard": { + "blockingTitle": "Esse valor ultrapassa seu limite.", + "warningTitle": "Você está perto do seu limite.", + "payUpTo": "Você pode pagar até {amount} por transação", + "resetsInDays": "Seu limite será redefinido em {days, plural, one {# dia} other {# dias}}.", + "checkLimits": "Ver meus limites." + } }, "exchangeRate": { "title": "Câmbio e taxas", @@ -581,5 +592,180 @@ "loggingOut": "Saindo", "paying": "Pagando", "withdrawing": "Sacando" + }, + "send": { + "linkCard": { + "title": "Envie dinheiro com um link", + "description": "Não precisa de conta para receber.", + "cta": "Enviar por link" + }, + "methods": { + "contactsTitle": "Contatos do Peanut", + "contactsDescription": "Peanuts com quem você já interagiu", + "bankTitle": "Banco", + "bankDescription": "EUR, USD, MXN, ARS e mais", + "instantTransfers": "Transferências instantâneas", + "exchangeOrWalletTitle": "Exchange ou carteira", + "exchangeOrWalletDescription": "Binance, Metamask e mais", + "yourAccountsOnly": "SOMENTE SUAS CONTAS" + }, + "contacts": { + "searchPlaceholder": "Buscar contatos...", + "yourContacts": "Seus contatos", + "loadingMore": "Carregando mais...", + "errorTitle": "Não foi possível carregar os contatos", + "errorDescription": "Não conseguimos carregar seus contatos. Tente de novo.", + "noResultsTitle": "Nenhum contato encontrado", + "noResultsDescription": "Tente buscar outro contato.", + "emptyTitle": "Nenhum contato ainda", + "emptyDescription": "Os contatos aparecem quando você envia, solicita ou convida alguém" + }, + "link": { + "createLink": "Criar link", + "linkCreated": "Link criado!", + "shareLink": "Compartilhar link", + "cancelLink": "Cancelar link", + "cancelling": "Cancelando", + "cancelled": "Cancelado", + "cancelSuccess": "Link cancelado com sucesso!", + "cancelSuccessRefresh": "Link cancelado! Atualize para ver o saldo atualizado.", + "cancelFailed": "Não foi possível cancelar o link. Tente de novo." + } + }, + "request": { + "leaveEmptyHint": "Deixe vazio para quem paga escolher o valor.", + "createRequest": "Criar solicitação", + "shareOpenRequest": "Compartilhar solicitação aberta", + "shareAmountRequest": "Compartilhar solicitação de ${amount}", + "billSplitFor": "Divisão de conta em {merchant}", + "linkCreatedToast": "Link criado com sucesso!", + "requestUpdatedToast": "Solicitação atualizada com sucesso!", + "recipientPlaceholder": "Insira um nome de usuário, um endereço ou ENS", + "errors": { + "createFailed": "Não foi possível criar o link", + "updateFailed": "Não foi possível atualizar a solicitação", + "enterRecipient": "Insira um endereço de destinatário", + "missingUsernameOrAmount": "Falta o nome de usuário ou o valor", + "createRequestFailed": "Não foi possível criar a solicitação.", + "validatingRecipient": "Validando destinatário..." + }, + "validation": { + "invalidRecipientTitle": "Destinatário inválido", + "missingRecipientTitle": "Destinatário ausente", + "invalidRecipientMessage": "O usuário de quem você está tentando solicitar não é um usuário válido do Peanut ou não existe. Confira se você tem o nome de usuário correto do Peanut.", + "missingRecipientMessage": "Nenhum nome de usuário informado na URL. Verifique o link e tente de novo.", + "goToHome": "Ir para o início", + "createWallet": "Crie sua Peanut Wallet" + } + }, + "payment": { + "validation": { + "talkToSupport": "Falar com o suporte", + "learnHow": "Saiba como receber dinheiro pelo Peanut", + "sadPeanutAlt": "Peanut triste 😢" + }, + "errorPage": { + "title": "Algo deu errado!", + "description": "Ocorreu um erro ao carregar a página de pagamento.", + "contactSupport": "Contatar o suporte" + } + }, + "qrPay": { + "youArePaying": "Você está pagando", + "paymentMethodAlt": "Método de pagamento", + "peanutManAlt": "Peanut Man", + "waitingForMerchant": "Aguardando o lojista definir o valor", + "havingTrouble": "Está com problemas?", + "orderNotReady": { + "title": "Não conseguimos obter o valor", + "description": "Peça ao lojista para inseri-lo e escaneie o QR de novo.", + "cta": "Escanear o código de novo" + }, + "maintenance": { + "title": "Serviço temporariamente indisponível", + "description": "Estamos com problemas nos pagamentos de {method} por uma falha de um provedor externo. Estamos trabalhando para restaurar o serviço o quanto antes.", + "goBack": "Voltar" + }, + "kyc": { + "unlockTitle": "Desbloqueie pagamentos com QR", + "unlockDescription": "Confirme sua identidade para pagar com código QR. Leva cerca de um minuto.", + "unlockCta": "Desbloquear agora", + "inProgressTitle": "Quase lá", + "inProgressDescription": "Estamos confirmando sua identidade. Continue de onde parou e logo você estará pagando.", + "notNow": "Agora não", + "fixableTitle": "Precisamos de um documento atualizado", + "restartTitle": "Verifique com outro documento", + "blockedTitle": "Pagamentos com QR não estão disponíveis", + "fixableDescription": "Precisamos de um documento atualizado para habilitar pagamentos com QR. Envie uma foto mais nítida do seu documento.", + "restartDescription": "Pagamentos com QR precisam de um documento de um país compatível. Você pode verificar com outro documento.", + "blockedDescription": "Pagamentos com QR não estão disponíveis para a sua conta. Fale com o suporte para receber ajuda.", + "uploadDocument": "Enviar documento" + }, + "info": { + "exchangeRate": "Taxa de câmbio", + "exchangeRateTooltip": "A taxa mostrada é a atual, mas pode variar um pouco (~$1-5 ARS) até o pagamento ser confirmado.", + "saveVsCard": "Economia vs. cartão", + "saveVsCardTooltipBrl": "Cartões estrangeiros pagam IOF (~3,5%) mais uma taxa de câmbio típica do emissor de ~3%. O Peanut usa o PIX na taxa atual de mercado.", + "saveVsCardTooltipArs": "Cartões estrangeiros aplicam a taxa oficial mais uma taxa de câmbio típica do emissor de ~3%. O Peanut usa o MercadoPago na taxa atual de mercado.", + "peanutFee": "Taxa do Peanut", + "sponsoredByPeanut": "Patrocinada pelo Peanut!" + }, + "success": { + "youPaid": "Você pagou {merchant}", + "earnedRewardTitle": "Você ganhou uma recompensa!", + "earnedHoldToClaim": "Você ganhou ${amount}! Segure para resgatar sua recompensa.", + "holdToClaim": "Você ganhou uma recompensa! Segure para resgatar.", + "earnedInviteFriends": "Você ganhou ${amount}! Convide amigos para ganhar ainda mais.", + "inviteFriends": "Convide amigos para ganhar ainda mais recompensas.", + "claimReward": "Resgatar recompensa", + "splitThisBill": "Dividir essa conta", + "seeReceipt": "Ver recibo" + }, + "errors": { + "invalidQr": "O código QR escaneado não é válido", + "pixMinAmount": "Essa cobrança Pix está abaixo do mínimo de {amount} BRL para pagamentos Pix.", + "pixRecurring": "Este código QR é de um pagamento recorrente (PIX Automático). O Peanut não aceita pagamentos PIX recorrentes — peça um código QR PIX comum.", + "pixDecode": "Não conseguimos decodificar este código QR do Pix. Peça ao lojista para gerar um novo.", + "genericDecode": "Não conseguimos decodificar este código QR. Pergunte ao lojista se ele pode gerar um QR do Mercado Pago", + "providerIssues": "Estamos com problemas nos pagamentos de {method}. Estamos trabalhando para resolver o quanto antes", + "initiateUnexpected": "Não foi possível iniciar o pagamento por um erro inesperado. Fale com o suporte", + "fetchDetails": "Não foi possível obter os detalhes do pagamento QR", + "cardAuthNeeded": "É necessária uma autorização única do cartão. Você vai confirmar uma vez.", + "confirmTransaction": "Confirme a transação.", + "signFailed": "Não foi possível assinar a transação.", + "accountStateChanged": "A transação falhou por uma mudança no estado da conta. Tente de novo. Se o problema continuar, fale com o suporte.", + "sessionExpired": "A sessão de pagamento expirou. Escaneie o código QR de novo.", + "merchantNotSupported": "Este código QR ou lojista não é aceito. Tente de novo com outro código QR.", + "completeFailed": "Não foi possível concluir o pagamento. Escaneie o código QR de novo. Se o problema continuar, fale com o suporte", + "minMantecaAmount": "O valor do pagamento deve ser de pelo menos ${amount}", + "minPixAmountBrl": "O valor mínimo do PIX é {amount} BRL", + "maxQrAmount": "O valor do pagamento QR ultrapassa o limite máximo de ${amount}", + "minQrAmount": "O valor do pagamento QR deve ser de pelo menos ${amount}", + "genericQrDetails": "Ocorreu um erro ao obter os detalhes do QR." + }, + "claim": { + "navTitle": "Resgatar código QR", + "unavailableTitle": "Código QR indisponível", + "alreadyClaimed": "Este código QR já foi resgatado por outro usuário.", + "notAvailable": "Este código QR não está disponível para resgate.", + "inviteQrTitle": "Seu QR de convite", + "inviteQrDescription": "Compartilhe onde quiser. Os pontos continuam chegando com a atividade dos seus amigos e dos amigos deles.", + "permanentNote": "Obs.: é permanente depois de resgatado.", + "claiming": "Resgatando...", + "holdToClaim": "Segure para torná-lo seu para sempre", + "invalidDestination": "O destino do código QR não é válido.", + "claimFailed": "Não foi possível resgatar o código QR. Tente de novo." + }, + "claimSuccess": { + "navTitle": "Sucesso", + "title": "Este QR agora é seu!", + "description": "Seu adesivo agora está vinculado ao seu perfil para sempre.", + "putItAnywhere": "Cole onde quiser!", + "stickerDescription": "Cole no seu notebook, na garrafa de água ou onde quiser que as pessoas te encontrem. Quem escanear vai poder entrar no Peanut com o seu convite e contribuir para os seus pontos para sempre.", + "shareQr": "Compartilhar código QR", + "linkCopied": "Link copiado", + "shareTitle": "Meu código QR do Peanut", + "shareText": "Escaneie meu código QR para se conectar comigo no Peanut!" + } } } From 3b7db8712ef0a1ebb43cfec78cf2c78790d22792 Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Thu, 16 Jul 2026 14:57:13 +0100 Subject: [PATCH 13/44] fix(i18n): translate limits warning card and passkey help content The warning card rendered raw English item text for the withdraw and add-money callers while qr-pay mapped the kind discriminator at its own render site. Resolve copy inside the card instead, so every flow gets it, and drop the duplicated mapping from qr-pay. Passkey troubleshooting steps and warnings become ids resolved against setup.passkey.help.*, so the modal's content is translated, not just its chrome. --- src/app/(mobile-ui)/qr-pay/page.tsx | 24 --------- .../Setup/Views/PasskeySetupHelpModal.tsx | 13 +++-- .../limits/components/LimitsWarningCard.tsx | 38 ++++++++++++-- src/features/limits/utils.ts | 22 +++++--- src/i18n/app/messages/en.json | 29 ++++++++++- src/i18n/app/messages/es-419.json | 29 ++++++++++- src/i18n/app/messages/pt-BR.json | 29 ++++++++++- src/utils/webauthn.utils.ts | 50 ++++++------------- 8 files changed, 153 insertions(+), 81 deletions(-) diff --git a/src/app/(mobile-ui)/qr-pay/page.tsx b/src/app/(mobile-ui)/qr-pay/page.tsx index f2db75c8f7..4d3077f004 100644 --- a/src/app/(mobile-ui)/qr-pay/page.tsx +++ b/src/app/(mobile-ui)/qr-pay/page.tsx @@ -93,7 +93,6 @@ export default function QRPayPage() { const t = useTranslations('qrPay') const tNav = useTranslations('navigation') const tCommon = useTranslations('common') - const tLimits = useTranslations('limits') // Shown wherever the backend rejects a Pix payment below the rail minimum // (typed 400 PIX_MIN_AMOUNT — fires at lock-init for merchant-encoded amounts // and at re-init for user-entered amounts on open-amount QRs). @@ -1532,29 +1531,6 @@ export default function QRPayPage() { return ( { - switch (item.kind) { - case 'limit-amount': - return { - ...item, - text: tLimits('warningCard.payUpTo', { amount: item.amount ?? '' }), - } - case 'reset-days': - return { - ...item, - text: tLimits('warningCard.resetsInDays', { days: item.days ?? 0 }), - } - case 'check-limits': - return { ...item, text: tLimits('warningCard.checkLimits') } - default: - return item - } - })} onIncreaseLimits={ isBrQrEligible && limitsValidation.isBlocking ? qrLimitIncreaseFlow.handleInitiate diff --git a/src/components/Setup/Views/PasskeySetupHelpModal.tsx b/src/components/Setup/Views/PasskeySetupHelpModal.tsx index 4baa399d62..a1bbf35166 100644 --- a/src/components/Setup/Views/PasskeySetupHelpModal.tsx +++ b/src/components/Setup/Views/PasskeySetupHelpModal.tsx @@ -14,7 +14,7 @@ interface PasskeySetupHelpModalProps { platform: 'android' | 'ios' | 'web' } -const getTroubleshootingSteps = (errorName: string, platform: 'android' | 'ios' | 'web'): readonly string[] => { +const getTroubleshootingStepIds = (errorName: string, platform: 'android' | 'ios' | 'web'): readonly string[] => { if (platform === 'android') { const steps = PASSKEY_TROUBLESHOOTING_STEPS.android return errorName in steps ? steps[errorName as keyof typeof steps] : steps.default @@ -26,7 +26,7 @@ const getTroubleshootingSteps = (errorName: string, platform: 'android' | 'ios' return PASSKEY_TROUBLESHOOTING_STEPS.web.default } -const getWarningMessage = (errorName: string, platform: 'android' | 'ios' | 'web'): string | null => { +const getWarningId = (errorName: string, platform: 'android' | 'ios' | 'web'): string | null => { if (platform === 'android' && errorName in PASSKEY_WARNINGS.android) { return PASSKEY_WARNINGS.android[errorName as keyof typeof PASSKEY_WARNINGS.android] } @@ -62,8 +62,11 @@ export const PasskeySetupHelpModal = ({ const title = getTitle() const description = getDescription() - const troubleshootingSteps = getTroubleshootingSteps(errorName, platform) - const warning = getWarningMessage(errorName, platform) + const troubleshootingSteps = getTroubleshootingStepIds(errorName, platform).map((id) => + t(`steps.${id}` as Parameters[0]) + ) + const warningId = getWarningId(errorName, platform) + const warning = warningId ? t(`warnings.${warningId}` as Parameters[0]) : null return ( {warning && ( diff --git a/src/features/limits/components/LimitsWarningCard.tsx b/src/features/limits/components/LimitsWarningCard.tsx index 097b542468..a6ca49e246 100644 --- a/src/features/limits/components/LimitsWarningCard.tsx +++ b/src/features/limits/components/LimitsWarningCard.tsx @@ -12,7 +12,9 @@ export type LimitsWarningType = 'warning' | 'error' export interface LimitsWarningCardProps { type: LimitsWarningType + /** English fallback; `titleKind` takes precedence when set */ title: string + titleKind?: 'blocking' | 'warning' items: LimitsWarningItem[] showSupportLink?: boolean /** when set, shows an "Increase my limits" button instead of the support link */ @@ -29,6 +31,7 @@ export interface LimitsWarningCardProps { export default function LimitsWarningCard({ type, title, + titleKind, items, showSupportLink = true, onIncreaseLimits, @@ -39,12 +42,41 @@ export default function LimitsWarningCard({ const tCommon = useTranslations('common') const { openSupportWithMessage } = useModalsContext() + // getLimitsWarningCardProps can't translate (it's a util), so it tags each + // item with a `kind` and we resolve the copy here — one place for every flow + const itemText = (item: LimitsWarningItem): string => { + switch (item.kind) { + case 'limit-amount': { + const amount = item.amount ?? '' + if (item.flowType === 'onramp') { + return item.perTransaction + ? t('warningCard.addUpToPerTransaction', { amount }) + : t('warningCard.addUpTo', { amount }) + } + if (item.flowType === 'offramp') { + return item.perTransaction + ? t('warningCard.withdrawUpToPerTransaction', { amount }) + : t('warningCard.withdrawUpTo', { amount }) + } + return item.perTransaction + ? t('warningCard.payUpToPerTransaction', { amount }) + : t('warningCard.payUpTo', { amount }) + } + case 'reset-days': + return t('warningCard.resetsInDays', { days: item.days ?? 0 }) + case 'check-limits': + return t('warningCard.checkLimits') + default: + return item.text + } + } + return ( )} - {item.text} + {itemText(item)} ) : ( - item.text + itemText(item) )} ))} diff --git a/src/features/limits/utils.ts b/src/features/limits/utils.ts index 5b558a12c4..bb368fa184 100644 --- a/src/features/limits/utils.ts +++ b/src/features/limits/utils.ts @@ -151,16 +151,19 @@ export interface LimitsWarningItem { isLink?: boolean href?: string icon?: IconName - // discriminator + values so components can map items to translated copy - // (this util can't call useTranslations; `text` stays the English fallback) + // discriminator + values so LimitsWarningCard can map items to translated + // copy (this util can't call useTranslations; `text` is the English fallback) kind?: 'limit-amount' | 'reset-days' | 'check-limits' amount?: string days?: number + flowType?: LimitFlowType + perTransaction?: boolean } export interface LimitsWarningCardPropsResult { type: 'warning' | 'error' title: string + titleKind: 'blocking' | 'warning' items: LimitsWarningItem[] showSupportLink: boolean } @@ -193,18 +196,20 @@ export function getLimitsWarningCardProps({ const formattedLimit = formatExtendedNumber(validation.remainingLimit ?? 0) const amountDisplay = currencySymbol === '$' ? `$${formattedLimit}` : `${currencySymbol} ${formattedLimit}` - // build the limit message based on flow type + // qr payments are always capped per transaction; onramp/offramp only when + // there's no period limit to reset + const perTransaction = flowType === 'qr-payment' || !validation.daysUntilReset + const perTransactionSuffix = perTransaction ? ' per transaction' : '' let limitMessage = '' if (flowType === 'onramp') { - limitMessage = `You can add up to ${amountDisplay}${validation.daysUntilReset ? '' : ' per transaction'}` + limitMessage = `You can add up to ${amountDisplay}${perTransactionSuffix}` } else if (flowType === 'offramp') { - limitMessage = `You can withdraw up to ${amountDisplay}${validation.daysUntilReset ? '' : ' per transaction'}` + limitMessage = `You can withdraw up to ${amountDisplay}${perTransactionSuffix}` } else { - // qr-payment - limitMessage = `You can pay up to ${amountDisplay} per transaction` + limitMessage = `You can pay up to ${amountDisplay}${perTransactionSuffix}` } - items.push({ text: limitMessage, kind: 'limit-amount', amount: amountDisplay }) + items.push({ text: limitMessage, kind: 'limit-amount', amount: amountDisplay, flowType, perTransaction }) // add days until reset if applicable if (validation.daysUntilReset) { @@ -227,6 +232,7 @@ export function getLimitsWarningCardProps({ return { type: validation.isBlocking ? 'error' : 'warning', title: validation.isBlocking ? LIMITS_COPY.BLOCKING_TITLE : LIMITS_COPY.WARNING_TITLE, + titleKind: validation.isBlocking ? 'blocking' : 'warning', items, showSupportLink: validation.isMantecaUser ?? false, } diff --git a/src/i18n/app/messages/en.json b/src/i18n/app/messages/en.json index d96c1cc3cc..0bc9846f28 100644 --- a/src/i18n/app/messages/en.json +++ b/src/i18n/app/messages/en.json @@ -403,7 +403,12 @@ "warningCard": { "blockingTitle": "This amount exceeds your limit.", "warningTitle": "You're close to your limit.", - "payUpTo": "You can pay up to {amount} per transaction", + "addUpTo": "You can add up to {amount}", + "addUpToPerTransaction": "You can add up to {amount} per transaction", + "withdrawUpTo": "You can withdraw up to {amount}", + "withdrawUpToPerTransaction": "You can withdraw up to {amount} per transaction", + "payUpTo": "You can pay up to {amount}", + "payUpToPerTransaction": "You can pay up to {amount} per transaction", "resetsInDays": "Limit resets in {days, plural, one {# day} other {# days}}.", "checkLimits": "Check my limits." } @@ -543,7 +548,27 @@ "tryTheseFixes": "Try these fixes:", "importantNote": "Important Note", "stillHavingIssues": "Still having issues?", - "contactSupport": "Contact our support team at peanut.me/support" + "contactSupport": "Contact our support team at peanut.me/support", + "steps": { + "signInGoogle": "Sign in to a Google account on this device", + "ensureSignedInGoogle": "Make sure you're signed in to a Google account on this device", + "updatePlayServices": "Update Google Play Services", + "enableScreenLock": "Enable screen lock (Settings > Security)", + "enableScreenLockSettings": "Enable screen lock in Settings > Security", + "restartApp": "Restart the app and retry", + "restartDevice": "Restart your device", + "turnOffVpnPrivacy": "Turn off VPN or privacy apps temporarily", + "turnOffVpn": "Turn off VPN temporarily", + "enableIcloudKeychain": "Enable iCloud Keychain in Settings", + "enableBiometrics": "Enable Face ID/Touch ID in Settings", + "unlockPasswordManager": "If you use a password manager like 1Password, open it and unlock it first", + "exitIncognito": "Exit Incognito/Private mode", + "checkSecuritySettings": "Check your device security settings", + "updateBrowserOs": "Update your browser and OS" + }, + "warnings": { + "lowEndAndroid": "Lower end Android devices may require recent security updates for passkeys to work properly." + } } }, "testTransaction": { diff --git a/src/i18n/app/messages/es-419.json b/src/i18n/app/messages/es-419.json index 40230f310c..8650c65c83 100644 --- a/src/i18n/app/messages/es-419.json +++ b/src/i18n/app/messages/es-419.json @@ -403,7 +403,12 @@ "warningCard": { "blockingTitle": "Este monto supera tu límite.", "warningTitle": "Estás cerca de tu límite.", - "payUpTo": "Puedes pagar hasta {amount} por transacción", + "addUpTo": "Puedes agregar hasta {amount}", + "addUpToPerTransaction": "Puedes agregar hasta {amount} por transacción", + "withdrawUpTo": "Puedes retirar hasta {amount}", + "withdrawUpToPerTransaction": "Puedes retirar hasta {amount} por transacción", + "payUpTo": "Puedes pagar hasta {amount}", + "payUpToPerTransaction": "Puedes pagar hasta {amount} por transacción", "resetsInDays": "Tu límite se restablece en {days, plural, one {# día} other {# días}}.", "checkLimits": "Ver mis límites." } @@ -543,7 +548,27 @@ "tryTheseFixes": "Prueba estas soluciones:", "importantNote": "Nota importante", "stillHavingIssues": "¿Sigues con problemas?", - "contactSupport": "Contacta a nuestro equipo de soporte en peanut.me/support" + "contactSupport": "Contacta a nuestro equipo de soporte en peanut.me/support", + "steps": { + "signInGoogle": "Inicia sesión en una cuenta de Google en este dispositivo", + "ensureSignedInGoogle": "Asegúrate de haber iniciado sesión en una cuenta de Google en este dispositivo", + "updatePlayServices": "Actualiza Google Play Services", + "enableScreenLock": "Activa el bloqueo de pantalla (Ajustes > Seguridad)", + "enableScreenLockSettings": "Activa el bloqueo de pantalla en Ajustes > Seguridad", + "restartApp": "Reinicia la app e inténtalo de nuevo", + "restartDevice": "Reinicia tu dispositivo", + "turnOffVpnPrivacy": "Desactiva temporalmente la VPN o las apps de privacidad", + "turnOffVpn": "Desactiva temporalmente la VPN", + "enableIcloudKeychain": "Activa el Llavero de iCloud en Ajustes", + "enableBiometrics": "Activa Face ID/Touch ID en Ajustes", + "unlockPasswordManager": "Si usas un gestor de contraseñas como 1Password, ábrelo y desbloquéalo primero", + "exitIncognito": "Sal del modo incógnito o privado", + "checkSecuritySettings": "Revisa los ajustes de seguridad de tu dispositivo", + "updateBrowserOs": "Actualiza tu navegador y tu sistema operativo" + }, + "warnings": { + "lowEndAndroid": "Los dispositivos Android de gama baja pueden necesitar actualizaciones de seguridad recientes para que las passkeys funcionen bien." + } } }, "testTransaction": { diff --git a/src/i18n/app/messages/pt-BR.json b/src/i18n/app/messages/pt-BR.json index ddb87e1c28..d4785bf2ff 100644 --- a/src/i18n/app/messages/pt-BR.json +++ b/src/i18n/app/messages/pt-BR.json @@ -403,7 +403,12 @@ "warningCard": { "blockingTitle": "Esse valor ultrapassa seu limite.", "warningTitle": "Você está perto do seu limite.", - "payUpTo": "Você pode pagar até {amount} por transação", + "addUpTo": "Você pode adicionar até {amount}", + "addUpToPerTransaction": "Você pode adicionar até {amount} por transação", + "withdrawUpTo": "Você pode sacar até {amount}", + "withdrawUpToPerTransaction": "Você pode sacar até {amount} por transação", + "payUpTo": "Você pode pagar até {amount}", + "payUpToPerTransaction": "Você pode pagar até {amount} por transação", "resetsInDays": "Seu limite será redefinido em {days, plural, one {# dia} other {# dias}}.", "checkLimits": "Ver meus limites." } @@ -543,7 +548,27 @@ "tryTheseFixes": "Tente estas soluções:", "importantNote": "Nota importante", "stillHavingIssues": "Ainda com problemas?", - "contactSupport": "Fale com nosso time de suporte em peanut.me/support" + "contactSupport": "Fale com nosso time de suporte em peanut.me/support", + "steps": { + "signInGoogle": "Entre em uma conta do Google neste dispositivo", + "ensureSignedInGoogle": "Verifique se você está conectado a uma conta do Google neste dispositivo", + "updatePlayServices": "Atualize o Google Play Services", + "enableScreenLock": "Ative o bloqueio de tela (Configurações > Segurança)", + "enableScreenLockSettings": "Ative o bloqueio de tela em Configurações > Segurança", + "restartApp": "Reinicie o app e tente de novo", + "restartDevice": "Reinicie seu dispositivo", + "turnOffVpnPrivacy": "Desative temporariamente a VPN ou apps de privacidade", + "turnOffVpn": "Desative a VPN temporariamente", + "enableIcloudKeychain": "Ative o Chaveiro do iCloud nos Ajustes", + "enableBiometrics": "Ative o Face ID/Touch ID nos Ajustes", + "unlockPasswordManager": "Se você usa um gerenciador de senhas como o 1Password, abra e desbloqueie ele primeiro", + "exitIncognito": "Saia do modo anônimo ou privado", + "checkSecuritySettings": "Confira as configurações de segurança do seu dispositivo", + "updateBrowserOs": "Atualize seu navegador e seu sistema operacional" + }, + "warnings": { + "lowEndAndroid": "Aparelhos Android mais simples podem precisar de atualizações de segurança recentes para as passkeys funcionarem direito." + } } }, "testTransaction": { diff --git a/src/utils/webauthn.utils.ts b/src/utils/webauthn.utils.ts index 747e430154..807c240bf7 100644 --- a/src/utils/webauthn.utils.ts +++ b/src/utils/webauthn.utils.ts @@ -176,61 +176,41 @@ export async function withWebAuthnRetry( * * RISK: If Android/iOS change their credential manager behavior, these may become outdated */ +// Step ids, not copy — PasskeySetupHelpModal resolves them against +// setup.passkey.help.steps.* so the guidance is translatable. export const PASSKEY_TROUBLESHOOTING_STEPS = { android: { // Native default — unknown/unclassified errors in the app shell. - default: [ - 'Sign in to a Google account on this device', - 'Update Google Play Services', - 'Enable screen lock (Settings > Security)', - 'Restart the app and retry', - ], - NotReadableError: [ - 'Restart your device', - 'Update Google Play Services', - 'Enable screen lock in Settings > Security', - ], - NotAllowedError: [ - "Make sure you're signed in to a Google account on this device", - 'Enable screen lock (Settings > Security)', - 'Update Google Play Services', - 'Turn off VPN or privacy apps temporarily', - ], + default: ['signInGoogle', 'updatePlayServices', 'enableScreenLock', 'restartApp'], + NotReadableError: ['restartDevice', 'updatePlayServices', 'enableScreenLockSettings'], + NotAllowedError: ['ensureSignedInGoogle', 'enableScreenLock', 'updatePlayServices', 'turnOffVpnPrivacy'], }, ios: { - default: [ - 'Enable iCloud Keychain in Settings', - 'Enable Face ID/Touch ID in Settings', - 'Restart the app and retry', - ], + default: ['enableIcloudKeychain', 'enableBiometrics', 'restartApp'], NotAllowedError: [ // First because it's the dominant field case: a wedged third-party // credential provider refuses every assertion until unlocked or the // device restarts (TASK-20000). - 'If you use a password manager like 1Password, open it and unlock it first', - 'Enable Face ID/Touch ID in Settings', - 'Enable iCloud Keychain in Settings', - 'Turn off VPN temporarily', - 'Restart your device', + 'unlockPasswordManager', + 'enableBiometrics', + 'enableIcloudKeychain', + 'turnOffVpn', + 'restartDevice', ], }, web: { // generic fallback for desktop/unsupported platforms - default: [ - 'Exit Incognito/Private mode', - 'Check your device security settings', - 'Restart your device', - 'Update your browser and OS', - ], + default: ['exitIncognito', 'checkSecuritySettings', 'restartDevice', 'updateBrowserOs'], }, } as const /** - * Platform-specific warnings for common issues + * Platform-specific warnings for common issues — warning ids resolved against + * setup.passkey.help.warnings.* by PasskeySetupHelpModal. */ export const PASSKEY_WARNINGS = { android: { - NotAllowedError: 'Lower end Android devices may require recent security updates for passkeys to work properly.', + NotAllowedError: 'lowEndAndroid', }, } as const From 348415e8cf4a98dfb4dd1d97987ce78ca4653fbb Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Thu, 16 Jul 2026 15:21:47 +0100 Subject: [PATCH 14/44] feat(i18n): extract claim flow strings --- src/components/Claim/Claim.tsx | 21 ++-- .../Claim/Generic/ClaimError.view.tsx | 10 +- src/components/Claim/Generic/Claimed.view.tsx | 20 +++- src/components/Claim/Link/Initial.view.tsx | 51 +++++---- .../Claim/Link/MantecaFlowManager.tsx | 10 +- .../Claim/Link/Onchain/Confirm.view.tsx | 30 +++-- .../Claim/Link/Onchain/Success.view.tsx | 26 ++--- .../Claim/Link/SendLinkActionList.tsx | 37 +++--- .../__tests__/SendLinkActionList.test.tsx | 8 +- .../Claim/Link/views/BankFlowManager.view.tsx | 12 +- .../Link/views/Confirm.bank-claim.view.tsx | 23 ++-- .../Link/views/MantecaDetailsStep.view.tsx | 8 +- .../Claim/Link/views/MantecaReviewStep.tsx | 26 ++--- .../Claim/__tests__/claim-states.test.tsx | 10 +- src/i18n/app/messages/en.json | 106 ++++++++++++++++++ src/i18n/app/messages/es-419.json | 106 ++++++++++++++++++ src/i18n/app/messages/pt-BR.json | 106 ++++++++++++++++++ 17 files changed, 493 insertions(+), 117 deletions(-) diff --git a/src/components/Claim/Claim.tsx b/src/components/Claim/Claim.tsx index e241423f48..f9ad054569 100644 --- a/src/components/Claim/Claim.tsx +++ b/src/components/Claim/Claim.tsx @@ -45,9 +45,12 @@ import { twMerge } from 'tailwind-merge' import { ClaimBankFlowStep, useClaimBankFlow } from '@/context/ClaimBankFlowContext' import { useSearchParams } from 'next/navigation' import { useHaptic } from 'use-haptic' +import { useTranslations } from 'next-intl' import type { IOfframpForm } from '@/constants/cashout.consts' export const Claim = ({}) => { + const t = useTranslations('claim') + const tCommon = useTranslations('common') const [linkUrl, setLinkUrl] = useState('') const [step, setStep] = useState<_consts.IClaimScreenState>(_consts.INIT_VIEW_STATE) const [linkState, setLinkState] = useState<_consts.claimLinkStateType>(_consts.claimLinkStateType.LOADING) @@ -448,14 +451,12 @@ export const Claim = ({}) => { {isSendLinkLoading && failureCount > 0 && (

- {failureCount < 3 - ? 'Loading your link...' - : 'This is taking longer than usual. The link might have just been created.'} + {failureCount < 3 ? t('loading.loadingYourLink') : t('loading.takingLonger')}

)} {isSendLinkLoading && failureCount >= 3 && (

- We're still trying... (attempt {failureCount + 1}/5) + {t('loading.stillTrying', { attempt: failureCount + 1, total: 5 })}

)}
@@ -502,9 +503,9 @@ export const Claim = ({}) => { )} {linkState === _consts.claimLinkStateType.WRONG_PASSWORD && ( { setLinkState(_consts.claimLinkStateType.LOADING) refetch() @@ -513,9 +514,9 @@ export const Claim = ({}) => { )} {linkState === _consts.claimLinkStateType.NOT_FOUND && ( { setLinkState(_consts.claimLinkStateType.LOADING) refetch() diff --git a/src/components/Claim/Generic/ClaimError.view.tsx b/src/components/Claim/Generic/ClaimError.view.tsx index b057a80a8a..e45bf2f8b1 100644 --- a/src/components/Claim/Generic/ClaimError.view.tsx +++ b/src/components/Claim/Generic/ClaimError.view.tsx @@ -4,6 +4,7 @@ import { Button } from '@/components/0_Bruddle/Button' import { useModalsContext } from '@/context/ModalsContext' import Image from 'next/image' import Link from 'next/link' +import { useTranslations } from 'next-intl' import { PeanutCrying } from '@/assets/mascot' type ClaimErrorViewProps = { @@ -15,10 +16,11 @@ type ClaimErrorViewProps = { export const ClaimErrorView = ({ title, message, primaryButtonText, onPrimaryClick }: ClaimErrorViewProps) => { const { openSupportWithMessage } = useModalsContext() + const t = useTranslations('claim') return (
- Sad peanut 😢 + {t('errors.sadPeanutAlt')}

{title}

{message}

@@ -29,17 +31,17 @@ export const ClaimErrorView = ({ title, message, primaryButtonText, onPrimaryCli - Go back to home + {t('errors.goBackToHome')}
diff --git a/src/components/Claim/Generic/Claimed.view.tsx b/src/components/Claim/Generic/Claimed.view.tsx index 5e5accfab3..82cc7b546c 100644 --- a/src/components/Claim/Generic/Claimed.view.tsx +++ b/src/components/Claim/Generic/Claimed.view.tsx @@ -2,6 +2,7 @@ import { Icon } from '@/components/Global/Icons/Icon' import { useAuth } from '@/context/authContext' import { useRouter } from 'next/navigation' +import { useTranslations } from 'next-intl' import { type FC } from 'react' import { Button } from '@/components/0_Bruddle/Button' import { Card } from '@/components/0_Bruddle/Card' @@ -14,6 +15,7 @@ interface ClaimedViewProps { export const ClaimedView: FC = ({ amount, senderUsername }) => { const { user } = useAuth() const router = useRouter() + const t = useTranslations('claim') return ( @@ -23,16 +25,24 @@ export const ClaimedView: FC = ({ amount, senderUsername }) =>
- Link no longer available + {t('claimed.title')}

- The ${amount} {senderUsername ? `sent by ` : ''} - {senderUsername && {senderUsername}} has already been claimed. + {senderUsername + ? t.rich('claimed.descriptionWithSender', { + amount: String(amount), + sender: senderUsername, + b: (chunks) => {chunks}, + }) + : t.rich('claimed.description', { + amount: String(amount), + b: (chunks) => {chunks}, + })}

- {!user &&

Create a wallet to receive future links.

} + {!user &&

{t('claimed.createWalletHint')}

}
diff --git a/src/components/Claim/Link/Initial.view.tsx b/src/components/Claim/Link/Initial.view.tsx index 792c698bfe..b8da2e7ef5 100644 --- a/src/components/Claim/Link/Initial.view.tsx +++ b/src/components/Claim/Link/Initial.view.tsx @@ -52,8 +52,12 @@ import { PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN } from '@/constants/zerodev.co import { ROUTE_NOT_FOUND_ERROR } from '@/constants/general.consts' import posthog from 'posthog-js' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' +import { useTranslations } from 'next-intl' export const InitialClaimLinkView = (props: IClaimScreenProps) => { + const t = useTranslations('claim') + const tCommon = useTranslations('common') + const tNav = useTranslations('navigation') // get campaign tag from claim link url const params = useSearchParams() const campaignTag = params.get('campaignTag') @@ -263,7 +267,7 @@ export const InitialClaimLinkView = (props: IClaimScreenProps) => { if (!inviterUsername) { setErrorState({ showError: true, - errorMessage: 'Unable to accept invite: missing inviter. Please contact support.', + errorMessage: t('errors.missingInviter'), }) setLoadingState('Idle') return @@ -278,7 +282,7 @@ export const InitialClaimLinkView = (props: IClaimScreenProps) => { console.error('Failed to accept invite') setErrorState({ showError: true, - errorMessage: 'Something went wrong. Please try again or contact support.', + errorMessage: t('errors.generic'), }) setLoadingState('Idle') return @@ -292,7 +296,7 @@ export const InitialClaimLinkView = (props: IClaimScreenProps) => { console.error('Failed to accept invite', error) setErrorState({ showError: true, - errorMessage: 'Something went wrong. Please try again or contact support.', + errorMessage: t('errors.generic'), }) setLoadingState('Idle') return @@ -451,6 +455,7 @@ export const InitialClaimLinkView = (props: IClaimScreenProps) => { address, campaignTag, fetchUser, + t, ] ) @@ -492,7 +497,7 @@ export const InitialClaimLinkView = (props: IClaimScreenProps) => { if (!route) { setErrorState({ showError: true, - errorMessage: 'offramp unavailable', + errorMessage: t('errors.offrampUnavailable'), }) return } @@ -556,7 +561,7 @@ export const InitialClaimLinkView = (props: IClaimScreenProps) => { } catch (error) { setErrorState({ showError: true, - errorMessage: 'You can not claim this link to your bank account.', + errorMessage: t('errors.bankClaimUnavailable'), }) Sentry.captureException(error) } finally { @@ -819,28 +824,28 @@ export const InitialClaimLinkView = (props: IClaimScreenProps) => { if (isPeanutWallet && !claimToExternalWallet) { return (
-
Receive on
+
{t('initial.receiveOn')}
- Peanut Logo - Peanut Logo + {tNav('peanutLogoAlt')} + {tNav('peanutLogoAlt')}
) } if (selectedRoute) { - return 'Review' + return t('review') } if ((isLoading || isXchainLoading) && !inputChanging) { - return 'Receiving' + return t('receiving') } if (isXChain && hasFetchedRoute && !selectedRoute) { - return 'Retry' + return tCommon('retry') } - return 'Receive now' + return t('receiveNow') } const handleClaimAction = () => { @@ -918,7 +923,7 @@ export const InitialClaimLinkView = (props: IClaimScreenProps) => { {!!user?.user.userId || claimBankFlowStep || claimToExternalWallet ? (
{ if (claimToExternalWallet) { setClaimToExternalWallet(false) @@ -930,7 +935,7 @@ export const InitialClaimLinkView = (props: IClaimScreenProps) => {
) : (
-
Receive
+
{t('receive')}
)}
@@ -968,7 +973,7 @@ export const InitialClaimLinkView = (props: IClaimScreenProps) => { {/* Manual Input Section - Always visible in non-peanut-only mode */} {!!claimToExternalWallet && ( { setRecipient(update.recipient) @@ -999,9 +1004,7 @@ export const InitialClaimLinkView = (props: IClaimScreenProps) => { /> )} {recipientType === 'username' && !!claimToExternalWallet && ( -
- You can only claim USDC on Arbitrum for Peanut Wallet users. -
+
{t('initial.usdcArbitrumOnly')}
)}
@@ -1038,11 +1041,11 @@ export const InitialClaimLinkView = (props: IClaimScreenProps) => { setShowConfirmationModal(false)} - title="Is this address compatible?" + title={t('addressCompatible.title')} description={
-

Only claim to an address that support the selected network and token.

-

Incorrect transfers may be lost. If you're unsure, do not proceed.

+

{t('addressCompatible.line1')}

+

{t('addressCompatible.line2')}

} icon="alert" @@ -1070,7 +1073,7 @@ export const InitialClaimLinkView = (props: IClaimScreenProps) => { setClaimToExternalWallet(false) }} > - Not sure? Claim to peanut instead + {t('addressCompatible.claimToPeanut')}
} @@ -1079,13 +1082,13 @@ export const InitialClaimLinkView = (props: IClaimScreenProps) => { /> { removeParamStep() setShowVerificationModal(false) }} - description="The sender isn't verified for this method. You'll have to create an account, verify your identity, and then your funds will be deposited to your bank." + description={t('guestVerification.description')} inviterUsername={claimLinkData?.sender?.username} />
diff --git a/src/components/Claim/Link/MantecaFlowManager.tsx b/src/components/Claim/Link/MantecaFlowManager.tsx index 6c79705df5..e2ce458c77 100644 --- a/src/components/Claim/Link/MantecaFlowManager.tsx +++ b/src/components/Claim/Link/MantecaFlowManager.tsx @@ -17,6 +17,7 @@ import { useMultiPhaseKycFlow } from '@/hooks/useMultiPhaseKycFlow' import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals' import { InitiateKycModal } from '@/components/Kyc/InitiateKycModal' import { deriveProviderRejection } from '@/utils/provider-rejection.utils' +import { useTranslations } from 'next-intl' interface MantecaFlowManagerProps { claimLinkData: ClaimLinkData @@ -25,6 +26,7 @@ interface MantecaFlowManagerProps { } const MantecaFlowManager: FC = ({ claimLinkData, amount, attachment }) => { + const t = useTranslations('claim') const { setClaimToMercadoPago, selectedCountry, regionalMethodType } = useClaimBankFlow() const [currentStep, setCurrentStep] = useState(MercadoPagoStep.DETAILS) const router = useRouter() @@ -97,7 +99,7 @@ const MantecaFlowManager: FC = ({ claimLinkData, amount if (currentStep === MercadoPagoStep.SUCCESS) { return ( ) } @@ -122,7 +124,7 @@ const MantecaFlowManager: FC = ({ claimLinkData, amount return (
- +
= ({ claimLinkData, amount avatarSize="medium" transactionType="REGIONAL_METHOD_CLAIM" recipientType="USERNAME" - recipientName={isSuccess ? 'You’ll receive' : 'Receive in ' + displayMethodType} + recipientName={ + isSuccess ? t('manteca.youWillReceive') : t('manteca.receiveIn', { method: displayMethodType }) + } amount={amount} tokenSymbol={claimLinkData.tokenSymbol} message={attachment.message} diff --git a/src/components/Claim/Link/Onchain/Confirm.view.tsx b/src/components/Claim/Link/Onchain/Confirm.view.tsx index 70af16df52..eefe1b95f0 100644 --- a/src/components/Claim/Link/Onchain/Confirm.view.tsx +++ b/src/components/Claim/Link/Onchain/Confirm.view.tsx @@ -23,6 +23,7 @@ import { useSearchParams } from 'next/navigation' import posthog from 'posthog-js' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' import underMaintenanceConfig, { CROSS_CHAIN_DISABLED_MESSAGE } from '@/config/underMaintenance.config' +import { useTranslations } from 'next-intl' export const ConfirmClaimLinkView = ({ onNext, @@ -35,6 +36,8 @@ export const ConfirmClaimLinkView = ({ attachment, selectedRoute, }: _consts.IClaimScreenProps) => { + const t = useTranslations('claim') + const tNav = useTranslations('navigation') const { address } = useWallet() const { user } = useAuth() const { claimLinkXchain, claimLink } = useClaimLink() @@ -74,7 +77,7 @@ export const ConfirmClaimLinkView = ({ }, [selectedRoute, resolvedTokenSymbol]) // Network fee display – always sponsored in this flow - const networkFeeDisplay: string = 'Sponsored by Peanut!' + const networkFeeDisplay: string = t('confirm.sponsoredByPeanut') const handleOnClaim = async () => { if (!recipient) { @@ -166,7 +169,7 @@ export const ConfirmClaimLinkView = ({ return (
- +
)} {/* Token & network row */} {
@@ -211,7 +214,7 @@ export const ConfirmClaimLinkView = ({
- {resolvedTokenSymbol || claimLinkData.tokenSymbol} on{' '} - {resolvedChainName || selectedChainID} + {t.rich('confirm.tokenOnChain', { + token: resolvedTokenSymbol || claimLinkData.tokenSymbol, + chain: resolvedChainName || selectedChainID, + c: (chunks) => {chunks}, + })}
} @@ -229,10 +235,10 @@ export const ConfirmClaimLinkView = ({ } {/* Max network fee row */} - + {/* Peanut fee row */} - + )} @@ -243,7 +249,7 @@ export const ConfirmClaimLinkView = ({ disabled={isLoading || (isXChain && !selectedRoute)} loading={isLoading || (isXChain && !selectedRoute)} > - Receive now + {t('receiveNow')} {errorState.showError && } diff --git a/src/components/Claim/Link/Onchain/Success.view.tsx b/src/components/Claim/Link/Onchain/Success.view.tsx index 32c9a21e55..1eae378a87 100644 --- a/src/components/Claim/Link/Onchain/Success.view.tsx +++ b/src/components/Claim/Link/Onchain/Success.view.tsx @@ -20,6 +20,7 @@ import CreateAccountButton from '@/components/Global/CreateAccountButton' import { PeanutCheering } from '@/assets/mascot' import Image from 'next/image' import { useHaptic } from 'use-haptic' +import { useTranslations } from 'next-intl' export const SuccessClaimLinkView = ({ transactionHash, @@ -27,6 +28,7 @@ export const SuccessClaimLinkView = ({ claimLinkData, tokenPrice, }: _consts.IClaimScreenProps) => { + const t = useTranslations('claim') const { user: authUser } = useUserStore() const { fetchUser } = useAuth() const router = useRouter() @@ -117,20 +119,20 @@ export const SuccessClaimLinkView = ({ const maskedAccountNumber = useMemo(() => { if (bankDetails?.iban) { - return `to ${shortenStringLong(bankDetails.iban)}` + return t('success.toAccount', { account: shortenStringLong(bankDetails.iban) }) } if (bankDetails?.clabe) { - return `to ${shortenStringLong(bankDetails.clabe)}` + return t('success.toAccount', { account: shortenStringLong(bankDetails.clabe) }) } if (bankDetails?.accountNumber) { - return `to ${shortenStringLong(bankDetails.accountNumber)}` + return t('success.toAccount', { account: shortenStringLong(bankDetails.accountNumber) }) } - return 'to your bank account' - }, [bankDetails]) + return t('success.toYourBankAccount') + }, [bankDetails, t]) const isBankClaim = claimType === 'claim-bank' - const navHeaderTitle = 'Receive' + const navHeaderTitle = t('receive') const cardProps = { viewType: 'SUCCESS' as const, @@ -145,8 +147,8 @@ export const SuccessClaimLinkView = ({ ) ?? '') : formatUnits(claimLinkData.amount, tokenDetails?.decimals ?? 6), tokenSymbol: isBankClaim ? (offrampDetails?.quote.destination_currency ?? '') : claimLinkData.tokenSymbol, - message: isBankClaim ? maskedAccountNumber : `from ${senderDisplay.displayName}`, - title: isBankClaim ? 'You will receive' : 'You claimed', + message: isBankClaim ? maskedAccountNumber : t('success.fromSender', { sender: senderDisplay.displayName }), + title: isBankClaim ? t('success.youWillReceive') : t('success.youClaimed'), } const renderButtons = () => { @@ -160,7 +162,7 @@ export const SuccessClaimLinkView = ({ }} className="w-full" > - Back to home + {t('backToHome')} ) } @@ -188,7 +190,7 @@ export const SuccessClaimLinkView = ({ Peanut Mascot {renderButtons()} {campaignTag === 'devconnect_ba_2025' && ( -

- Tap the X button at the top left to return to the Devconnect app -

+

{t('success.devconnectReturnHint')}

)}
diff --git a/src/components/Claim/Link/SendLinkActionList.tsx b/src/components/Claim/Link/SendLinkActionList.tsx index 3fa6d87546..4a2e13193f 100644 --- a/src/components/Claim/Link/SendLinkActionList.tsx +++ b/src/components/Claim/Link/SendLinkActionList.tsx @@ -52,6 +52,7 @@ import { validateMinimumAmount, } from '@/constants/payment.consts' import { useAppDispatch } from '@/redux/hooks' +import { useTranslations } from 'next-intl' const SHOW_INVITE_MODAL_FOR_DEVCONNECT = false @@ -74,6 +75,9 @@ export default function SendLinkActionList({ showDevconnectMethod, setExternalWalletRecipient, }: ISendLinkActionListProps) { + const t = useTranslations('claim') + const tCommon = useTranslations('common') + const tNav = useTranslations('navigation') const router = useRouter() const { setClaimToExternalWallet, @@ -226,11 +230,12 @@ export default function SendLinkActionList({ }} >
- Claim on Devconnect Logo Devconnect - app + {t('actions.claimOn')}{' '} + {t('actions.devconnectLogoAlt')}{' '} + {t('actions.devconnectApp')}
- + )} @@ -241,23 +246,23 @@ export default function SendLinkActionList({ onClick={handleContinueWithPeanut} className="flex w-full items-center gap-1" > - {showDevconnectMethod ?
Claim on
:
Continue with
} + {showDevconnectMethod ?
{t('actions.claimOn')}
:
{t('actions.continueWith')}
}
- Peanut Logo - Peanut Logo + {tNav('peanutLogoAlt')} + {tNav('peanutLogoAlt')}
)} {SHOW_INVITE_MODAL_FOR_DEVCONNECT && isInviteLink && !userHasAppAccess && username && (
- star -

Invited by {username}, you have early access!

- star + {t('actions.starAlt')} +

{t('actions.invitedBy', { username })}

+ {t('actions.starAlt')}
)} - +
{sortedActionMethods.map((method) => { @@ -290,12 +295,15 @@ export default function SendLinkActionList({ setShowMinAmountError(false)} - title="Minimum Amount" - description={`The minimum amount for ${minAmountErrorInfo?.title ?? 'this payment method'} is $${minAmountErrorInfo?.amount ?? 0}. Please try a different method.`} + title={t('minAmount.title')} + description={t('minAmount.description', { + method: minAmountErrorInfo?.title ?? t('minAmount.thisPaymentMethod'), + amount: minAmountErrorInfo?.amount ?? 0, + })} icon="alert" ctas={[ { - text: 'Close', + text: tCommon('close'), shadowSize: '4', onClick: () => setShowMinAmountError(false), }, @@ -340,6 +348,7 @@ const MethodCard = ({ // static method config has soon=false (e.g. guest claim-to-bank maintenance) soon?: boolean }) => { + const t = useTranslations('claim') const showSoon = method.soon || soon return ( )}
diff --git a/src/components/Claim/Link/__tests__/SendLinkActionList.test.tsx b/src/components/Claim/Link/__tests__/SendLinkActionList.test.tsx index 88f6b1557f..5f6dee8161 100644 --- a/src/components/Claim/Link/__tests__/SendLinkActionList.test.tsx +++ b/src/components/Claim/Link/__tests__/SendLinkActionList.test.tsx @@ -8,6 +8,8 @@ */ import React from 'react' import { render, screen, fireEvent } from '@testing-library/react' +import { NextIntlClientProvider } from 'next-intl' +import en from '@/i18n/app/messages/en.json' // ---------- module-level mocks (before importing the component) ---------- @@ -126,7 +128,11 @@ const claimLinkData = { } as any function renderList() { - return render() + return render( + + + + ) } beforeEach(() => { diff --git a/src/components/Claim/Link/views/BankFlowManager.view.tsx b/src/components/Claim/Link/views/BankFlowManager.view.tsx index cc533bbd79..3550477fe1 100644 --- a/src/components/Claim/Link/views/BankFlowManager.view.tsx +++ b/src/components/Claim/Link/views/BankFlowManager.view.tsx @@ -40,6 +40,7 @@ import { useTosGuard } from '@/hooks/useTosGuard' import { BridgeTosStep } from '@/components/Kyc/BridgeTosStep' import { InitiateKycModal } from '@/components/Kyc/InitiateKycModal' import { useModalsContext } from '@/context/ModalsContext' +import { useTranslations } from 'next-intl' type BankAccountWithId = IBankAccountDetails & ( @@ -55,6 +56,7 @@ type BankAccountWithId = IBankAccountDetails & * It handles creating off-ramps, adding bank accounts, and orchestrating the KYC process. */ export const BankFlowManager = (props: IClaimScreenProps) => { + const t = useTranslations('claim') // props and basic setup const { onCustom, claimLinkData, setTransactionHash } = props const { user, fetchUser } = useAuth() @@ -378,7 +380,7 @@ export const BankFlowManager = (props: IClaimScreenProps) => { setReceiverFullName(`${bankDetails.firstName} ${bankDetails.lastName}`) setClaimBankFlowStep(ClaimBankFlowStep.BankConfirmClaim) } else { - return { error: 'Failed to process bank account. Please try again.' } + return { error: t('bank.processAccountFailed') } } } finally { setIsProcessingKycSuccess(false) @@ -388,7 +390,7 @@ export const BankFlowManager = (props: IClaimScreenProps) => { // scenario 3: guest user is claiming (using sender's KYC) else if (bankClaimType === BankClaimType.GuestBankClaim) { if (!selectedCountry) { - const err = 'Country not selected' + const err = t('bank.countryNotSelected') setError(err) return { error: err } } @@ -486,7 +488,7 @@ export const BankFlowManager = (props: IClaimScreenProps) => { case ClaimBankFlowStep.SavedAccountsList: return ( setClaimBankFlowStep(null)} savedAccounts={savedAccounts} onAccountClick={async (account) => { @@ -538,13 +540,13 @@ export const BankFlowManager = (props: IClaimScreenProps) => { /> ) case ClaimBankFlowStep.BankCountryList: - return + return case ClaimBankFlowStep.BankDetailsForm: return (
{ dispatch(bankFormActions.clearFormData()) // clear DynamicBankAccountForm data if (savedAccounts.length > 0) { diff --git a/src/components/Claim/Link/views/Confirm.bank-claim.view.tsx b/src/components/Claim/Link/views/Confirm.bank-claim.view.tsx index c3372bc0dc..c6c91ddef3 100644 --- a/src/components/Claim/Link/views/Confirm.bank-claim.view.tsx +++ b/src/components/Claim/Link/views/Confirm.bank-claim.view.tsx @@ -14,6 +14,7 @@ import { formatUnits } from 'viem' import ExchangeRate from '@/components/ExchangeRate' import { AccountType } from '@/interfaces' import countryCurrencyMappings from '@/constants/countryCurrencyMapping' +import { useTranslations } from 'next-intl' interface ConfirmBankClaimViewProps { onConfirm: () => void @@ -34,6 +35,8 @@ export function ConfirmBankClaimView({ fullName, claimLinkData, }: ConfirmBankClaimViewProps) { + const t = useTranslations('claim') + const tCommon = useTranslations('common') const displayedFullName = useMemo(() => { if (fullName) return fullName if (typeof window !== 'undefined') { @@ -66,7 +69,7 @@ export function ConfirmBankClaimView({ return (
- +
{/* todo: take full name from user, this name rn is of senders */} - + {bankDetails.iban && } {bankDetails.bic && } {bankDetails.clabe && } {bankDetails.accountNumber && ( - + )} {bankDetails.routingNumber && ( - + )} - +
@@ -106,7 +115,7 @@ export function ConfirmBankClaimView({ className="w-full" icon="retry" > - Retry + {tCommon('retry')} ) : ( )} diff --git a/src/components/Claim/Link/views/MantecaDetailsStep.view.tsx b/src/components/Claim/Link/views/MantecaDetailsStep.view.tsx index a778748d5a..8b9641fe24 100644 --- a/src/components/Claim/Link/views/MantecaDetailsStep.view.tsx +++ b/src/components/Claim/Link/views/MantecaDetailsStep.view.tsx @@ -8,6 +8,7 @@ import ValidatedInput from '@/components/Global/ValidatedInput' import { validateCbuCvuAlias } from '@/utils/withdraw.utils' import ErrorAlert from '@/components/Global/ErrorAlert' import { MANTECA_COUNTRIES_CONFIG } from '@/constants/manteca.consts' +import { useTranslations } from 'next-intl' interface MantecaDetailsStepProps { setCurrentStep: Dispatch> @@ -20,6 +21,7 @@ const MantecaDetailsStep: FC = ({ destinationAddress, setDestinationAddress, }) => { + const t = useTranslations('claim') const handleOnClick = async () => { setCurrentStep(MercadoPagoStep.REVIEW) } @@ -43,7 +45,7 @@ const MantecaDetailsStep: FC = ({ return ( <> -

Enter account details

+

{t('manteca.enterAccountDetails')}

= ({ />
- You can only withdraw to accounts under your name. + {t('manteca.ownAccountOnly')}
{errorMessage && } ) diff --git a/src/components/Claim/Link/views/MantecaReviewStep.tsx b/src/components/Claim/Link/views/MantecaReviewStep.tsx index 0c8c19081d..40d135bdd7 100644 --- a/src/components/Claim/Link/views/MantecaReviewStep.tsx +++ b/src/components/Claim/Link/views/MantecaReviewStep.tsx @@ -10,6 +10,7 @@ import { type Dispatch, type FC, type SetStateAction, useState } from 'react' import useClaimLink from '@/components/Claim/useClaimLink' import * as Sentry from '@sentry/nextjs' import { MANTECA_DEPOSIT_ADDRESS } from '@/constants/manteca.consts' +import { useTranslations } from 'next-intl' interface MantecaReviewStepProps { setCurrentStep: Dispatch> @@ -26,6 +27,8 @@ const MantecaReviewStep: FC = ({ amount, currency, }) => { + const t = useTranslations('claim') + const tNav = useTranslations('navigation') const [isSubmitting, setIsSubmitting] = useState(false) const [error, setError] = useState(null) const { price, isLoading } = useCurrency(currency) @@ -34,19 +37,19 @@ const MantecaReviewStep: FC = ({ const detailsCardRows: MantecaCardRow[] = [ { key: 'destinationAddress', - label: 'Destination Address', + label: t('manteca.destinationAddress'), value: destinationAddress, allowCopy: true, }, { key: 'exchangeRate', - label: 'Exchange Rate', + label: t('manteca.exchangeRate'), value: `1 USD = ${price?.buy} ${currency}`, }, { key: 'fee', - label: 'Fee', - value: 'Sponsored by Peanut', + label: t('fee'), + value: t('manteca.sponsoredByPeanut'), hideBottomBorder: true, }, ] @@ -63,7 +66,7 @@ const MantecaReviewStep: FC = ({ }) if (!txHash) { - setError('Claim failed: missing transaction hash.') + setError(t('manteca.errors.missingTxHash')) return } @@ -86,10 +89,7 @@ const MantecaReviewStep: FC = ({ } catch (retryError) { console.error('Failed to associate claim after retry:', retryError) // Show warning but don't block - user's funds are safe - setError( - 'Withdrawal successful! Your funds are being processed. ' + - "If the transaction doesn't appear in your history within 5 minutes, please contact support." - ) + setError(t('manteca.errors.withdrawalProcessing')) Sentry.captureException(retryError, { tags: { feature: 'manteca-claim-association-retry-failed' }, extra: { txHash, claimLink }, @@ -108,15 +108,15 @@ const MantecaReviewStep: FC = ({ if (withdrawError || !data) { // handle third-party account error with user-friendly message if (withdrawError === 'TAX_ID_MISMATCH' || withdrawError === 'CUIT_MISMATCH') { - setError('You can only withdraw to accounts under your name.') + setError(t('manteca.ownAccountOnly')) } else { - setError(withdrawError || 'Something went wrong. Please contact Support') + setError(withdrawError || t('manteca.errors.generic')) } return } setCurrentStep(MercadoPagoStep.SUCCESS) } catch (error) { - setError(error instanceof Error ? error.message : 'Something went wrong. Please contact Support') + setError(error instanceof Error ? error.message : t('manteca.errors.generic')) console.error('Error claiming link:', error) } finally { setIsSubmitting(false) @@ -134,7 +134,7 @@ const MantecaReviewStep: FC = ({ {error && } ) diff --git a/src/components/Claim/__tests__/claim-states.test.tsx b/src/components/Claim/__tests__/claim-states.test.tsx index a41fde0509..dad024e98c 100644 --- a/src/components/Claim/__tests__/claim-states.test.tsx +++ b/src/components/Claim/__tests__/claim-states.test.tsx @@ -11,6 +11,8 @@ import React from 'react' import { render, screen, waitFor } from '@testing-library/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { NextIntlClientProvider } from 'next-intl' +import en from '@/i18n/app/messages/en.json' // ---------- module-level mocks (must be before imports that depend on them) ---------- @@ -248,9 +250,11 @@ function createQueryClient() { function renderClaim() { const queryClient = createQueryClient() return render( - - - + + + + + ) } diff --git a/src/i18n/app/messages/en.json b/src/i18n/app/messages/en.json index 0bc9846f28..b6b535b231 100644 --- a/src/i18n/app/messages/en.json +++ b/src/i18n/app/messages/en.json @@ -695,6 +695,112 @@ "contactSupport": "Contact Support" } }, + "claim": { + "receive": "Receive", + "receiveNow": "Receive now", + "receiving": "Receiving", + "review": "Review", + "backToHome": "Back to home", + "fee": "Fee", + "loading": { + "loadingYourLink": "Loading your link...", + "takingLonger": "This is taking longer than usual. The link might have just been created.", + "stillTrying": "We're still trying... (attempt {attempt}/{total})" + }, + "errors": { + "sadPeanutAlt": "Sad peanut 😢", + "talkToSupport": "Talk to support", + "goBackToHome": "Go back to home", + "supportPrefill": "I clicked on this link but got an error: {url}", + "wrongPasswordTitle": "Wrong password!", + "wrongPasswordMessage": "Are you sure you clicked on the right link?", + "brokenLinkTitle": "This link seems broken!", + "brokenLinkMessage": "Are you sure you clicked on the right link? Was this link just created? Try again in a few seconds.", + "retryLoadingLink": "Retry Loading Link", + "missingInviter": "Unable to accept invite: missing inviter. Please contact support.", + "generic": "Something went wrong. Please try again or contact support.", + "offrampUnavailable": "Offramp unavailable", + "bankClaimUnavailable": "You can not claim this link to your bank account." + }, + "claimed": { + "title": "Link no longer available", + "description": "The ${amount} has already been claimed.", + "descriptionWithSender": "The ${amount} sent by {sender} has already been claimed.", + "createWalletHint": "Create a wallet to receive future links.", + "getStarted": "Get Started" + }, + "initial": { + "receiveOn": "Receive on", + "recipientPlaceholder": "Enter a username, an address or ENS", + "usdcArbitrumOnly": "You can only claim USDC on Arbitrum for Peanut Wallet users." + }, + "addressCompatible": { + "title": "Is this address compatible?", + "line1": "Only claim to an address that support the selected network and token.", + "line2": "Incorrect transfers may be lost. If you're unsure, do not proceed.", + "claimToPeanut": "Not sure? Claim to peanut instead" + }, + "guestVerification": { + "otherMethod": "Claim with other method", + "description": "The sender isn't verified for this method. You'll have to create an account, verify your identity, and then your funds will be deposited to your bank." + }, + "actions": { + "claimOn": "Claim on", + "continueWith": "Continue with", + "devconnectApp": "Devconnect app", + "devconnectLogoAlt": "Devconnect Logo", + "starAlt": "star", + "invitedBy": "Invited by {username}, you have early access!", + "requiresVerification": "REQUIRES VERIFICATION" + }, + "minAmount": { + "title": "Minimum Amount", + "thisPaymentMethod": "this payment method", + "description": "The minimum amount for {method} is ${amount}. Please try a different method." + }, + "confirm": { + "minReceived": "Min Received", + "slippageInfo": "This transaction may face slippage due to token conversion or cross-chain bridging.", + "tokenAndNetwork": "Token and network", + "tokenAlt": "token", + "chainAlt": "chain", + "tokenOnChain": "{token} on {chain}", + "maxNetworkFee": "Max network fee", + "peanutFee": "Peanut fee", + "sponsoredByPeanut": "Sponsored by Peanut!" + }, + "success": { + "peanutMascotAlt": "Peanut Mascot", + "toAccount": "to {account}", + "toYourBankAccount": "to your bank account", + "fromSender": "from {sender}", + "youWillReceive": "You will receive", + "youClaimed": "You claimed", + "devconnectReturnHint": "Tap the X button at the top left to return to the Devconnect app" + }, + "bank": { + "selectCountry": "Select your bank account's country", + "fullName": "Full name", + "accountNumber": "Account Number", + "routingNumber": "Routing Number", + "countryNotSelected": "Country not selected", + "processAccountFailed": "Failed to process bank account. Please try again." + }, + "manteca": { + "youWillReceive": "You’ll receive", + "receiveIn": "Receive in {method}", + "enterAccountDetails": "Enter account details", + "ownAccountOnly": "You can only withdraw to accounts under your name.", + "destinationAddress": "Destination Address", + "exchangeRate": "Exchange Rate", + "sponsoredByPeanut": "Sponsored by Peanut", + "errors": { + "missingTxHash": "Claim failed: missing transaction hash.", + "withdrawalProcessing": "Withdrawal successful! Your funds are being processed. If the transaction doesn't appear in your history within 5 minutes, please contact support.", + "generic": "Something went wrong. Please contact Support" + } + } + }, "qrPay": { "youArePaying": "You're paying", "paymentMethodAlt": "Payment method", diff --git a/src/i18n/app/messages/es-419.json b/src/i18n/app/messages/es-419.json index 8650c65c83..891bcaf2f9 100644 --- a/src/i18n/app/messages/es-419.json +++ b/src/i18n/app/messages/es-419.json @@ -695,6 +695,112 @@ "contactSupport": "Contactar a soporte" } }, + "claim": { + "receive": "Recibir", + "receiveNow": "Recibir ahora", + "receiving": "Recibiendo", + "review": "Revisar", + "backToHome": "Volver al inicio", + "fee": "Comisión", + "loading": { + "loadingYourLink": "Cargando tu enlace...", + "takingLonger": "Esto está tardando más de lo normal. Puede que el enlace se haya creado recién.", + "stillTrying": "Seguimos intentando... (intento {attempt}/{total})" + }, + "errors": { + "sadPeanutAlt": "Peanut triste 😢", + "talkToSupport": "Hablar con soporte", + "goBackToHome": "Volver al inicio", + "supportPrefill": "Hice clic en este enlace pero obtuve un error: {url}", + "wrongPasswordTitle": "¡Contraseña incorrecta!", + "wrongPasswordMessage": "¿Seguro que hiciste clic en el enlace correcto?", + "brokenLinkTitle": "¡Este enlace parece roto!", + "brokenLinkMessage": "¿Seguro que hiciste clic en el enlace correcto? ¿El enlace se acaba de crear? Inténtalo de nuevo en unos segundos.", + "retryLoadingLink": "Reintentar cargar el enlace", + "missingInviter": "No se pudo aceptar la invitación: falta quien te invitó. Contacta a soporte.", + "generic": "Algo salió mal. Inténtalo de nuevo o contacta a soporte.", + "offrampUnavailable": "Retiro no disponible", + "bankClaimUnavailable": "No puedes reclamar este enlace a tu cuenta bancaria." + }, + "claimed": { + "title": "El enlace ya no está disponible", + "description": "Los ${amount} ya fueron reclamados.", + "descriptionWithSender": "Los ${amount} enviados por {sender} ya fueron reclamados.", + "createWalletHint": "Crea una billetera para recibir enlaces en el futuro.", + "getStarted": "Empezar" + }, + "initial": { + "receiveOn": "Recibir en", + "recipientPlaceholder": "Ingresa un usuario, una dirección o ENS", + "usdcArbitrumOnly": "Solo puedes reclamar USDC en Arbitrum para usuarios de Peanut Wallet." + }, + "addressCompatible": { + "title": "¿Esta dirección es compatible?", + "line1": "Reclama solo a una dirección que admita la red y el token seleccionados.", + "line2": "Las transferencias incorrectas pueden perderse. Si no estás seguro, no continúes.", + "claimToPeanut": "¿No estás seguro? Reclama en Peanut" + }, + "guestVerification": { + "otherMethod": "Reclamar con otro método", + "description": "Quien envió no está verificado para este método. Tendrás que crear una cuenta, verificar tu identidad y luego tus fondos se depositarán en tu banco." + }, + "actions": { + "claimOn": "Reclamar en", + "continueWith": "Continuar con", + "devconnectApp": "app de Devconnect", + "devconnectLogoAlt": "Logo de Devconnect", + "starAlt": "estrella", + "invitedBy": "¡{username} te invitó, tienes acceso anticipado!", + "requiresVerification": "REQUIERE VERIFICACIÓN" + }, + "minAmount": { + "title": "Monto mínimo", + "thisPaymentMethod": "este método de pago", + "description": "El monto mínimo para {method} es ${amount}. Prueba con otro método." + }, + "confirm": { + "minReceived": "Mínimo a recibir", + "slippageInfo": "Esta transacción puede tener slippage por la conversión de tokens o el puente entre redes.", + "tokenAndNetwork": "Token y red", + "tokenAlt": "token", + "chainAlt": "red", + "tokenOnChain": "{token} en {chain}", + "maxNetworkFee": "Comisión máxima de red", + "peanutFee": "Comisión de Peanut", + "sponsoredByPeanut": "¡Patrocinada por Peanut!" + }, + "success": { + "peanutMascotAlt": "Mascota de Peanut", + "toAccount": "a {account}", + "toYourBankAccount": "a tu cuenta bancaria", + "fromSender": "de {sender}", + "youWillReceive": "Vas a recibir", + "youClaimed": "Reclamaste", + "devconnectReturnHint": "Toca el botón X de arriba a la izquierda para volver a la app de Devconnect" + }, + "bank": { + "selectCountry": "Selecciona el país de tu cuenta bancaria", + "fullName": "Nombre completo", + "accountNumber": "Número de cuenta", + "routingNumber": "Número de routing", + "countryNotSelected": "No seleccionaste un país", + "processAccountFailed": "No se pudo procesar la cuenta bancaria. Inténtalo de nuevo." + }, + "manteca": { + "youWillReceive": "Vas a recibir", + "receiveIn": "Recibir en {method}", + "enterAccountDetails": "Ingresa los datos de la cuenta", + "ownAccountOnly": "Solo puedes retirar a cuentas a tu nombre.", + "destinationAddress": "Dirección de destino", + "exchangeRate": "Tipo de cambio", + "sponsoredByPeanut": "Patrocinada por Peanut", + "errors": { + "missingTxHash": "Falló el reclamo: falta el hash de la transacción.", + "withdrawalProcessing": "¡Retiro exitoso! Tus fondos se están procesando. Si la transacción no aparece en tu historial en 5 minutos, contacta a soporte.", + "generic": "Algo salió mal. Contacta a soporte" + } + } + }, "qrPay": { "youArePaying": "Estás pagando", "paymentMethodAlt": "Método de pago", diff --git a/src/i18n/app/messages/pt-BR.json b/src/i18n/app/messages/pt-BR.json index d4785bf2ff..a1e77a6a13 100644 --- a/src/i18n/app/messages/pt-BR.json +++ b/src/i18n/app/messages/pt-BR.json @@ -695,6 +695,112 @@ "contactSupport": "Contatar o suporte" } }, + "claim": { + "receive": "Receber", + "receiveNow": "Receber agora", + "receiving": "Recebendo", + "review": "Revisar", + "backToHome": "Voltar ao início", + "fee": "Taxa", + "loading": { + "loadingYourLink": "Carregando seu link...", + "takingLonger": "Isso está demorando mais que o normal. O link pode ter sido criado agora há pouco.", + "stillTrying": "Ainda estamos tentando... (tentativa {attempt}/{total})" + }, + "errors": { + "sadPeanutAlt": "Peanut triste 😢", + "talkToSupport": "Falar com o suporte", + "goBackToHome": "Voltar ao início", + "supportPrefill": "Cliquei neste link mas deu erro: {url}", + "wrongPasswordTitle": "Senha incorreta!", + "wrongPasswordMessage": "Tem certeza de que clicou no link certo?", + "brokenLinkTitle": "Este link parece quebrado!", + "brokenLinkMessage": "Tem certeza de que clicou no link certo? O link acabou de ser criado? Tente de novo em alguns segundos.", + "retryLoadingLink": "Tentar carregar o link de novo", + "missingInviter": "Não foi possível aceitar o convite: falta quem convidou. Fale com o suporte.", + "generic": "Algo deu errado. Tente de novo ou fale com o suporte.", + "offrampUnavailable": "Saque indisponível", + "bankClaimUnavailable": "Você não pode resgatar este link para sua conta bancária." + }, + "claimed": { + "title": "O link não está mais disponível", + "description": "Os ${amount} já foram resgatados.", + "descriptionWithSender": "Os ${amount} enviados por {sender} já foram resgatados.", + "createWalletHint": "Crie uma carteira para receber links no futuro.", + "getStarted": "Começar" + }, + "initial": { + "receiveOn": "Receber em", + "recipientPlaceholder": "Digite um usuário, um endereço ou ENS", + "usdcArbitrumOnly": "Você só pode resgatar USDC na Arbitrum para usuários da Peanut Wallet." + }, + "addressCompatible": { + "title": "Este endereço é compatível?", + "line1": "Resgate apenas para um endereço que aceite a rede e o token selecionados.", + "line2": "Transferências incorretas podem ser perdidas. Se tiver dúvida, não continue.", + "claimToPeanut": "Na dúvida? Resgate na Peanut" + }, + "guestVerification": { + "otherMethod": "Resgatar com outro método", + "description": "Quem enviou não está verificado para este método. Você vai precisar criar uma conta, verificar sua identidade e então seus fundos serão depositados no seu banco." + }, + "actions": { + "claimOn": "Resgatar no", + "continueWith": "Continuar com", + "devconnectApp": "app da Devconnect", + "devconnectLogoAlt": "Logo da Devconnect", + "starAlt": "estrela", + "invitedBy": "{username} te convidou, você tem acesso antecipado!", + "requiresVerification": "EXIGE VERIFICAÇÃO" + }, + "minAmount": { + "title": "Valor mínimo", + "thisPaymentMethod": "este método de pagamento", + "description": "O valor mínimo para {method} é ${amount}. Tente outro método." + }, + "confirm": { + "minReceived": "Mínimo a receber", + "slippageInfo": "Esta transação pode ter slippage por causa da conversão de tokens ou da ponte entre redes.", + "tokenAndNetwork": "Token e rede", + "tokenAlt": "token", + "chainAlt": "rede", + "tokenOnChain": "{token} na {chain}", + "maxNetworkFee": "Taxa máxima de rede", + "peanutFee": "Taxa do Peanut", + "sponsoredByPeanut": "Patrocinada pelo Peanut!" + }, + "success": { + "peanutMascotAlt": "Mascote do Peanut", + "toAccount": "para {account}", + "toYourBankAccount": "para sua conta bancária", + "fromSender": "de {sender}", + "youWillReceive": "Você vai receber", + "youClaimed": "Você resgatou", + "devconnectReturnHint": "Toque no botão X no canto superior esquerdo para voltar ao app da Devconnect" + }, + "bank": { + "selectCountry": "Selecione o país da sua conta bancária", + "fullName": "Nome completo", + "accountNumber": "Número da conta", + "routingNumber": "Número de routing", + "countryNotSelected": "País não selecionado", + "processAccountFailed": "Não foi possível processar a conta bancária. Tente de novo." + }, + "manteca": { + "youWillReceive": "Você vai receber", + "receiveIn": "Receber no {method}", + "enterAccountDetails": "Digite os dados da conta", + "ownAccountOnly": "Você só pode sacar para contas no seu nome.", + "destinationAddress": "Endereço de destino", + "exchangeRate": "Taxa de câmbio", + "sponsoredByPeanut": "Patrocinada pelo Peanut", + "errors": { + "missingTxHash": "Falha no resgate: hash da transação ausente.", + "withdrawalProcessing": "Saque realizado! Seus fundos estão sendo processados. Se a transação não aparecer no seu histórico em 5 minutos, fale com o suporte.", + "generic": "Algo deu errado. Fale com o suporte" + } + } + }, "qrPay": { "youArePaying": "Você está pagando", "paymentMethodAlt": "Método de pagamento", From 2e24a6b78cb5841308aa8bf44c345b7d8b90e8d8 Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Thu, 16 Jul 2026 15:47:17 +0100 Subject: [PATCH 15/44] fix(claim): show real copy for IBAN cashout limit errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The min/max cashout branches assigned the placeholder slugs 'offramp_lt_minimum'/'offramp_mt_maximum', which ErrorAlert rendered verbatim — users saw the raw slug. Assign real messages with the limit formatted as currency. Also return on the over-maximum branch: it set the error and then kept going, fetching a route and letting the flow continue past the limit, unlike the under-minimum branch. --- src/components/Claim/Link/Initial.view.tsx | 9 ++++++--- src/i18n/app/messages/en.json | 4 +++- src/i18n/app/messages/es-419.json | 4 +++- src/i18n/app/messages/pt-BR.json | 4 +++- 4 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/components/Claim/Link/Initial.view.tsx b/src/components/Claim/Link/Initial.view.tsx index b8da2e7ef5..c97139d8b6 100644 --- a/src/components/Claim/Link/Initial.view.tsx +++ b/src/components/Claim/Link/Initial.view.tsx @@ -52,10 +52,11 @@ import { PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN } from '@/constants/zerodev.co import { ROUTE_NOT_FOUND_ERROR } from '@/constants/general.consts' import posthog from 'posthog-js' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' -import { useTranslations } from 'next-intl' +import { useFormatter, useTranslations } from 'next-intl' export const InitialClaimLinkView = (props: IClaimScreenProps) => { const t = useTranslations('claim') + const format = useFormatter() const tCommon = useTranslations('common') const tNav = useTranslations('navigation') // get campaign tag from claim link url @@ -474,17 +475,19 @@ export const InitialClaimLinkView = (props: IClaimScreenProps) => { if (tokenPrice) { const cashoutUSDAmount = Number(formatUnits(claimLinkData.amount, claimLinkData.tokenDecimals)) * tokenPrice + const usd = (amount: number) => format.number(amount, { style: 'currency', currency: 'USD' }) if (cashoutUSDAmount < MIN_CASHOUT_LIMIT) { setErrorState({ showError: true, - errorMessage: 'offramp_lt_minimum', + errorMessage: t('errors.belowMinimum', { amount: usd(MIN_CASHOUT_LIMIT) }), }) return } else if (cashoutUSDAmount > MAX_CASHOUT_LIMIT) { setErrorState({ showError: true, - errorMessage: 'offramp_mt_maximum', + errorMessage: t('errors.aboveMaximum', { amount: usd(MAX_CASHOUT_LIMIT) }), }) + return } } diff --git a/src/i18n/app/messages/en.json b/src/i18n/app/messages/en.json index b6b535b231..1dba10a9f0 100644 --- a/src/i18n/app/messages/en.json +++ b/src/i18n/app/messages/en.json @@ -720,7 +720,9 @@ "missingInviter": "Unable to accept invite: missing inviter. Please contact support.", "generic": "Something went wrong. Please try again or contact support.", "offrampUnavailable": "Offramp unavailable", - "bankClaimUnavailable": "You can not claim this link to your bank account." + "bankClaimUnavailable": "You can not claim this link to your bank account.", + "belowMinimum": "This link is below the {amount} minimum for bank payouts.", + "aboveMaximum": "This link is above the {amount} maximum for bank payouts." }, "claimed": { "title": "Link no longer available", diff --git a/src/i18n/app/messages/es-419.json b/src/i18n/app/messages/es-419.json index 891bcaf2f9..063a8a87af 100644 --- a/src/i18n/app/messages/es-419.json +++ b/src/i18n/app/messages/es-419.json @@ -720,7 +720,9 @@ "missingInviter": "No se pudo aceptar la invitación: falta quien te invitó. Contacta a soporte.", "generic": "Algo salió mal. Inténtalo de nuevo o contacta a soporte.", "offrampUnavailable": "Retiro no disponible", - "bankClaimUnavailable": "No puedes reclamar este enlace a tu cuenta bancaria." + "bankClaimUnavailable": "No puedes reclamar este enlace a tu cuenta bancaria.", + "belowMinimum": "Este enlace está por debajo del mínimo de {amount} para retiros bancarios.", + "aboveMaximum": "Este enlace supera el máximo de {amount} para retiros bancarios." }, "claimed": { "title": "El enlace ya no está disponible", diff --git a/src/i18n/app/messages/pt-BR.json b/src/i18n/app/messages/pt-BR.json index a1e77a6a13..6afba9c672 100644 --- a/src/i18n/app/messages/pt-BR.json +++ b/src/i18n/app/messages/pt-BR.json @@ -720,7 +720,9 @@ "missingInviter": "Não foi possível aceitar o convite: falta quem convidou. Fale com o suporte.", "generic": "Algo deu errado. Tente de novo ou fale com o suporte.", "offrampUnavailable": "Saque indisponível", - "bankClaimUnavailable": "Você não pode resgatar este link para sua conta bancária." + "bankClaimUnavailable": "Você não pode resgatar este link para sua conta bancária.", + "belowMinimum": "Este link está abaixo do mínimo de {amount} para saques bancários.", + "aboveMaximum": "Este link está acima do máximo de {amount} para saques bancários." }, "claimed": { "title": "O link não está mais disponível", From 3e90b886000f28eaf0918fcbedb86c6e1501951c Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Thu, 16 Jul 2026 16:42:56 +0100 Subject: [PATCH 16/44] feat(i18n): extract card strings validatePin returns reason codes instead of English copy so the util stays copy-free; CardPinSetupFlow maps them to messages. CardCountryConfirmScreen feeds the active locale into Intl.DisplayNames (was hardcoded to en), so country names follow the UI language. --- src/app/(mobile-ui)/card-recovery/page.tsx | 45 ++-- src/app/(mobile-ui)/card/limit/page.tsx | 6 +- src/app/(mobile-ui)/card/page.tsx | 31 ++- src/app/(mobile-ui)/card/physical/page.tsx | 6 +- src/app/(mobile-ui)/card/pin/page.tsx | 6 +- src/components/Card/AddCardEntryScreen.tsx | 18 +- src/components/Card/AddToWalletCarousel.tsx | 34 ++- .../Card/ApplicationStatusScreen.tsx | 50 ++-- src/components/Card/BadgeSkipCelebration.tsx | 38 +-- src/components/Card/CancelCardModal.tsx | 42 ++- .../Card/CardCountryConfirmScreen.tsx | 34 ++- .../Card/CardEligibilityCheckScreen.tsx | 12 +- src/components/Card/CardFace.tsx | 20 +- src/components/Card/CardLimitEditModal.tsx | 10 +- src/components/Card/CardLimitsScreen.tsx | 21 +- src/components/Card/CardPinScreen.tsx | 24 +- src/components/Card/CardPinSetupFlow.tsx | 45 ++-- src/components/Card/CardRejectionScreen.tsx | 27 +- src/components/Card/CardTermsScreen.tsx | 96 +++---- src/components/Card/CardUnlockDrawer.tsx | 6 +- src/components/Card/CardUnlockHistoryItem.tsx | 30 +-- src/components/Card/LockCardModal.tsx | 57 ++-- src/components/Card/PhysicalCardScreen.tsx | 18 +- src/components/Card/PinInput.tsx | 4 +- src/components/Card/SlideToAction.tsx | 4 +- src/components/Card/YourCardScreen.tsx | 37 ++- .../ApplicationStatusScreen.test.tsx | 15 +- .../CardCountryConfirmScreen.test.tsx | 11 +- .../Card/__tests__/CardFace.test.tsx | 11 +- src/components/Card/pin.utils.ts | 11 +- .../Card/share-asset/ShareAssetActions.tsx | 10 +- src/i18n/app/messages/en.json | 253 +++++++++++++++++- src/i18n/app/messages/es-419.json | 253 +++++++++++++++++- src/i18n/app/messages/pt-BR.json | 253 +++++++++++++++++- 34 files changed, 1165 insertions(+), 373 deletions(-) diff --git a/src/app/(mobile-ui)/card-recovery/page.tsx b/src/app/(mobile-ui)/card-recovery/page.tsx index 0676a690e3..bb511f61c8 100644 --- a/src/app/(mobile-ui)/card-recovery/page.tsx +++ b/src/app/(mobile-ui)/card-recovery/page.tsx @@ -1,6 +1,7 @@ 'use client' import { useCallback, useEffect, useState } from 'react' +import { useTranslations } from 'next-intl' import type { Hex } from 'viem' import { Button } from '@/components/0_Bruddle/Button' import { Card } from '@/components/0_Bruddle/Card' @@ -36,6 +37,7 @@ type Step = 'preview' | 'confirm' | 'signing' | 'submitting' | 'done' * requires the user's passkey. */ export default function CardRecoveryPage() { + const t = useTranslations('card.recovery') const onBack = useSafeBack('/home') const { getClientForChain } = useKernelClient() @@ -55,13 +57,13 @@ export default function CardRecoveryPage() { const data = await rainApi.getRecoverFundsPreview() if (!cancelled) setPreview(data) } catch (e) { - if (!cancelled) setError((e as Error).message || 'Could not load recovery preview') + if (!cancelled) setError((e as Error).message || t('previewFailed')) } })() return () => { cancelled = true } - }, []) + }, [t]) const handleRecover = useCallback(async () => { setError(null) @@ -98,25 +100,24 @@ export default function CardRecoveryPage() { setTxHash(hash as Hex) setStep('done') } catch (e) { - setError((e as Error).message || 'Recovery failed — please try again') + setError((e as Error).message || t('failed')) setStep('preview') } - }, [getClientForChain]) + }, [getClientForChain, t]) if (!preview && !error) return return (
- +
{error && } {step === 'done' && txHash ? ( -

Funds sent to your wallet.

+

{t('doneTitle')}

- ${formatCents(recoveredCents ?? preview!.amountCents)} USDC has been returned to your peanut - wallet. + {t('doneBody', { amount: `$${formatCents(recoveredCents ?? preview!.amountCents)}` })}

- View transaction + {t('viewTransaction')}
) : ( @@ -132,22 +133,18 @@ export default function CardRecoveryPage() { <>

- {preview.hasRecoverableCard ? 'Recover your card collateral' : 'No card on file'} + {preview.hasRecoverableCard ? t('title') : t('noCardOnFile')}

-

- This pulls every USDC currently held in your card collateral contract back to your - peanut wallet. Auto-balance is turned off as part of recovery so the rebalancer - can't top up between now and the transfer. -

- - - +

{t('description')}

+ + + {BigInt(preview.dustWei) > 0n && ( - + )}
@@ -165,10 +162,10 @@ export default function CardRecoveryPage() { onClick={handleRecover} > {step === 'signing' - ? 'Sign with passkey…' + ? t('signWithPasskey') : step === 'submitting' - ? 'Submitting…' - : 'Recover funds'} + ? t('submitting') + : t('cta')} ) diff --git a/src/app/(mobile-ui)/card/limit/page.tsx b/src/app/(mobile-ui)/card/limit/page.tsx index 53bd5e67b3..7db69fc6d4 100644 --- a/src/app/(mobile-ui)/card/limit/page.tsx +++ b/src/app/(mobile-ui)/card/limit/page.tsx @@ -1,5 +1,6 @@ 'use client' import { type FC } from 'react' +import { useTranslations } from 'next-intl' import PageContainer from '@/components/0_Bruddle/PageContainer' import Loading from '@/components/Global/Loading' import { Button } from '@/components/0_Bruddle/Button' @@ -9,6 +10,7 @@ import CardLimitsScreen from '@/components/Card/CardLimitsScreen' import { useSafeBack } from '@/hooks/useSafeBack' const CardLimitPage: FC = () => { + const t = useTranslations('card') const { overview, isLoading } = useRainCardOverview() const card = findActiveCard(overview) const onBack = useSafeBack('/card') @@ -27,9 +29,9 @@ const CardLimitPage: FC = () => { return (
-

No active card to manage limits for.

+

{t('limits.noActiveCard')}

diff --git a/src/app/(mobile-ui)/card/page.tsx b/src/app/(mobile-ui)/card/page.tsx index 17e3062aa2..6baa0be4d3 100644 --- a/src/app/(mobile-ui)/card/page.tsx +++ b/src/app/(mobile-ui)/card/page.tsx @@ -2,6 +2,7 @@ import { type FC, useCallback, useEffect, useRef, useState } from 'react' import { notFound } from 'next/navigation' import { useQuery, useQueryClient } from '@tanstack/react-query' +import { useTranslations } from 'next-intl' import posthog from 'posthog-js' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' import { cardApi, type CardInfoResponse } from '@/services/card' @@ -51,6 +52,8 @@ function markSkipCelebrationSeen(): void { // they don't re-see the gate after celebration / add-card transitions. const CardPage: FC = () => { + const t = useTranslations('card') + const tCommon = useTranslations('common') const queryClient = useQueryClient() const { user, fetchUser } = useAuth() const userId = user?.user?.userId @@ -272,13 +275,13 @@ const CardPage: FC = () => { } advanceFromApplyResponse(res) } catch (e) { - const message = e instanceof Error ? e.message : 'Failed to confirm country' + const message = e instanceof Error ? e.message : t('page.confirmCountryFailed') console.error('[card apply] country confirm error:', e) setApplyError(message) posthog.capture(ANALYTICS_EVENTS.CARD_APPLY_FAILED, { error_message: message }) } }, - [advanceFromApplyResponse] + [advanceFromApplyResponse, t] ) const handleApply = useCallback( @@ -298,13 +301,13 @@ const CardPage: FC = () => { } advanceFromApplyResponse(res) } catch (e) { - const message = e instanceof Error ? e.message : 'Failed to apply for card' + const message = e instanceof Error ? e.message : t('page.applyFailed') console.error('[card apply] error:', e) setApplyError(message) posthog.capture(ANALYTICS_EVENTS.CARD_APPLY_FAILED, { error_message: message }) } }, - [advanceFromApplyResponse] + [advanceFromApplyResponse, t] ) const handleAcceptTerms = useCallback(async () => { @@ -340,18 +343,14 @@ const CardPage: FC = () => { // the backend — no card should be created without consent. setIsIssuing(false) setPendingTerms({ isUsResident: isUsResidentSnapshot }) - setApplyError( - tap.error.kind === 'user-cancelled' - ? 'Setup cancelled — please try again.' - : 'Could not complete setup — please try again.' - ) + setApplyError(tap.error.kind === 'user-cancelled' ? t('page.setupCancelled') : t('page.setupFailed')) return } await handleApply(true, tap.serialized) } finally { setIsIssuing(false) } - }, [handleApply, overview, pendingTerms, serializeGrant]) + }, [handleApply, overview, pendingTerms, serializeGrant, t]) // Distinguishes "user finished the applicant action" from "user closed the // modal without finishing" — without this both paths would fire @@ -394,7 +393,7 @@ const CardPage: FC = () => { }) if (controller.signal.aborted) return if (readyResult === false) { - setApplyError('Verification is taking longer than expected. Please try again.') + setApplyError(t('page.verificationSlow')) return } @@ -410,21 +409,21 @@ const CardPage: FC = () => { }) if (controller.signal.aborted) return if (!res) { - setApplyError('Verification is taking longer than expected. Please try again.') + setApplyError(t('page.verificationSlow')) return } posthog.capture(ANALYTICS_EVENTS.CARD_APPLY_SUCCEEDED, { outcome: res.status }) advanceFromApplyResponse(res) } catch (e) { if (controller.signal.aborted) return - const message = e instanceof Error ? e.message : 'Failed to apply for card' + const message = e instanceof Error ? e.message : t('page.applyFailed') console.error('[card apply] post-sumsub poll error:', e) setApplyError(message) posthog.capture(ANALYTICS_EVENTS.CARD_APPLY_FAILED, { error_message: message }) } finally { if (!controller.signal.aborted) setIsIssuing(false) } - }, [advanceFromApplyResponse]) + }, [advanceFromApplyResponse, t]) const handleSumsubClose = useCallback(() => { if (!sumsubCompletedRef.current) { @@ -473,9 +472,9 @@ const CardPage: FC = () => { return (
-

Failed to load card info. Please try again.

+

{t('page.loadFailed')}

diff --git a/src/app/(mobile-ui)/card/physical/page.tsx b/src/app/(mobile-ui)/card/physical/page.tsx index 08e46571b4..3396a98c1a 100644 --- a/src/app/(mobile-ui)/card/physical/page.tsx +++ b/src/app/(mobile-ui)/card/physical/page.tsx @@ -1,5 +1,6 @@ 'use client' import { type FC } from 'react' +import { useTranslations } from 'next-intl' import PageContainer from '@/components/0_Bruddle/PageContainer' import Loading from '@/components/Global/Loading' import { Button } from '@/components/0_Bruddle/Button' @@ -9,6 +10,7 @@ import PhysicalCardScreen from '@/components/Card/PhysicalCardScreen' import { useSafeBack } from '@/hooks/useSafeBack' const PhysicalCardPage: FC = () => { + const t = useTranslations('card') const { overview, isLoading } = useRainCardOverview() const card = findActiveCard(overview) const onBack = useSafeBack('/card') @@ -27,9 +29,9 @@ const PhysicalCardPage: FC = () => { return (
-

No active card.

+

{t('noActiveCard')}

diff --git a/src/app/(mobile-ui)/card/pin/page.tsx b/src/app/(mobile-ui)/card/pin/page.tsx index ef48352943..fb6ffdef19 100644 --- a/src/app/(mobile-ui)/card/pin/page.tsx +++ b/src/app/(mobile-ui)/card/pin/page.tsx @@ -1,5 +1,6 @@ 'use client' import { type FC } from 'react' +import { useTranslations } from 'next-intl' import PageContainer from '@/components/0_Bruddle/PageContainer' import Loading from '@/components/Global/Loading' import { Button } from '@/components/0_Bruddle/Button' @@ -9,6 +10,7 @@ import CardPinScreen from '@/components/Card/CardPinScreen' import { useSafeBack } from '@/hooks/useSafeBack' const CardPinPage: FC = () => { + const t = useTranslations('card') const { overview, isLoading } = useRainCardOverview() const card = findActiveCard(overview) const onBack = useSafeBack('/card') @@ -27,9 +29,9 @@ const CardPinPage: FC = () => { return (
-

No active card.

+

{t('noActiveCard')}

diff --git a/src/components/Card/AddCardEntryScreen.tsx b/src/components/Card/AddCardEntryScreen.tsx index 0a075fc5d3..fd47bf06e7 100644 --- a/src/components/Card/AddCardEntryScreen.tsx +++ b/src/components/Card/AddCardEntryScreen.tsx @@ -1,5 +1,6 @@ 'use client' import { type FC, useState } from 'react' +import { useTranslations } from 'next-intl' import { Button } from '@/components/0_Bruddle/Button' import ErrorAlert from '@/components/Global/ErrorAlert' import NavHeader from '@/components/Global/NavHeader' @@ -12,9 +13,10 @@ interface Props { applyError?: string | null } -const FEATURES = ['One unified balance', 'Under your control', 'Works online & contactless'] as const +const FEATURE_KEYS = ['entry.featureBalance', 'entry.featureControl', 'entry.featureOnline'] as const const AddCardEntryScreen: FC = ({ onApply, onPrev, applyError }) => { + const t = useTranslations('card') const [isApplying, setIsApplying] = useState(false) const handleClick = async () => { @@ -27,7 +29,7 @@ const AddCardEntryScreen: FC = ({ onApply, onPrev, applyError }) => { } return (
- + {/* Pixelated card — keeps the anticipation/tease intact through the "Get your card" CTA, matching the /shhhhh + eligibility @@ -36,15 +38,15 @@ const AddCardEntryScreen: FC = ({ onApply, onPrev, applyError }) => {
-

Spend anywhere Visa is accepted

-

Use your balance at 150M+ merchants. Online, contactless, yours.

+

{t('entry.title')}

+

{t('entry.description')}

    - {FEATURES.map((feature) => ( -
  • + {FEATURE_KEYS.map((featureKey) => ( +
  • - {feature} + {t(featureKey)}
  • ))}
@@ -64,7 +66,7 @@ const AddCardEntryScreen: FC = ({ onApply, onPrev, applyError }) => { shadowSize="4" className="w-full" > - Get your card + {t('entry.cta')}
) diff --git a/src/components/Card/AddToWalletCarousel.tsx b/src/components/Card/AddToWalletCarousel.tsx index 988d3883ce..9c4c712161 100644 --- a/src/components/Card/AddToWalletCarousel.tsx +++ b/src/components/Card/AddToWalletCarousel.tsx @@ -1,6 +1,7 @@ 'use client' import { type FC, type PointerEvent as ReactPointerEvent, useRef, useState } from 'react' import Image, { type StaticImageData } from 'next/image' +import { useTranslations } from 'next-intl' import { twMerge } from 'tailwind-merge' import NavHeader from '@/components/Global/NavHeader' import { Button } from '@/components/0_Bruddle/Button' @@ -11,23 +12,25 @@ import { useWalletPlatform, type WalletPlatform } from '@/hooks/useWalletPlatfor // feel jumpy on desktop trackpads; larger values miss short mobile flicks. const SWIPE_THRESHOLD_PX = 50 +type StepTitleKey = `addToWallet.${'ios' | 'android'}Step${1 | 2 | 3 | 4}` + interface Step { - title: string + titleKey: StepTitleKey image: StaticImageData } const STEPS_BY_PLATFORM: Record, Step[]> = { ios: [ - { title: 'Open Wallet App', image: APPLE_WALLET_STEPS[0] }, - { title: 'Tap + in the top right', image: APPLE_WALLET_STEPS[1] }, - { title: 'Tap Debit or Credit Card', image: APPLE_WALLET_STEPS[2] }, - { title: 'Follow onscreen steps', image: APPLE_WALLET_STEPS[3] }, + { titleKey: 'addToWallet.iosStep1', image: APPLE_WALLET_STEPS[0] }, + { titleKey: 'addToWallet.iosStep2', image: APPLE_WALLET_STEPS[1] }, + { titleKey: 'addToWallet.iosStep3', image: APPLE_WALLET_STEPS[2] }, + { titleKey: 'addToWallet.iosStep4', image: APPLE_WALLET_STEPS[3] }, ], android: [ - { title: 'Open Wallet App', image: GOOGLE_WALLET_STEPS[0] }, - { title: 'Tap + add to wallet', image: GOOGLE_WALLET_STEPS[1] }, - { title: 'Tap Payment card', image: GOOGLE_WALLET_STEPS[2] }, - { title: 'Follow onscreen steps', image: GOOGLE_WALLET_STEPS[3] }, + { titleKey: 'addToWallet.androidStep1', image: GOOGLE_WALLET_STEPS[0] }, + { titleKey: 'addToWallet.androidStep2', image: GOOGLE_WALLET_STEPS[1] }, + { titleKey: 'addToWallet.androidStep3', image: GOOGLE_WALLET_STEPS[2] }, + { titleKey: 'addToWallet.androidStep4', image: GOOGLE_WALLET_STEPS[3] }, ], } @@ -37,8 +40,10 @@ interface Props { } const AddToWalletCarousel: FC = ({ onDone, onPrev }) => { + const t = useTranslations('card') + const tCommon = useTranslations('common') const platform = useWalletPlatform() - const platformLabel = platform === 'android' ? 'Google Wallet' : 'Apple Wallet' + const platformLabel = platform === 'android' ? t('addToWallet.googleWallet') : t('addToWallet.appleWallet') const steps: Step[] = STEPS_BY_PLATFORM[platform === 'android' ? 'android' : 'ios'] const [index, setIndex] = useState(0) const isLast = index === steps.length - 1 @@ -93,9 +98,12 @@ const AddToWalletCarousel: FC = ({ onDone, onPrev }) => {
-
{step.title}
+
{t(step.titleKey)}
-
+
{steps.map((_, i) => ( = ({ onDone, onPrev }) => {
diff --git a/src/components/Card/ApplicationStatusScreen.tsx b/src/components/Card/ApplicationStatusScreen.tsx index ced7d5061e..f3d66c358c 100644 --- a/src/components/Card/ApplicationStatusScreen.tsx +++ b/src/components/Card/ApplicationStatusScreen.tsx @@ -1,6 +1,7 @@ 'use client' import type { FC } from 'react' import Image from 'next/image' +import { useTranslations } from 'next-intl' import { PeanutCrying } from '@/assets/mascot' import NavHeader from '@/components/Global/NavHeader' import Loading from '@/components/Global/Loading' @@ -17,47 +18,34 @@ interface Props { onPrev?: () => void } -const COPY: Record = { - pending: { - title: 'Setting up your card…', - body: 'Hang tight while we finish setting up your card. This usually takes a few seconds.', - }, - 'manual-review': { - title: 'Manual review needed', - body: "We need to do a manual review of your submission. This usually takes 1-2 days and we'll let you know when it's ready.", - }, - 'requires-info': { - title: 'We need more information', - body: 'Our team will help you finish your card application — message support to continue.', - }, - 'requires-support': { - title: 'Something went wrong on our side', - body: "We hit a snag while processing your card application. Our team needs to take a look — message support and we'll get you sorted.", - }, - rejected: { - // The specific reason (when known) renders above via `reasonMessage`; - // this body stays reassuring — a declined card doesn't touch the rest - // of the account, so point the user back to what still works. - title: "We couldn't issue you a card", - body: 'You can still use Peanut freely to deposit, withdraw, and pay with crypto.', - }, -} +// The rejected variant's `reasonMessage` (when known) renders above its body; +// the body itself stays reassuring — a declined card doesn't touch the rest of +// the account, so it points the user back to what still works. +const COPY_KEYS = { + pending: { title: 'status.pendingTitle', body: 'status.pendingBody' }, + 'manual-review': { title: 'status.manualReviewTitle', body: 'status.manualReviewBody' }, + 'requires-info': { title: 'status.requiresInfoTitle', body: 'status.requiresInfoBody' }, + 'requires-support': { title: 'status.requiresSupportTitle', body: 'status.requiresSupportBody' }, + rejected: { title: 'status.rejectedTitle', body: 'status.rejectedBody' }, +} as const satisfies Record /** Variants where support is the only path forward — these render the CTA. */ const SUPPORT_VARIANTS: ReadonlySet = new Set(['requires-info', 'requires-support', 'rejected']) const ApplicationStatusScreen: FC = ({ variant, reasonMessage, onContactSupport, onPrev }) => { - const copy = COPY[variant] + const t = useTranslations('card') + const tCommon = useTranslations('common') + const copyKeys = COPY_KEYS[variant] return (
- +
{variant === 'pending' && } {(variant === 'rejected' || variant === 'requires-support') && ( Peanutman crying 😭 = ({ variant, reasonMessage, onContactS /> )}
-

{copy.title}

+

{t(copyKeys.title)}

{reasonMessage &&

{reasonMessage}

} -

{copy.body}

+

{t(copyKeys.body)}

{SUPPORT_VARIANTS.has(variant) && onContactSupport && ( )}
diff --git a/src/components/Card/BadgeSkipCelebration.tsx b/src/components/Card/BadgeSkipCelebration.tsx index 1156feb2e7..2ff9663a76 100644 --- a/src/components/Card/BadgeSkipCelebration.tsx +++ b/src/components/Card/BadgeSkipCelebration.tsx @@ -18,6 +18,7 @@ import { type FC, useEffect, useRef, useState } from 'react' import { AnimatePresence, motion } from 'framer-motion' +import { useTranslations } from 'next-intl' import { Button } from '@/components/0_Bruddle/Button' import { Checkbox } from '@/components/0_Bruddle/Checkbox' import NavHeader from '@/components/Global/NavHeader' @@ -46,19 +47,16 @@ interface Props { onContinue: () => void } -const SKIP_BADGE_HEADLINES: Record = { - OG_2025_10_12: 'OG access. You skipped the line.', - DEVCONNECT_BA_2025: 'Devconnect crew. You skipped the line.', - ARBIVERSE_DEVCONNECT_BA_2025: 'Arbiverse. You skipped the line.', -} - -// Fallback when the user has card access but no skip badge (e.g. admin -// grant, waitlist roll-out). Same celebration moment, different framing. -const NO_BADGE_HEADLINE = "You're in." +const SKIP_BADGE_HEADLINE_KEYS = { + OG_2025_10_12: 'celebration.headlineOg', + DEVCONNECT_BA_2025: 'celebration.headlineDevconnect', + ARBIVERSE_DEVCONNECT_BA_2025: 'celebration.headlineArbiverse', +} as const type Phase = 'looking-up' | 'shaking' | 'revealed' const BadgeSkipCelebration: FC = ({ badgeCode, username, badges, stats, tier, pointsBalance, onContinue }) => { + const t = useTranslations('card') const [phase, setPhase] = useState('looking-up') const [hideUsername, setHideUsername] = useState(false) // Gate the Share/Save buttons until the card face's async hand @@ -67,11 +65,13 @@ const BadgeSkipCelebration: FC = ({ badgeCode, username, badges, stats, t const { triggerHaptic } = useHaptic() const captureRef = useRef(null) const hasBadge = !!badgeCode - const headline = - (badgeCode && SKIP_BADGE_HEADLINES[badgeCode]) || (hasBadge ? 'You skipped the line.' : NO_BADGE_HEADLINE) - const subline = hasBadge - ? 'You hold a badge that skips the closed-beta queue. Card’s yours.' - : 'Welcome to the closed beta. Card’s yours.' + // Falls back to the generic skip headline for an unmapped badge, and to the + // no-badge framing for card access granted without one (admin grant, roll-out). + const badgeHeadlineKey = badgeCode + ? SKIP_BADGE_HEADLINE_KEYS[badgeCode as keyof typeof SKIP_BADGE_HEADLINE_KEYS] + : undefined + const headline = t(badgeHeadlineKey ?? (hasBadge ? 'celebration.headlineSkip' : 'celebration.headlineNoBadge')) + const subline = t(hasBadge ? 'celebration.sublineBadge' : 'celebration.sublineNoBadge') useEffect(() => { // Only fire the skipped-BY-BADGE event when the user actually has one. @@ -99,7 +99,7 @@ const BadgeSkipCelebration: FC = ({ badgeCode, username, badges, stats, t return (
- + {phase === 'looking-up' ? ( @@ -111,8 +111,8 @@ const BadgeSkipCelebration: FC = ({ badgeCode, username, badges, stats, t exit={{ opacity: 0 }} transition={{ duration: 0.2 }} > -

Looking you up…

-

Checking your badges.

+

{t('celebration.lookingUpTitle')}

+

{t('celebration.lookingUpBody')}

) : ( = ({ badgeCode, username, badges, stats, t {/* Anti-dox toggle — hides the peanut.me/ pill on the asset */} setHideUsername(e.target.checked)} />
diff --git a/src/components/Card/CancelCardModal.tsx b/src/components/Card/CancelCardModal.tsx index 57602ce962..0e40d2d94f 100644 --- a/src/components/Card/CancelCardModal.tsx +++ b/src/components/Card/CancelCardModal.tsx @@ -1,6 +1,7 @@ 'use client' import { type FC, useEffect, useState } from 'react' import { useQueryClient } from '@tanstack/react-query' +import { useTranslations } from 'next-intl' import posthog from 'posthog-js' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' import Modal from '@/components/Global/Modal' @@ -23,6 +24,8 @@ interface Props { } const CancelCardModal: FC = ({ cardId, isOpen, onClose }) => { + const t = useTranslations('card') + const tCommon = useTranslations('common') const [phase, setPhase] = useState('confirm') const [feedback, setFeedback] = useState('') const [error, setError] = useState(null) @@ -64,7 +67,7 @@ const CancelCardModal: FC = ({ cardId, isOpen, onClose }) => { let verifiedWithdrawal: import('@/hooks/wallet/useSignSpendBundle').SignedRainWithdrawal | undefined if (spendingPowerUnits > 0n) { if (!smartWalletAddress) { - throw new Error('Wallet not ready — please retry in a moment') + throw new Error(t('errors.walletNotReady')) } // Force collateral-only routing — same pattern as LockCardModal. const artifact = await signSpend({ @@ -74,7 +77,7 @@ const CancelCardModal: FC = ({ cardId, isOpen, onClose }) => { kind: 'CRYPTO_WITHDRAW', }) if (artifact.strategy !== 'collateral-only') { - throw new Error('Unexpected withdrawal strategy — please contact support') + throw new Error(t('errors.unexpectedStrategy')) } verifiedWithdrawal = artifact.rainWithdrawal } @@ -83,11 +86,11 @@ const CancelCardModal: FC = ({ cardId, isOpen, onClose }) => { setCanceled(true) setPhase('feedback') } catch (e) { - let message = e instanceof Error ? e.message : 'Failed to cancel card' + let message = e instanceof Error ? e.message : t('cancel.failed') if (e instanceof InsufficientSpendableError) { - message = 'Could not return your card balance. Please try again or contact support.' + message = t('errors.balanceReturnFailed') } else if (e instanceof SessionKeyGrantRequiredError) { - message = 'Card authorization failed. Please try again or contact support.' + message = t('errors.authorizationFailed') } setError(message) posthog.capture(ANALYTICS_EVENTS.CARD_CANCEL_FAILED, { error_message: message }) @@ -139,14 +142,11 @@ const CancelCardModal: FC = ({ cardId, isOpen, onClose }) => {
-
Cancel card?
-

- This will permanently cancel your card. It won't work for any transactions. You will - have to request a new one if you change your mind. -

+
{t('cancel.title')}
+

{t('cancel.body')}

{error &&

{error}

} @@ -156,19 +156,17 @@ const CancelCardModal: FC = ({ cardId, isOpen, onClose }) => {
-
Card canceled
-

- If you change your mind later, you can request another card. -

+
{t('cancel.canceledTitle')}
+

{t('cancel.canceledBody')}