fix(i18n): localize backend errors, drop locale-unsafe comparisons - #2554
fix(i18n): localize backend errors, drop locale-unsafe comparisons#2554innolope-dev wants to merge 2 commits into
Conversation
Backend errors were reaching non-English users in English, and one branch
compared a translated string against an English literal.
Wire codes over prose. `friendlyError` now checks a backend `code` before any
message matcher, mapping it through an allow-list to a display code. The
allow-list is load-bearing and must stay one: ethers sets `.code` to
NETWORK_ERROR/CALL_EXCEPTION and EIP-1193 wallets use numeric codes, so mapping
an arbitrary code straight to a translation key would turn every one of them
into a missing-message error. Anything unlisted falls through to today's
matchers, which is also what makes this safe to ship before the API deploys the
contract — pre-contract errors simply carry no code.
rain.ts was already reading `code: 'STALE_CARD_APPROVAL'` off the 409 and then
discarding it in favour of the English string; the three typed Rain errors now
carry their discriminant, and the generic throws become ApiError so a backend
code survives. So the Rain paths localize without waiting on the backend.
The cooldown is now `errors.rainCooldownRetry` with an ICU plural on retryAfterSec
(rounded up, floored at 1 — a 20s wait must not read "0 minutes"), plus a
countdown-less variant for when the backend sends no number, mirroring a
distinction rain.ts already made. This needed a third `FriendlyError` variant
rather than another member of `FriendlyErrorCode`: next-intl types t()'s values
as the INTERSECTION of every union member's ICU args, so a parameterized key in
the param-less union makes `{minutes}` required at every call site.
Also fixed:
- ServiceUnavailableError (fetchWithSentry's rethrow, real cause on `.cause`,
which the classifier does not walk) was collapsing every server fetch timeout
to the generic support fallback instead of the retryable code.
- The SDK's transactionHash fetch failure is static English, so it gets a key
instead of passing through.
- withdraw/[country]/bank compared the mapper's OUTPUT to "Something failed.
Please try again." — a literal in no catalog, so dead in every locale,
English included. Replaced with an errorAlreadyDisplayed flag: deleting the
branch outright would have regressed, because two pre-throw setError calls
set already-correct copy that the else-branch would then clobber.
- The cooldown pill rendered a hardcoded "Card cool-down"; src/context is
outside the jsx-no-literals globs, which is how it shipped.
- regions.utils compares an internal region name; hoisted to a named constant
with a note that it must move to a stable key if names get localized.
The liquidity and rain-collateral passthroughs stay as the pre-contract
fallback. The liquidity text does not originate in peanut-api-ts at all (it is
SDK/routing-layer), so no backend code can address it.
…Error
Both screens re-implemented friendlyError's precedence by hand, so every wire
code added in the previous commit would have silently failed to reach them —
and their inline `rainCollateralErrorMessage` call was a second passthrough site
that would have kept showing untranslated backend English.
Deliberately not a straight swap. Three behaviours are preserved:
- SessionKeyGrantRequiredError keeps its per-flow copy. The two screens
intentionally differ here (cardAuthNeeded vs cardAuthFailed), so this stays
an explicit branch ahead of the classifier rather than a shared code.
- The `.includes('not allowed')` match is looser than the classifier's
'not allowed by the user agent'. Kept as-is so this screen's matching does
not narrow.
- The fallback stays flow-specific: falling through to genericSupport
("contact support") would have been a regression from signFailed
("couldn't sign the transaction") on what is always a signing failure. The
Sentry captureException on that path is preserved with it.
The errorCode sentinel both screens already had is now fed from the classifier,
so downstream retry gates key off a locale-independent value.
Separate commit so it can be reverted on its own if a QA pass on the Rain paths
turns up copy regressions.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
Code-analysis diffPainscore total: 6656.05 → 6656.59 (+0.54) 🆕 New findings (58)
…and 38 more. ✅ Resolved (55)
…and 35 more. 📈 Painscore deltas (top movers)
|
🧪 UI test report — ✅ all greenSuites
📊 Coverage (unit)
⏱ 10 slowest test cases
|
…nner removal Carries the localization follow-up cherry-picked from the dev-based PRs (peanut-ui #2554 / #2555) plus the iOS-only removal of the pink beta feedback ribbon: - Backend errors now map to localized copy via a wire-code allow-list instead of being echoed in English; the Rain cooldown renders "try again in N minutes" in the user's language. - Locale-unsafe comparisons removed (a dead branch in the bank-withdraw flow that compared a translated string to an English literal). - Untranslated toasts, placeholders, aria-labels and alt text extracted. - 39 hand-rolled test intl wrappers collapsed into one shared helper. - The beta feedback ribbon is hidden on iOS native only; connectivity and maintenance banners still show, and Android/web are unchanged. App icon note: no icon change here. The BETA badge was dropped from the iOS icon in 1.0.38 and every build since (1.0.39, 1.0.40, 1.0.41) has carried the clean 1024px marketing icon.
Why
Follow-up to #2447 (next-intl, en / es-419 / pt-BR). Two gaps remained on the error path:
friendlyErrorpassed them through untranslated because it had no stable discriminant to key off.Backend companion: peanutprotocol/peanut-api-ts#1249. This PR does not depend on it — see below.
Wire codes over prose
friendlyErrornow checks a backendcodebefore any message matcher and maps it through an allow-list.The allow-list is load-bearing. ethers sets
.codetoNETWORK_ERROR/CALL_EXCEPTION, and EIP-1193 wallets use numeric codes like4001. Mapping an arbitrary.codestraight to a translation key would turn every one of those into a missing-message error. Anything unlisted falls through to today's matchers — which is also what makes this safe to merge before the API deploys the contract, since pre-contract errors simply carry no code. Both cases have regression tests.rain.tswas already readingcode: 'STALE_CARD_APPROVAL'off the 409 and then throwing it away in favour of the English string. The three typed Rain errors now carry their discriminant, so the Rain paths localize without waiting on the backend at all — the FE constructs those errors itself.The cooldown needed a type change
errors.rainCooldownRetrytakes an ICU plural onretryAfterSec(rounded up, floored at 1 — a 20s wait must not read "0 minutes"), plus a countdown-less variant for when the backend sends no number, mirroring a distinctionrain.tsalready made.This required a third
FriendlyErrorvariant rather than another member ofFriendlyErrorCode: next-intl typest()'svaluesas the intersection of every union member's ICU args, so a parameterized key inside the param-less union makes{minutes}required at every call site and breakst(result.code). There's a comment on the type explaining it for whoever adds the next one.Also fixed
ServiceUnavailableError—fetchWithSentryrethrows it with the real timeout on.cause, which the classifier doesn't walk, so every server fetch timeout was collapsing to the generic support fallback instead of the retryable code. Matched on the name we set ourselves rather than walking.causegenerically, which would have silently reclassified every wrapped error in the app.withdraw/[country]/bankcompared the mapper's output to'Something failed. Please try again.'— a literal in no catalog, so dead in every locale including English. Deleting the branch would have regressed: two pre-throwsetErrorcalls set already-correct copy (backend-authored, and the confirm-pending notice) that theelsewould then clobber. Replaced with a localerrorAlreadyDisplayedflag.Card cool-down;src/context/**is outside thejsx-no-literalsglobs, which is how it shipped.transactionHashfetch failure is static English, so it gets a key instead of passing through.regions.utilscompares an internal region name — locale-safe today, hoisted to a named constant with a note.Second commit is revertible on its own
qr-payandwithdraw/mantecare-implemented the classifier's precedence by hand, so every code above would have silently failed to reach them. Routing them throughfriendlyErroris not a straight swap — three behaviours are deliberately preserved: the per-flowSessionKeyGrantRequiredErrorcopy (the two screens intentionally differ), the looser.includes('not allowed')match, and the flow-specificsignFailedfallback with itscaptureException(falling through togenericSupportwould have been a copy regression on what is always a signing failure). Split out so it can be reverted if QA on the Rain paths disagrees.What stays a passthrough
The liquidity text — it does not originate in peanut-api-ts at all (it's SDK/routing-layer), so no backend code can address it. Verified by grep across the API repo.
Verification
pnpm typecheckclean · 174 suites / 2311 tests pass ·eslint0 errors ·prettier --check .clean7 new keys × 3 locales; catalog parity + ICU gates pass. es-419 / pt-BR copy is machine-assisted, same caveat #2447 flagged.
Not covered: no manual multi-locale walkthrough — the "try again in N minutes" rendering is unit-tested but hasn't been seen on a device.