Prod Release Sprint 154 — app localization · lifecycle emails · error codes (2026-08-06) - #2624
Prod Release Sprint 154 — app localization · lifecycle emails · error codes (2026-08-06)#2624kushagrasarathe wants to merge 223 commits into
Conversation
…the apply funnel The /card state machine ignored country eligibility: users from Rain's prohibited-issuance countries were teased with the press-and-hold "see if you qualify" gate and could run the whole KYC funnel before Rain rejected them. - computeCardState: new 'geo-blocked' state, keyed on BE's new geoProhibited field (country KNOWN and prohibited — never blocks on unknown, so pre-KYC users still enter). Scoped to users with no existing application: in-flight/rejected rails keep their truthful state and the re-issue path stays untouched, mirroring the BE gate. - ApplicationStatusScreen: 'geo-blocked' variant — regulatory dead end, no support CTA (support can't override regulation). - api.openapi.json patched surgically with just the new field (the committed copy has pre-existing drift; full re-sync left for its own PR).
…unnel /code-review findings: - A Sumsub-only user (country unknown up front, so geoProhibited couldn't block them) who completes KYC with a prohibited-country address got a generic applyError on add-card — an apply button that can never succeed. The BE now answers a typed 'geo-blocked' status; handle it in the apply/poll/refresh paths with a local flag that renders the terminal screen immediately, plus a cardInfo refetch so the state machine owns the block on later visits. - Component test pinning the geo-blocked no-support-CTA contract (support cannot override regulation; guards SUPPORT_VARIANTS drift). - Straight apostrophe in the geo-blocked body copy (matched the rest of the COPY table).
…d screen A geo-blocked user had no way to see WHICH regions are restricted: Rain's marketing-compliance rules ban country names on card-marketing surfaces (help articles included), so the full issuance denylist is published in exactly one compliant place — the legal Prohibited Activities Policy (§1 Restricted Countries). Link it from the geo-blocked screen, the one surface where a user actually needs it. Tests pin the href and that the link stays off other variants.
…zation header 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.
@capacitor/device requires the next native binary release; JS falls back to navigator.language on older binaries.
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.
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.
Replace raw <a> internal-route anchors with <Link> (client-side navigation) across LandingPage components and the dev debug page.
- 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.
/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).
The OPEN-status badge fix was picked up by 1eb7c3c ("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.
Step titles/descriptions keyed by screenId (removed from ISetupStep), all Setup views, wrapper, and (setup) pages on useTranslations. es-419 + pt-BR drafts included.
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.
ExchangeRateWidget (marketing-shared) takes an optional labels prop with English defaults; product callers pass translated labels.
Limits warning-card items now carry a kind discriminator so render sites can map them to translated copy; qr-pay uses it.
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.
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.
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.
Drop the vestigial capitalize class on the feed's type label: it existed to case raw enum values (getActionText returned the type verbatim), but the catalog now supplies cased copy, and CSS title-casing mangles multi-word labels in every locale. cardDeclineReason and the bank-account label util return codes now, with the copy resolved at the render site.
Drop the capitalize class on the badge-unlocked label for the same reason as the transaction feed: it title-cased translated copy. Remove invites.consts.ts — it held only display copy, and its last consumer now reads the setup-flow waitlist key so the two gates can't drift.
sumsub-reject-labels.consts.ts becomes a copy-free code registry; the 62 reject labels move to the kyc namespace and resolve at the render site, with unknown codes collapsing to FALLBACK as before. recover-funds no longer prints a raw loadingState or raw token amounts, and the KYC screens format dates through the active locale instead of a hardcoded en-US.
Marketing-shared components under Global/ keep their English and take copy as props instead: they render inside the app intl context, but the marketing site resolves its language from the URL, so a shared component would show the app locale on a marketing page. Fix UserCard.getTitle reading fullName/username while only depending on type, which rendered a stale name after a rename.
Extraction silently rewrote ' to the typographic form in 32 places, changing English copy that ships today (e.g. the balance-warning modal's "you're the only one who can access your funds"). Straighten them all so en matches source and reads consistently; the translated catalogs are unaffected. Also wrap the useSumsubKycFlow test in an intl provider — the hook now calls useTranslations and the suite never had one.
friendly-error.utils.tsx becomes copy-free: ErrorHandler is replaced by friendlyError(), returning a code or backend-provided text, resolved to a message by the new useFriendlyError hook. Backend copy (rain collateral, stale-card re-enable with its dynamic retry hint) passes through untranslated. Three sites compared the rendered error string against a constant to gate UI; those now compare the error CODE, so the gate survives translation.
src/features/payments was missed by every earlier pass — a lint probe caught it. Extends the existing payment namespace (no payments dupe); contributor and receipt counts use ICU plurals, the 'on <chain>' label reuses the shared tokenSelector.onChain rich-text key. Also covers the KYC status drawer, sumsub load-error, and amount-input balance label.
Scopes react/jsx-no-literals to the translated surface so new hardcoded JSX strings fail lint. Excludes tests, the /dev catalog, and marketing- shared Global components; allowlists non-copy glyphs (card masks, %, decorative emoji). Extraction stragglers the guard surfaced are also handled: the beta/demo banners and the transaction 'Enjoy Peanut!' title.
useSendMoney calls useTranslations, and it runs inside ContextProvider (via TokenContextProvider → useWallet), which was mounted ABOVE the intl provider — so every route 500'd with a missing-context error. Unit tests each wrap their subject in a provider, so only a full-app render caught it. Move AppIntlProvider to wrap ContextProvider (still inside PeanutProvider, which needs no translations).
'Video element not available' was shown to users when the video element lost the mount race. Say what it means to them instead. Adds a ClientProviders provider-order test: it walks the real element tree rather than rendering it, so the AppIntlProvider-wraps-ContextProvider contract is checked without mocking the wallet and kernel stack.
fix(i18n): four locale gaps — spinner words, savings message, Crisp locale, date casing
…second broadcast A recordPayment timeout after the on-chain transfer broadcast lands the user on Retry, and Retry re-ran the whole flow — issuing a second on-chain transfer for the same charge (TASK-19581 double-spend: two distinct Rhino bridges 30s apart, user moved $10 instead of $5). Stamp the executed spend (charge, tx hash, strategy) in a ref the moment a tx identifier exists; a retry for the same charge skips the broadcast and replays only the record. Failures before any tx identifier still retry with a fresh broadcast. Also give createPayment 30s (default 20s abort fired while the API had committed in 49ms — the Vercel proxy cold-start ate the budget).
pre-commit scan false positive: flagged value is a hardcoded test fixture constant from dev's side of the merge, already in dev history.
…unted sections Two review findings: - `handleClaimLink` reads `tCommon` in both invite-error branches but the callback's dep list only carried `t`, so a locale switch mid-session would leave it closed over the old translator. eslint didn't flag it because `t` is still a dep and the callback is large. - The es-AR delta assertion counted top-level sections (`< 8`), which breaks the moment someone legitimately overrides one more section. It now compares leaf paths: every es-AR path must exist in es-419, and the delta must stay strictly smaller. That is the actual contract.
…d-only fix(withdraw): record-only retry after an executed spend (TASK-19581 double-spend)
…sive service type The hand-declared spec marked geoProhibited required while src/services/card.ts declares it optional — and the current BE does not emit it yet (FE-first deploy). Optional is the honest contract and reduces sync-openapi churn direction.
Locale was client-only (cookie / native Preferences) and never reached analytics, so the localization OKR was unmeasurable. Register app_locale as an event super property at startup resolve and on manual change, set the person property for identified users, and mirror it on the identify payload to cover the first session.
Emission moves to AppIntlProvider's apply points so analytics report the language the user actually saw, never a failed catalog swap. $set fires only on a real change by an identified user (startup coverage rides the identify), calls are deduped and fenced so a posthog failure can't break i18n, and logout re-registers app_locale after posthog.reset() wipes super properties. Gating logic covered by locale-store.test.ts.
The English startup path never swaps intl state, so if setLocale already applied another locale the UI keeps it — emitting the startup value would record a language nobody sees (CodeRabbit).
feat: emit app_locale to PostHog
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Important Review skippedToo many files! This PR contains 745 files, which is 595 over the limit of 150. To get a review, reduce the PR to 150 files or fewer by splitting it into smaller PRs or changing its base branch. Upgrade to Pro+ to raise the limit. Usage-priced reviews support at most 300 files. ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: ⛔ Files ignored due to path filters (8)
📒 Files selected for processing (745)
You can disable this status message by setting the Comment |
Code-analysis diffPainscore total: 6345.36 → 7065.51 (+720.15) 🆕 New findings (1908)
…and 1888 more. ✅ Resolved (1757)
…and 1737 more. 📈 Painscore deltas (top movers)
|
🧪 UI test report — ✅ all greenSuites
📊 Coverage (unit)
⏱ 10 slowest test cases
|
Send has no destination screens of its own — SendRouter navigates into the
withdraw routes (`/withdraw?method=crypto`, `?method=bank`), so `?method=` is
the only signal that the user framed this as a send. `/withdraw` honours it
("Send", "Amount to send") and forwards it to `/withdraw/crypto?method=crypto`,
but that page never read searchParams. The marker arrived and was dropped, so
every screen after the amount step reverted to withdraw copy: pick
Send → Exchange or Wallet, and the next screen says "You're withdrawing".
The word itself is not the bug. PeanutActionDetailsCard maps one
transactionType to one title, and WITHDRAW is genuinely both a withdrawal and
a send depending on how the user arrived — so it has already been flipped in
opposite directions twice (abd71b8 → "sending", d532b6a → "withdrawing"),
each engineer right about the flow in front of them. Flipping it a third time
would just re-break the real withdraw flow. Give the card the discriminator it
was missing instead.
isFromSendFlow is a narrow presentation flag, not a new transactionType: the
transaction really is a withdraw, and arrow-up/wallet-outline are already
correct for both framings, so only the verb branches. Same reason
PaymentSuccessView keeps isWithdrawFlow — it also suppresses the recipient
render and picks the "to" prefix, both right for a send to an address.
useSendFlowOrigin replaces four copies of the marker rule that had drifted into
three different definitions, which is how the screens came apart in the first
place. Manteca (Pix/Mercado Pago) has the same defect on its own page and is a
deliberate follow-up.
The Fit OKR needs the language a user's phone asks for vs the language they actually use, to read the override rate (served their language, switched to English). app_locale already ships; this adds device_language (raw device or browser tag, kept un-resolved so a non-ES/PT phone stays itself instead of collapsing to en) and platform as super properties. No KYC join, so it works web and native (the Capacitor webview runs posthog-js as-is).
…dge call Addresses code-review findings on the device_language/platform emit: - the once-guard was set before the await, so any throw (or posthog.reset() on logout wiping super properties) permanently dropped device_language for the session. Cache the resolved context and re-register it on logout, mirroring app_locale; set the guard only after a successful register so a throw can retry. - memoize the raw device tag so the native Device.getLanguageTag() bridge call on the splash-gated startup path is shared with resolveStartupLocale instead of fired twice. - test: resetAllMocks so a mockImplementation can't leak between cases.
…nguage Addresses the CodeRabbit nitpick — the Capacitor branch of the device-context emit was untested; assert the native bridge tag is lowercased and registered with the native platform.
CodeRabbit caught two instances of the exact seam this PR exists to close, on the bank side rather than the crypto side. The bank review CTA still read "Withdraw" while the header and amount card above it already read "Send" — the same screen disagreeing with itself. Three bank redirects also dropped ?method=bank, so the step the user was sent back to silently reverted to withdraw copy: the existing-saved-account push in DynamicBankAccountForm, and the no-amount / no-account replaces in the bank page. A fourth in the same file (the unsupported-country bounce) had the same defect and is fixed here too; that one needed the hook hoisted above the effect that uses it.
Three follow-ups from CodeRabbit's full pass, all cases of the marker being read too eagerly rather than too little. The marker alone doesn't mean "send": the add-money flow navigates with its own ?method=bank, so AddWithdrawCountriesList — which serves both flows — needs the same `flow === 'withdraw'` guard its sibling AddWithdrawRouterView already had. (CodeRabbit's stated consequence pointed at enforceSupportedCountries, which actually lives in the sibling and was already guarded; the guard is still right here for the navigation branches.) Behaviour is unchanged from before this PR — the previous expression had the same gap. Entering as /withdraw?method=bank and then picking Crypto lands on /withdraw/crypto?method=bank, where the back target hard-coded ?method=crypto and silently rewrote the marker, changing the amount step's back behaviour. The hook now exposes the raw value so callers forward it verbatim instead of re-deriving it. actionDetailsProps is a Partial of the card's full props, so now that isFromSendFlow is one of them a caller could override the flow-guarded value. Moved after the spread to keep the derived value authoritative.
…d-docs-locale fix(i18n): stop app copy drifting between locales, and localize /shhhhh
dev landed six i18n commits, including a pass that dedups app copy across
locales. One real conflict, in es-419's peanutActionDetailsCard block: this
branch added `youreSending` while dev retranslated `youWillReceive`
("Recibirás" -> "Vas a recibir").
Resolved as a union — dev's retranslation wins for its own key, this branch's
new key is kept. Neither side loses an entry, which is the hazard git flags
here.
Everything else auto-merged, including dev's `t('confirm.sponsoredByPeanut')`
-> `tCommon('sponsoredByPeanut')` move inside Confirm.withdraw.view.tsx, which
sits next to this branch's CTA change in the same file.
…-analytics feat(i18n): emit device_language + platform for localization OKR
fix(send): keep the send framing past the amount step
feat(card): geo-blocked state — stop prohibited-country users before the apply funnel
Prod Release Sprint 154 — app localization (en/es-419/pt-BR) · lifecycle emails · error codes (2026-08-06)
Release payload:
dev→main. Back-merge of prod hotfixes (main → dev) already landed via #2611.What ships (peanut-ui)
Localization (the dominant theme)
Native / migration
Money flows
Platform
Migrations
None for this release (FE).
Deploy order
Deploys after the BE release PR. See the release doc for the full checklist:
https://app.notion.com/p/3b38381175798001af66d8450d48f692