Conversation
The parse-error catch logged the whole frame:
console.error('Error parsing WebSocket message:', error, event.data)
console.error is not local. instrumentation-client.ts and
sentry.client.config.ts both register
captureConsoleIntegration({ levels: ['error', 'warn'] }), so every
console.error becomes a Sentry event. beforeSendHandler scrubs
request.headers, request.data, extra, contexts and breadcrumb data by key
name - it never touches event.message, and key-name redaction does
nothing to a raw serialized blob anyway.
The frames this handler receives are kyc_status_update,
sumsub_kyc_status_update, manteca_kyc_status_update, history_entry,
rain_card_balance_changed and user_rail_status_changed - user KYC state
and financial data. A malformed one carried all of it to Sentry.
Log the byte length instead. That still separates a truncated frame from
a malformed one, which is the only thing this catch ever needed.
The test pins it: it fails against the old line and passes against this
one. CodeQL alert #145 (js/log-injection, medium).
…ation fix(ci): consolidate UI workflow reliability
Second half of TASK-21141. The backend now writes an in-app notification row for every support reply; this shows it. The Support icon in the mobile nav gets a pink dot while support has replied and the user has not opened the chat. Opening the drawer clears it. The count is server truth, read from /notifications/unread-count?category=support — the Crisp widget is a sandboxed iframe on web and an event-less plugin on native, so the client cannot work this out for itself. Clearing hangs off isSupportModalOpen, which is the one flag every entry sets before anything opens — the nav tap, openSupportWithMessage(), the push deep link and the Capacitor path. One effect covers all four. SupportDeepLink handles /home?support=open, the link a support push carries. The pink dot was copy-pasted in three places and the badge would have made a fourth, so it is now one IndicatorDot component. The three call sites render the same as before — twMerge resolves the size and animation overrides. The name is deliberately neutral: on a transaction card the dot means pending, on the perk carousel it means claimable. Do not merge before the backend PR is deployed. An old backend ignores the category param and would light the badge for any unread notification.
jest.fn(async () => …) infers a zero-arg function, so calling it with the category failed tsc. Local typecheck predated this mock and missed it.
The freshness check compared backend timestamps against the DEVICE clock with
a five-minute tolerance. A phone more than five minutes out rejected every
response the backend could send — permanently, and there is no local fallback
left to catch it. Device-clock comparisons now allow six hours; the checks
that actually bound staleness compare two backend timestamps and stay tight.
Sentry now skips 503 on /fx/rate. It means a provider leg is momentarily
absent, which peanut-api already reports with the upstream cause attached;
reporting it client-side multiplies one incident by every mounted hook and
its retries, and buries the signal that can be acted on.
Also gives /fx/rate a real demo-api handler. Without one a failed passthrough
fell to defaultShape and answered 200 {} — a contract violation dressed as a
success. A canned rate is not possible (handlers never see the query string,
and the validator rejects a mismatched pair), so it answers 503.
Retargeted from main to dev, so dev's locale redirect and native authReady gate had to land alongside the FX changes. proxy.ts: kept dev's locale block and Vary reasoning, carried over the /api/exchange-rate cache exemption. api-fetch.ts: authReady() now runs only when includeAuth is true. A public rate read sends no token either way, so making it queue behind auth hydration on a native cold start would delay it for nothing.
The three migrated call sites are only safe if twMerge wins their size and animation overrides instead of emitting both. Asserting the resolved class string pins that more precisely than a screenshot of a 10px dot.
String.length is UTF-16 code units. Getting a true byte count means running the whole frame through a TextEncoder inside an error path, and only the magnitude matters for telling a truncated frame from a malformed one — so relabel rather than pay for the encode.
Five findings from /code-review on the badge lifecycle. Opening the drawer is not the same as reading the reply. When the Crisp bundle fails to load, this same component shows the email fallback instead — the badge used to clear anyway and bury a reply nobody saw. The web path now waits for CRISP_READY. The native path has no such signal, so it clears right after openMessenger() instead. A reply arriving while the drawer is open — the normal case in a live conversation — used to light the badge with nothing new behind it and leave it lit until the user opened support again. Clearing now also fires on the closing edge. Concurrent refreshes could resurrect a cleared badge: tapping a push fires a foreground refetch (count 1), the deep link then clears and refetches (count 0), and if the first response lands last it wins. With no polling nothing corrected it. Responses now carry a request id and stale ones are dropped. Guests reach this drawer through claim and pay links, and were sending an unauthenticated mark-read on every open. Gated on a resolved userId. The nav badge announced nothing: aria-label on a bare span is ignored by assistive tech and is an aria-prohibited-attr violation. It now carries role="status" with a translated label. es-AR has no navigation block at all and falls back, so only the three locales that do were touched.
feat(support): unread badge on the Support nav icon
refactor(fx): consume shared backend rate policy
fix(websocket): stop shipping raw frames to Sentry on a parse error
…into-dev-20260807
…o-dev-20260807 chore: back-merge main → dev (pre-release 2026-08-07)
|
Bugbot is not enabled for your account, so this pull request was not reviewed. Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs. |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Important Review skippedDraft detected. 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:
📝 WalkthroughWalkthroughChangesFX rate flow
Support unread state
API contracts and diagnostics
Release and repository updates
Estimated code review effort: 5 (Critical) | ~90 minutes Sequence Diagram(s)sequenceDiagram
participant Client
participant ExchangeRateHook
participant ExchangeRateRoute
participant FxApi
Client->>ExchangeRateHook: request exchange rate
ExchangeRateHook->>FxApi: GET /fx/rate
FxApi-->>ExchangeRateHook: rate or structured error
ExchangeRateHook-->>Client: rate or cleared conversion state
Client->>ExchangeRateRoute: request compatibility route
ExchangeRateRoute->>FxApi: fetch validated rate
FxApi-->>ExchangeRateRoute: status and rate data
ExchangeRateRoute-->>Client: cache or no-store response
Possibly related PRs
Suggested labels: Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
Comment |
| // TextEncoder inside an error path, and only the magnitude | ||
| // matters here. | ||
| const size = typeof event.data === 'string' ? event.data.length : 'non-string' | ||
| console.error('Error parsing WebSocket message:', error, `(frame length: ${size})`) |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
src/hooks/__tests__/useSupportUnread.test.ts (1)
16-72: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAdd an out-of-order response regression test.
The suite does not verify
latestRequestId. Create two controlled requests. Resolve the newer request with{ count: 0 }, then resolve the older request with{ count: 1 }. Assert that the hook remainsfalse.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/hooks/__tests__/useSupportUnread.test.ts` around lines 16 - 72, Add a regression test for latestRequestId in the useSupportUnread suite using two controlled unread-count requests. Resolve the newer request with count 0 before resolving the older request with count 1, then assert the hook remains false so stale responses cannot update the state.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/components/Profile/components/ProfileMenuItem.tsx`:
- Line 59: Use one semantic accessibility contract for labelled indicator dots:
in src/components/Profile/components/ProfileMenuItem.tsx lines 59-59, add an
appropriate role with a localized accessible name or mark the visual dot
decorative; in src/components/Home/HomeCarouselCTA/CarouselCTA.tsx lines 84-84,
place the claimable label on a role-bearing wrapper or IndicatorDot and hide the
other element; in
src/components/Global/IndicatorDot/__tests__/IndicatorDot.test.tsx lines 32-37,
assert the intended role/name or hidden state and cover ProfileMenuItem
integration if required.
---
Nitpick comments:
In `@src/hooks/__tests__/useSupportUnread.test.ts`:
- Around line 16-72: Add a regression test for latestRequestId in the
useSupportUnread suite using two controlled unread-count requests. Resolve the
newer request with count 0 before resolving the older request with count 1, then
assert the hook remains false so stale responses cannot update the state.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 564cd269-dfb5-4ce7-89dd-67d84ced437a
⛔ Files ignored due to path filters (1)
src/types/api.generated.tsis excluded by!**/*.generated.*
📒 Files selected for processing (41)
.github/workflows/capgo-deploy-ios.yml.github/workflows/capgo-deploy.yml.github/workflows/content-publish-automerge.ymldocs/api-types.mdnext.config.jssrc/__tests__/proxy.test.tssrc/app/(mobile-ui)/add-money/[country]/bank/page.tsxsrc/app/(mobile-ui)/layout.tsxsrc/app/api/exchange-rate/__tests__/route.test.tssrc/app/api/exchange-rate/route.tssrc/app/m/[slug]/MerchantLandingPage.tsxsrc/components/Global/IndicatorDot/__tests__/IndicatorDot.test.tsxsrc/components/Global/IndicatorDot/index.tsxsrc/components/Global/SupportDeepLink/index.tsxsrc/components/Global/SupportDrawer/__tests__/SupportDrawer.test.tsxsrc/components/Global/SupportDrawer/index.tsxsrc/components/Global/WalletNavigation/index.tsxsrc/components/Home/HomeCarouselCTA/CarouselCTA.tsxsrc/components/Profile/components/ProfileMenuItem.tsxsrc/components/TransactionDetails/TransactionCard.tsxsrc/contentsrc/hooks/__tests__/useExchangeRate.test.tsxsrc/hooks/__tests__/useSupportUnread.test.tssrc/hooks/useExchangeRate.tssrc/hooks/useSupportUnread.tssrc/i18n/app/messages/en.jsonsrc/i18n/app/messages/es-419.jsonsrc/i18n/app/messages/pt-BR.jsonsrc/proxy.tssrc/services/__tests__/websocket-parse-error-pii.test.tssrc/services/notifications.tssrc/services/websocket.tssrc/types/api.openapi.jsonsrc/utils/__tests__/api-fetch.test.tssrc/utils/__tests__/demo-api.test.tssrc/utils/__tests__/fx.utils.test.tssrc/utils/__tests__/sentry.utils.test.tssrc/utils/api-fetch.tssrc/utils/demo-api.tssrc/utils/fx.utils.tssrc/utils/sentry.utils.ts
💤 Files with no reviewable changes (1)
- next.config.js
| <div className="h-2.5 w-2.5 rounded-full bg-primary-1" /> | ||
| </div> | ||
| )} | ||
| {highlight && <IndicatorDot className="animate-pulse" aria-label="highlight-indicator" />} |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Use one semantic accessibility contract for labelled dots.
The carousel and profile call sites preserve aria-label but do not provide a semantic role. The test checks only the attribute. A generic span or div does not expose the intended status to assistive technology.
src/components/Profile/components/ProfileMenuItem.tsx#L59-L59: add an appropriate role and localized accessible name, or mark the visual dot as decorative.src/components/Home/HomeCarouselCTA/CarouselCTA.tsx#L84-L84: move the claimable label to a role-bearing wrapper orIndicatorDot, and hide the other element.src/components/Global/IndicatorDot/__tests__/IndicatorDot.test.tsx#L32-L37: assert the intended role/name or hidden state, and coverProfileMenuItemif integration behavior is required.
📍 Affects 3 files
src/components/Profile/components/ProfileMenuItem.tsx#L59-L59(this comment)src/components/Home/HomeCarouselCTA/CarouselCTA.tsx#L84-L84src/components/Global/IndicatorDot/__tests__/IndicatorDot.test.tsx#L32-L37
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/components/Profile/components/ProfileMenuItem.tsx` at line 59, Use one
semantic accessibility contract for labelled indicator dots: in
src/components/Profile/components/ProfileMenuItem.tsx lines 59-59, add an
appropriate role with a localized accessible name or mark the visual dot
decorative; in src/components/Home/HomeCarouselCTA/CarouselCTA.tsx lines 84-84,
place the claimable label on a role-bearing wrapper or IndicatorDot and hide the
other element; in
src/components/Global/IndicatorDot/__tests__/IndicatorDot.test.tsx lines 32-37,
assert the intended role/name or hidden state and cover ProfileMenuItem
integration if required.
…w skip reason (#2645) Without this the Explorer shows a balance-held user as 'due now', the exact opposite of the truth in the tool an operator uses to verify api#1309.
…e profile owner The store handoff ran window.open after an awaited validate — iOS drops the user gesture across a fetch, blocks the popup, and the intercepted branch skipped navigation, so the CTA died silently on mobile web. The intercept now fires synchronously first; the cookie write still lands because the handoff opens _blank and this tab lives on (store-hop attribution proper is TASK-21044). Crediting now requires the code to resolve to the profile owner themself: the API typo-fallback can resolve a waitlisted handle to a different real user (maria23 -> maria), and that click must not write a cookie nor report link_type invite_code.
…ests to tables, comments to file norm The receipt and profile pill hand-rolled the share/copy/toast/capture flow that ShareButton (imported in the same file) already owns; both route through it now, which also lets copyTextToClipboardWithFallback revert to its original contract. Design-history comments and repeated tellings of the same rule go — the allow-list story lives once, on REFERRAL_NUDGE_KINDS; the anti-dox rule once, on profileShareUrl. Mock-permutation tests collapse to test.each tables; suites that tested untouched code or pinned a Tailwind class are deleted. rewardClaimed reverted (readability refactor of untouched conditionals — out of scope). Behavior deltas, all from the ShareButton unification: mobile copies + toasts before the sheet; the receipt copy toast says 'Link copied'; a double failure now surfaces 'Sharing failed' instead of dying silently.
…action-predicates.test.ts
…fire The consecutive-failure counter was reset by any successful response. The app fires requests in parallel, so on a flaky connection successes interleave with timeouts and the threshold of 2 was never reached — the connectivity banner shipped dead. Failures now live in a 60s sliding window and age out on their own instead of being cleared by a success. Timeouts also told users 'Service temporarily unavailable' when the request never reached the server — the copy now blames the connection, not the service (new connectionTimeout friendly-error code, 4 locales). TASK-21108
Review follow-ups from Kush's pass: 1. ShareButton: a cancelled share sheet after a successful copy now calls onSuccess — the link is on the clipboard and the 'copied' toast already showed, so consumers capturing INVITE_LINK_SHARED must count it. With no copy landed, cancellation stays quiet as before. 2. ProfileHeader: the share-pill impression re-arms when the pill hides — the [...recipient] route reuses the component instance, so a mount-scoped latch undercounted self → other → self navigations. Both matter because this PR's variant/link_type comparison is the metric these events feed.
feat(referral): attributed share links + invite nudge on every payment type
/code-review found the hook's count-keyed expiry effect could strand (React same-value bailout never re-arms the timer -> banner stuck), and the generic fetch catch shared ServiceUnavailableError with the timeout path, so CORS/CSP outages on our side would blame the user's connection. - connectivity.ts now owns expiry: one timer per failure decrements the count and emits (useSubmissionWindow pattern); hook shrinks to a useSyncExternalStore read, no timer math - timeout path renamed ConnectionTimeoutError (connection-blaming copy); generic path keeps ServiceUnavailableError -> networkBusyTimeout - both names in the Sentry alreadyReported filter; HomeHistory matches both - FAILURE_THRESHOLD moved next to FAILURE_WINDOW_MS so the policy reads in one place
…window
1. copy no longer blames the user's internet — the 20s abort is our own
AbortController and fires on a slow backend over a healthy connection;
new copy names both ('Peanut is taking too long to respond — check
your connection and try again', 4 locales)
2. the online event now clears the failure window, so reconnecting shows
a clean app instead of 'trouble reaching Peanut' for up to 60s
3. truth is Date.now() stamps pruned on read — timers only notify, so a
tab resumed from freeze/sleep can't render a stale banner
4. count is distinct failing endpoints, not raw failures — React Query's
3 retry attempts on one slow route no longer trip the app-wide banner
TASK-20958 — QA of the app in Portuguese found four surfaces still showing English, and one term the "Cobrar" section names two different ways. Countries and regions were untranslated because they are static English catalog data rendered straight to the screen. Countries now resolve through `Intl.DisplayNames` from the ISO-2 code the catalog already carries, so no locale has to ship ~250 country names. Regions keep their English `name` — `deriveRegionAccess` branches on it — and resolve display copy from the stable `path` against a new shared `common.regions` catalog, which also absorbs the old `limits.restOfWorld` so the label lives in one place. Badge names and reasons come from the backend catalog, which ships English only. A `badges.catalog` namespace keyed by badge code now supplies the localized copy, and the backend prose stays the fallback for any code added after this build. In Portuguese the money request a user creates from "Cobrar" is a "cobrança", not a "solicitação" (which reads as a support ticket). One name for one thing, across the request, payment and transaction namespaces.
The badge catalog swallowed the audience choice in BadgesRow: the backend ships a third-person `publicDescription` for other people's profiles, and routing it through the catalog replaced it with the second-person self copy for all 48 known codes. The old test only passed because it used a code the catalog does not know; it now has a sibling that uses a real one. `notifications.requesting` labels the browser push-permission button, not a money request — it was swept into the cobrança rename by mistake and read "Cobrando..." while asking for notification access. Two InitiateKycModal call sites (Manteca add-money, Manteca claim) still passed the English catalog title, so the same modal named the country two different ways depending on which flow opened it. Rest-of-world is one exported constant again instead of a literal retyped in LimitsPageView.
…-window fix(connectivity): windowed failure counting + connection-aware timeout copy
modal={false} disables vaul's scroll prevention and nothing set
touch-action on the drawer, so the browser could claim an upward swipe
as a scroll and fire pointercancel — vaul aborted the drag and the
drawer sprang back, needing a second swipe (TASK-20720).
- touch-none on the content so the pointer stream reaches vaul intact
- dismissible={false} restored: drag-down at the first snap point was
calling closeDrawer() against the forced open={true} (flicker + 500ms
dead-drag window after reopen); dropped by accident in 80d930c
- snapPoints hoisted to module scope so vaul's snap-sync effect stops
refiring on every parent re-render
openStore() was opening the bare store url, so the hand-off machinery shipped in the app (>=1.0.47) never received a payload from any real surface — only /dev/deferred exercised it. now android rides the play install referrer and ios gets the clipboard hand-off written inside the tap gesture, from every bounce CTA (guest claim/invite/request, home banner, download modals).
code-review found touch-none on the outer content stops governing touches once content overflows the shared wrapper's inner scroller — the touch-action walk ends at the pan-implementing element, so the two-swipe hijack returned on small viewports. DrawerContent now takes an optional scrollAreaClassName so the QR drawer can repeat touch-none on the scroller itself. also: dead contentRef removed, comments split to STE sentence length, vaul-pinned fast-flick note added.
code-review finding: SendWithPeanutCta routes web guests to /invite?code=<inviter> but the store intercept dropped the inviter — mirror the claim CTA and pass it explicitly.
pr-review callers sweep: LandingPageClient and StickyMobileCTA anchors navigated to the bare store url themselves, bypassing the deferred hand-off on the top-of-funnel surface (PT/ES SEO pages land here). the tap now preventDefaults and goes through openStore; href stays as the non-click fallback.
blanket touch-none on the scroll wrapper made overflowing content unreachable at full snap on SE-class viewports — the same devices the wrapper coverage was added for. apply it only while collapsed; at full snap vaul's shouldDrag already arbitrates drag vs scroll.
fix(i18n): translate countries, regions and badge copy for pt-BR
with dismissible={false} vaul ignores drag-down at the collapsed snap
entirely — the drawer does not move, it never rubber-banded. also
correct the touch-action comment: the walk stops at the overflow-auto
wrapper even without overflow, so the outer class only covers the
handle area (verified empirically with chromium touch emulation).
…view)
1. logout now clears the invite cookie — a signed-in native user who
tapped a friend's invite app link could otherwise never reach Log In
again within the session (setup skips Landing on the cookie).
2. the deep-link mapper drops or rewrites every non-reserved path: bare
profiles and semantic pay paths funnel into /send?recipient= via the
existing recipientPayUrl, anything unmappable returns null instead of
chunk-erroring against the pruned catch-all. this also covers the
deferred dest restore (finding 1) at the one shared boundary.
3. the three in-app router.push('/invite...') sites route through a new
inviteFlowUrl helper — web keeps the landing page, native writes the
session cookie and goes straight to signup.
4. buildDeferredPayload omits the default dest when the page url carries
a claim secret (#p=) — restoring a claim page without its password
renders it unclaimable; the user's re-tap is the working path.
5. landing store CTAs are self-navigating anchors again (no
preventDefault): android's payload rides the href, ios' rides the
clipboard written in the click handler — the anchor navigation is the
fallback that survives in-app browsers that suppress window.open.
…jack fix(qr-drawer): stop browser hijacking the expand swipe
…ring feat: carry the deferred deep-link payload on every store bounce + fix /invite native dead-end
… by id Two locale bugs in the code-injected rails FAQ on /es-419, /es-ar, /pt-br: the insert anchored on the English question text (/what is peanut/i), so on localized pages it fell to the end of the list, and the question/answer were English constants, leaving one English FAQ inside translated pages (visible live today, also in the FAQPage JSON-LD). Anchor by FAQ id instead (content files keep ids aligned across locales) and move the question + answer sentence templates into the marketing i18n catalogs; the fact lists (chains, rails) still interpolate from rhino.consts so the answer can't drift from what the app supports. Known follow-up: FIAT_RAILS region labels still render in English inside the localized answer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
fix(landing): localize the injected supported-rails FAQ and anchor it by id
Payload
FX — consume the shared backend rate policy (#2607)
Replaces the local conversion implementation with
GET /fx/rate, so the wallet and Peanut Split share one contract.503added to the Sentry skip list for/fx/rate— peanut-api already reports the upstream cause; reporting it client-side multiplies one incident by every mounted hook and its retries./fx/rategets a real demo-api handler; without one a failed passthrough answered200 {}.Support — unread badge (#2639) — FE half of peanut-api-ts #1303. Ships together.
Observability (#2637) — stop shipping raw websocket frames to Sentry on a parse error.
CI (#2636) — workflow consolidation. No runtime effect.
Order — merge this AFTER the backend
peanut-api-ts #1308 must be deployed first. This PR drops
api.frankfurter.appfrom CSP, so if the FE ships before/fx/*is live on prod, every rate surface blanks and the old path cannot be hot-restored.Verification
FX verified against staging (which tracks
dev): 43/43 on the shadow-compare, every Manteca pair resolving from Manteca rather than the reference feed.Summary by CodeRabbit
New Features
Bug Fixes