Skip to content

Prod Release Sprint 157 — mobile app fixes · app-locale sync · peanut-shaper badge (2026-08-24) - #2804

Open
jjramirezn wants to merge 96 commits into
mainfrom
dev
Open

Prod Release Sprint 157 — mobile app fixes · app-locale sync · peanut-shaper badge (2026-08-24)#2804
jjramirezn wants to merge 96 commits into
mainfrom
dev

Conversation

@jjramirezn

@jjramirezn jjramirezn commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Release PR: devmain for the Sprint 157 prod release.

Payload (so far):

Migrations: n/a (FE).

Notion release page + full changelog to follow. Hugo merges this PR, after the BE deploy.

Summary by CodeRabbit

  • New Features

    • Added iOS-specific cashback terminology, localized messaging, and a rewards “How it works” explainer.
    • Added improved pull-to-refresh feedback with haptics and animations.
    • Android now opens terms pages in the system browser.
    • Added a cancel-send-link drawer and richer support verification details.
    • Added localized marketing content, improved exchange CTA routing, and automatic locale synchronization.
  • Bug Fixes

    • Improved return navigation, balance hiding, QR-code visibility, route highlighting, and terms acceptance handling.
    • Improved login, invitation, and error reporting behavior.
    • Native releases now automatically publish and verify matching OTA updates when needed.
    • Improved ENS name resolution and landing-page performance.

innolope-dev and others added 30 commits August 18, 2026 16:55
Support agents have no visibility into a user's live verification state, so
they guess where a user is stuck. Adds a support-facing snapshot to the Crisp
agent sidebar (session:data), derived entirely from the two backend read-models
already on /get-user (`capabilities`, `identityVerification`) — no backend
change and no new provider-state interpretation on the client.

New agent-only fields: identity_status, email_on_file, verification_gates,
verification_rails, failure_reason, pending_actions. Threaded through all three
Crisp sinks: web widget (setCrispUserData), the proxy iframe (which receives the
whole CrispUserData over the postMessage handshake), and native Capacitor
(SupportDrawer).

Sidebar only — the user's own composer (message:text) is never touched, so
internal reason codes and rail ids stay out of the user's view.

Closes #2360.
`shouldIgnoreError` only ever inspected `exception.values[0]`. Sentry orders
that array root-cause-first, so for any error carrying a `cause` the wrapper
sits at the end — and `fetchWithSentry` always sets `userError.cause`.

The `alreadyReported` filter added in 10ee160 to stop double-counting fetch
failures has therefore been inert for its own motivating case ever since:
PEANUT-UI-SNP (the ServiceUnavailableError wrapper) kept being reported
alongside PEANUT-UI-QEY (the timeout it wraps). Sentry confirms the shape —
`error.type` on those events reads "Error, ServiceUnavailableError".

Scan every value's type and message, and collect extension stack frames from
every value rather than just the first.

Also suppress Capgo's background-updater chatter, which captureConsoleIntegration
promotes to ~95 events/day on native. `disable_auto_update_under_native` and
checksum mismatches stay reported: those mean OTA is actually broken for a
build, not that one download hiccuped.
Native reported 970 Sentry events in 24h, 691 of them level:info. Almost all
of that is instrumentation written to watch the CapacitorHttp → direct-fetch
switch (PEANUT-UI-R44). That switch has shipped; the probes have not.

- native-canary: five captureMessage calls per app launch (~550 events/day),
  plus five extra API round-trips on every cold start, to answer a question
  we already answered. Deleted.
- legacy-cookie native transport engaged / native http fallback engaged:
  once-per-session census notes for the same migration. Deleted.
- onesignal subscription snapshot: still useful, but it's a state fact, not a
  fault — moved to PostHog as notification_subscription_snapshot. The failure
  variant stays in Sentry, since failing to read the state is a real error.

Transport behaviour is unchanged; the fallback and prefer-native paths still
work exactly as before, and their tests now assert the paths stay silent.
Same shape as the fetch wrappers. useZeroDev classifies the raw WebAuthn
failure, captures it with full context, and throws a curated user-facing
PasskeyError — and for a plain user cancel it deliberately captures nothing
on web.

Three call sites re-report that wrapper: Landing and JoinWaitlist call
Sentry.captureException on it directly, and GuestLoginModal console.errors it.
The result is a second, context-free event, and LOGIN_CANCELED showing up at
error level despite the deliberate silence — PEANUT-UI-QRW and PEANUT-UI-R20,
19 events yesterday.
useZeroDev classifies a WebAuthn failure, captures the raw error with full
context, and throws a curated PasskeyError for display. For a plain user
cancel it deliberately captures nothing on web — "Cancel saved no state".

Four call sites undid that:

- InvitesPage fired `void handleLoginClick()` with no catch, so cancelling the
  prompt became an unhandled rejection. Now caught, and the curated message is
  surfaced the way every other login entry point surfaces it.
- GuestLoginModal console.error'd the wrapper, which captureConsoleIntegration
  turns into an event.
- Landing and JoinWaitlist called Sentry.captureException on the wrapper —
  a second, context-free copy of an error already reported at the throw site,
  and the reason LOGIN_CANCELED showed up at error level at all.

Landing and JoinWaitlist still report anything that isn't a PasskeyError, so an
unexpected failure in the login path is not silenced.

PEANUT-UI-QRW and PEANUT-UI-R20: 19 events yesterday, all expected outcomes.
…dentity

`capabilities` is optional on /get-user during the capability migration. Deriving
gates over the empty fallback state made every operation read `needs-identity`,
which a support agent cannot tell apart from a genuinely unverified user — the
exact misreading this snapshot exists to prevent. Report an empty `gates` when
the read-model is absent; a read-model that is present but empty still derives
normally, since needs-identity is the truth there.
5343f1d landed in the same function. Both changes keep their meaning:

- Critical-flow captures still bypass every group but userRejected, and the
  early return now sits ahead of the Capgo check so a money-flow event can
  never be dropped by it either.
- The chain scan is narrowed to exception TYPES only. Class names are exact,
  so matching them across the chain can only catch our own wrappers. Scanning
  chained MESSAGES the same way would suppress more, not less — which is the
  failure 5343f1d fixed, where viem's "Details: Failed to fetch" ate real
  payment errors through the networkIssues group. values[0].value keeps the
  matching reach it had before.

Added a guard test for that boundary: a wrapper whose message contains a noise
pattern is still reported.
Android's Capacitor WebView cannot host a third-party subframe.
BridgeWebViewClient.shouldOverrideUrlLoading hands EVERY navigation to
Bridge.launchIntent without checking request.isForMainFrame(), and
launchIntent cancels the load for any host outside the app origin that
isn't listed in server.allowNavigation — which capacitor.config.ts does
not set. The Bridge ToS iframe therefore painted pure white inside our
verification chrome, no signedAgreementId postMessage ever arrived, and
Bridge kept the terms pending. All 39 post-deploy ToS confirmations came
from web sessions; the one native user in the set is the failure. Sumsub
KYC itself is unaffected on native because SumsubKycWrapper routes
Capacitor to the Cordova SDK, not an iframe.

allowNavigation was deliberately NOT the fix: it is baked into the
binary (every installed app stays broken until a store release), and on
android it also registers the host as a WebViewLocalServer authority —
its HTML would be proxied through handleProxyRequest with Capacitor's
bridge JS injected — and widens the androidBridge trusted-origin set.

Android now opens the ToS in the system browser (@capacitor/browser,
already in the binary since April) and treats `browserFinished` as a
`returned` signal — "the user came back", not "the user accepted".
/users/bridge-tos-confirm re-reads has_accepted_terms_of_service from
Bridge, so the detour needs no postMessage; confirmBridgeTosAndAwaitRails
now returns that verdict and the callers use it to tell a real acceptance
from an abandoned one (no phantom completion, no unearned KYC_TOS_ACCEPTED).
When the caller observed no acceptance and Bridge says no, the helper
stops after the confirm retry instead of arming the 30s submission
window and polling rails that will never change.

iOS is untouched — its navigation delegate already gates the same detour
on targetFrame.isMainFrame, so the iframe works there. The web iframe path
is unchanged. Ships over the air; no store release needed.
A fast unmount or visibility change while Browser.addListener was still
resolving removed the listener but still opened the Custom Tab — the ToS
could pop after its owning flow had closed. Late arrivals now drop the
listener and stop.
Before a request exists the QR already encodes the profile payment link
for the entered amount (/{username}/{amount}USDC), so there is no reason
to keep it blurred until the create button is clicked. It now unblurs on
a positive amount and re-blurs when the amount is cleared or zero.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YQHgKufMZzqzwCviP4gXKs
On native the pull refetches via react-query instead of reloading the page,
so nothing on screen blinks and the gesture reads as having done nothing.
Give the indicator the full state sequence instead:

- pulling: the indicator scales in and the arrow rotates toward upright,
  flipping at the release threshold with a light haptic on the crossing
- refreshing: a readable spinner arc (was a thin quarter-circle path)
- done: green checkmark with a pop + success haptic, held briefly, and the
  content fades back in so the screen visibly re-renders

Also restyles the indicator to the app's brutalist look (black border + hard
shadow), guards Element.animate for WebViews that lack it, honours
prefers-reduced-motion for the content fade, and clears pending timeouts on
unmount.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011zVw46ZCvXYp7b4dpXvJiq
Going /profile → "Exchange rates and fees" → "Try it!" lands the user in the
add-money or withdraw root, depending on their balance and unlocked regions.
Both roots deliberately reset to /home on back rather than calling
router.back(), because their own sub-pages push back to the root and back()
ping-pongs there. That is right for a tab-bar entry, but it strands anyone who
arrived from another screen: back never returns to the widget they came from.

Add a `returnTo` query param the caller sets and the flow roots honour, so the
origin travels with the navigation instead of being guessed at the destination:

- new `withReturnTo` / `readReturnTo` helpers — same-origin only (reusing
  sanitizeRedirectURL), and a target pointing at the current page is dropped,
  since re-pushing the page you are on is a back button that does nothing.
- the exchange-rate CTA passes its own path *and* query string, so back
  restores the currency pair and amount the user was looking at.
- add-money and withdraw check it before falling back to /home. The send-flow
  /send branch and the in-page steps (country list → method selection, amount
  → method selection) keep priority, so back still unwinds one step at a time.

Tests: unit coverage for the helpers (including the off-origin and
self-referential rejections) plus back-navigation cases on both flow roots and
the exchange-rate CTA.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SbALh4dJvcBUAinjnwQ53q
…-app-fixes

Brings in the pull-to-refresh feedback work (arrow arming, spinner, success
checkmark + haptics, content settle) along with the current main base.
Reverses the iOS gate from 4564b40 and adds an iOS-only copy layer that
presents the referral programme as cashback. Web and Android are
unchanged: every pre-existing catalog string is byte-identical to dev, and
the two new UI elements are behind isIOSNative().

Hiding the programme while the backend kept accruing — and while
referral.reward.earned kept pushing "You earned $X! <name> joined Peanut
with your invite" to the same devices — was the worse position. Guideline
2.3.1 treats hidden-but-discoverable features as grounds for removal. The
programme will be disclosed in Notes for Review instead.

The old citation was also wrong: appStoreCompliance.ts cited 3.1.5(ii),
which is Mining. The clause about offering currency for encouraging
downloads is 3.1.5(v).

appStoreCompliance.ts is deleted along with its seven call sites — the
/rewards and /rewards/invites route guards, the home pill, the profile
row, both invite carousel CTAs, the surprise-claim treatment and the
receipt points row. Removing the route guard also fixes the
referral.reward.earned deep link, which pointed at a guarded route and
bounced iOS users to /home.

The cross-chain withdraw gate is untouched: it lives in
underMaintenance.config.ts, rests on 3.1.5(iii) (Exchanges, which needs
per-region licensing), and multi-chain swaps are the strongest crypto-app
signal we ship.

useAppTranslations wraps useTranslations and prefers an `iosCopy.<key>`
override when one exists, falling back to the base string otherwise.
Overrides sit inside the namespace they belong to, so call sites keep
their existing keys and only the hook name changes. The block is called
iosCopy, not ios, because profile.backup.steps.ios is already content — a
namespace whose own content sat under `ios` would have had every key
silently redirected. Platform is read at render time; the Capacitor bridge
is absent during prerender.

45 overrides per full locale plus voseo deltas for es-AR:

- rewards -> cashback. "Cashback" as a loanword is the standard term in
  both LatAm markets.
- "used Peanut" -> "paid with Peanut", "the more they use" -> "the more
  they pay". Ties the money to a transaction rather than a signup;
  downloading is what 3.1.5(v) names.
- A four-step "How cashback works" card on /rewards. The earn instruction
  used to be a half-sentence on the lifetime total; as its own card it
  reads better and states the process without pinning a dollar to a
  person.
- Dropped "friends & their friends" from qrPay.claim.inviteQrDescription
  and "contribute towards your points forever" from the sticker copy — the
  only two places the UI stated the transitive structure.

Push notification copy is deliberately unchanged. PushChannel targets by
userId and OneSignal fans one notification out to every subscription a
user has, so there is no per-platform copy path and a single notification
cannot say two different things to the same person's devices.

Tests cover both platforms, the fall-through, all four locales, and two
catalog invariants: every override shadows a real base key (a typo would
otherwise silently never resolve), and iosCopy stays distinct from the
existing ios content key.
…o-dev-20260820-2

chore: back-merge main → dev (2026-08-20 release + #2760)
Removing the route guard took the file's only useEffect with it, and
no-unused-vars is an error rather than a warning — the single eslint error
in the run, on top of the 65 pre-existing warnings.
feat(rewards): iOS-only cashback copy, referral surfaces restored
…cel drawer

Four unrelated mobile-app fixes.

**Home tab has no active state.** WalletNavigation compared `usePathname()`
to the nav href with `===`. The native build sets `trailingSlash: true`, so
the pathname is `/home/` there and the comparison never matched — every
active state in the app was silently lost, not just Home. Added
`isSameRoute()` next to the other route helpers and routed the mobile and
desktop nav through it.

**Withdraw's final screen loads twice.** `withdraw/page.tsx` called
`React.lazy()` inside the render body for its two native `?country=` views.
That hands back a fresh, unresolved lazy on every render, so each re-render
re-suspended: React hid the rendered view and swapped in the Suspense
fallback (null) until the import re-resolved a microtask later. The screen
blanked and loaded again — and the success view triggers a re-render itself
when it invalidates the transactions query. Hoisted both to module scope.
The regression test asserts the view isn't display:none straight after a
re-render; it fails against the old code.

**Stray rule under the receipt's last row.** The details card underlines
every row and drops the rule on the last one, but `shouldHideBorder` only
reaches rows the receipt renders itself. `BridgeDepositInstructions` expands
into rows of its own and doesn't take the flag, so the pending bank-deposit
receipt ends on a rule sitting directly on the card border. Rows with a
second runtime gate (a token icon still being fetched, a rate missing from
the payload) fail the same way — the config calls them last, the DOM never
gets them. Let the container decide with `[&>*:last-child]:border-b-0`.

**Cancel-link confirmation is a modal.** Replaced it with a bottom drawer.
It opens from two places — the transaction details drawer and the send-link
success page — so `Drawer` gained a `nested` prop that switches to vaul's
`NestedRoot`; a plain Root inside a Root double-applies the background scale
and fights over the scroll lock. That also retires the `!z-[10]` shuffle the
parent drawer needed to get out of the modal's way. Verified in Chromium
that the content fits without scrolling from 320x568 up, and that the
drawer's `max-h-[80vh] overflow-auto` takes over below that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SbALh4dJvcBUAinjnwQ53q
…mobile-app-fixes

Brings in five mobile fixes:
- back navigation after the exchange-rate widget CTA (?returnTo)
- home tab active state on the trailing-slash native build
- withdraw's final screen re-suspending and loading twice
- stray dashed rule under the receipt's last row
- cancel-link confirmation converted from a modal to a drawer
The top strip was forced black in bd9a1b3 so it would stop flipping between
black and beige above the pink beta feedback ribbon. That ribbon is now hidden
on iOS (e56254e), and the underlying inconsistency was really a sizing/source
problem, fixed in 9b3384e by reading Capacitor's natively measured insets.

Recolor only — the safe zone keeps h-safe-top, so the natively measured inset on
Android 15+ and the env() fallback everywhere else are unchanged. Style.Light
pairs dark status-bar icons with the light strip.
fix(mobile): batch of native app fixes
…cation-error-uzgmxr

fix(claim): stop blaming the sender, and offer a retry when one would work (dev)
Artwork and manifest entry for the Help Shape Peanut interview badge
(catalog entry lands in peanut-api-ts). PNG like OFFRAMP_USER — the
vectorized SVG can replace it later without a code change.
TASK-21713
…rame

fix(native): accept Bridge ToS via the system browser on android
feat(badges): PEANUT_SHAPER asset + share line
users.locale on the BE was never written — notification emails could not
know the user's language. LocaleSync (below AppIntlProvider + AuthProvider)
sends the resolved app locale to /update-user, deduped per (user, locale)
via localStorage, synced on startup resolution and on a manual switch in
Settings. Pairs with peanut-api-ts#1387 (locale field + localized
badge-unlocked email).
feat: mirror the user's app locale to the BE for localized emails
The root layout mounts the full app provider stack on every route, so the
landing page pays for the wallet even though it never uses one. Gate the
wallet half on the route and take the analytics init off the critical path.

Follow-up to #2788, which shrank the fonts and fixed the hero LCP. The landing
page still scored 45 on mobile afterwards (LCP 4.6s, TBT ~4s), and the weight
left is JavaScript rather than images.

- ContextProvider splits into a core (toast + auth) and AppFlowProviders
  (kernel client, token context, the six transfer-flow contexts), the latter
  loaded as its own chunk. KernelClientProvider statically imports the ZeroDev
  SDK, so mounting it was enough to put the SDK on the landing page.
- WagmiProvider moves behind the same gate; the query client moves to
  config/queryClient so marketing routes keep react-query without wagmi.
- App-only globals (rain-cooldown modal, badge toast, app lock, PeanutDebug)
  move to AppGlobals. Two of them read contexts that now only exist on app
  routes, so they had to move with the providers.
- isMarketingRoute keys on the locale prefix, not the path segment: /withdraw
  is BOTH a marketing page under a locale and the app's withdraw flow under
  (mobile-ui), and matching the segment would have stripped the providers off
  the app route. Unknown paths fall through to the full tree.

The ENS client fallback used @justaname.id/react's usePrimaryName, whose
onChain path is ENS reverse resolution through a public client — viem, already
bundled for wagmi, does the same thing. Dropping the dependency takes siwe and
@ensdomains/ensjs out of the client entirely. normalizeEnsName also moves to
its own module so client components stop importing ens.utils, which pulls the
JustaName SDK for a pure string helper.

Analytics keep every event, just later:
- posthog session recording starts on idle instead of at init, so rrweb's
  recorder is no longer fetched and snapshotted mid-load. The opening moment
  of a replay is no longer captured; everything else is unchanged.
- Sentry.init defers to idle behind a buffer that holds anything thrown before
  the SDK exists and replays it on init, so error coverage is unchanged.
- gtag moves to lazyOnload — next emits a preload for afterInteractive
  scripts, which put 186 KB of gtag.js at High priority ahead of the LCP.
- whenIdle also fires on pagehide so a visitor who leaves early still counts.

Assets and misc:
- landing-countries.svg run through svgo at precision 1: 362 KB -> 43 KB
  gzipped, pixel-diffed against the original (RMS 2.4, edge antialiasing only).
- Londrina Solid is dead — declared with two weights, its CSS variable is
  referenced nowhere. Removed.
- sniglet and the two knerd faces stop preloading; they render below the fold
  and were competing with the hero image at High priority.
- i18n-iso-countries loads on demand, and the /qr-pay prefetch moves out of the
  root layout so marketing visitors stop paying for it.

Not verified locally: this machine ran out of disk before a build completed, so
there is no post-change Lighthouse run yet — see the PR description.
innolope-dev and others added 3 commits August 25, 2026 13:46
Incidents now render an impact line per service — what you can't do and
whether your money is safe — with the classified reason as a short second
clause. The feed no longer carries the provider's own error text, so
"Failed to get price: Company blocked." becomes "Deposits and withdrawals
in Brazilian reais, including Pix, are unavailable. Transfers already
submitted are safe and will complete once service is restored. The
provider is refusing our requests." — translated, in all four locales.

Also addresses review findings:

- The feed was cast with `as StatusSummary`, which is a compile-time claim
  about a runtime payload. A 200 carrying `{}` satisfied the type and then
  threw inside providers.map, past the point where the page could still
  show its fallback — on the one page whose entire job is to render when
  things are broken. It is validated now, and a malformed feed falls back.

- Incident times were formatted without a time zone. The page is
  server-rendered, so they came out in whatever zone the host runs in with
  nothing to interpret them against. Pinned to UTC, and the page says so.
…links

- Status moves out of the footer's top nav into "Learn More", directly
  below Supported Networks.

- The manifest shipped two pricing links into that column — the `pricing`
  resource (/pricing) and the `fees-pricing` article (/help/fees-pricing),
  rendering as "Pricing" and "Fees & Pricing". The resource keeps the slot
  under the clearer name "Fees and Pricing"; the article is dropped from
  the footer only.

- Site-directory links set text-decoration-skip-ink: none and a 2px
  offset. The browser default breaks the rule around descenders, so the
  "g" in a link read as though it were not underlined at all.

- Reserve /status. It is six lowercase characters, so it was
  recipient-shaped and the catch-all served a payment-profile shell on a
  200 instead of the status page — the same trap /pricing, /stories and
  /content already hit. Reserved here and 301'd to /en/status, as those
  were. NOTE: like 'stories' and 'content', 'status' is still claimable as
  a username server-side; the backend needs it on its reserved list too.
ci: stop double-running Tests on every PR commit
innolope-dev and others added 7 commits August 25, 2026 16:16
…fiat claim minimums

Guest bank claims are currently a dead end (GuestBankClaim is
hard-disabled and GuestKycNeeded is a signup wall), so naming bank in
the no-account sentence re-created the over-promise this branch
removes. The copy now promises account-less claims for crypto wallets
only, in all three locales.

The three fiat minimums now live in one CLAIM_RAIL_MINIMUMS map that
validateMinimumAmount, the claim action list and the send-time warning
gate all derive from, and a test pins the values equal — the warning
copy names the three rails as one class, so a per-rail divergence must
force the copy question instead of silently moving the gate.

Also drops a useMemo that memoized two comparisons.
A preview exists to review unreleased code, and the backend half of this
page ships on the same cycle: production's API answers /status/summary
with a 404 until dev reaches main, so every preview rendered the "could
not be loaded" state instead of the board under review.

Preview builds now read staging, which is also the honest pairing —
preview frontend against preview-era backend. Production is untouched and
still reads PEANUT_API_URL.

`STATUS_API_URL` overrides both. A status page is a monitoring surface and
there are legitimate reasons to point it at a backend other than the one
the app talks to; it also means this needs no Vercel dashboard change,
per-branch or otherwise.

VERCEL_ENV is read with the same server/NEXT_PUBLIC fallback pair as
src/utils/sentry-env.ts.
Header now matches the marketing pages: full-bleed pink band, drifting
clouds, and the Roboto Flex display face at the same weight and uppercase
treatment the MDX Hero uses. Content sits on bg-background like the other
marketing pages rather than plain white.

Not reusing <Hero> itself: it hard-codes a marquee reading "No fees ·
Instant · 24/7", and scrolling that above a live outage is the wrong thing
to say. Its CTA has no counterpart here either.

Separately, feed.test.ts declared a helper named `origin`, which collides
with the DOM global — jest transpiles without typechecking so it passed,
but `tsc` rejects it.
Header now uses the same <Hero> every marketing page uses (privacy,
pricing, help) rather than a lookalike, so the pink band, its height and
the yellow marquee are identical by construction — measured at 212px band
+ 60px marquee on both /en/privacy and /en/status. Subtitle is the window
label; the metadata sentence wrapped to three lines at the Hero's
uppercase 2rem.

Adds an operational-share donut: 144px, hand-rolled SVG so the page still
ships no client JS, the arc coloured by the worst state present so shape
says how much is broken and colour says how badly.

The figure weights the app and website at half on their own, with the
eight rails splitting the rest. Someone who cannot open Peanut at all has
lost more than someone who can use everything except one deposit rail, and
a flat per-service average scores those two the same.

The summary card holding it is behind SHOW_SUMMARY_CARD and not rendered —
the page opens at App & Account. `operationalScore` and `OperationalDonut`
are exported and tested directly, so hiding the card costs no coverage.
The page had a bare title and description and nothing else — pasted into
a chat during an incident it unfurled as a blank card, which is when the
link gets shared most.

Routes metadata through the shared `@/app/metadata` helper the other
marketing pages use, so it now carries og:title/description/url/site_name,
og:locale plus the three alternates, a summary_large_image twitter card,
canonical, and hreflang for all four locales. Registers `status` in
ROUTE_SLUGS so getAlternatesFor() types it.

`statusMetaTitle` is separate from `statusPageTitle`: the H1 stays the
bare brand mark, while the meta title also has to say what the page is in
a search result or an unfurl.

The OG image is the branded dynamic card rather than the generic
marketing PNG. Verified rendering at 1200x630.

Still noindex — nothing here should compete with the marketing pages, and
a stale cached copy of an outage is worse than none. Unfurls are
unaffected: og:/twitter: tags are read regardless of robots.
The feed origin followed the deployment: previews read staging. That made
the preview report staging's health under a production banner, and today
it did exactly that — staging's Manteca credentials started failing
authorization at 18:25 UTC, so the preview showed Argentina and Brazil
down while production priced both currencies normally.

A status page is a monitoring surface rather than part of the app, so it
now reads api.peanut.me in every environment. A broken staging deploy can
no longer read as a user-facing outage. STATUS_API_URL still overrides,
for local work against a fixture or a deliberate review build.

Until dev reaches main, production answers /status/summary with a 404 and
every environment shows the "could not be loaded" state. That state now
renders the same Hero as the loaded page — it is the view on show whenever
the feed's own backend is what's down, so it should not look like an
error page.
feat(status): public status page at /status
innolope-dev and others added 3 commits August 26, 2026 12:54
Restricted Android in-app browsers expose `localStorage` as null, and some
Chrome documents throw SecurityError from the property getter itself. Two
startup paths dereferenced it unguarded:

- locale-store checked only for `undefined`, so a null storage passed the
  guard and `getItem` threw. The rejection was then memoized by
  `localeReady()`, which AppIntlProvider awaits without a rejection handler,
  leaving supported-language visitors in English (PEANUT-UI-STC).
- wagmi's default storage reads `window.localStorage` unguarded inside
  `getDefaultStorage()`, and our `createConfig` runs at module scope — a
  throwing getter took the app shell down before it rendered (PEANUT-UI-STF).

Both now go through a shared `safe-storage` helper that guards the property
access and every storage method, falling through to the browser-language
default. `localeReady()` also catches, so a memoized failure can no longer
wedge the session.
iOS reclaims the capture device while the app is backgrounded, so the stopped
scanner's own `start()` can come back "Camera not found" on resume
(PEANUT-UI-SV1). The visibilitychange handler only logged that rejection and
left `isCameraReady` false, so the scanner sat on "Starting camera…" forever
with no way out but closing the drawer.

Route foreground resume through `startCamera` instead: it rebuilds the scanner
after the iOS hardware settle delay and, on failure, lands on the existing
error view. A `isStartingCameraRef` guard keeps overlapping visibility events —
or a retry racing a resume — from starting two cameras at once, and the error
view now offers Retry alongside Close and the paste fallback.
…torage-guard

fix(startup): survive a null or throwing localStorage
innolope-dev and others added 3 commits August 26, 2026 15:16
Replaces the isStartingCameraRef guard, which reintroduced the black-video
state it was meant to prevent: a resume landing mid-start was dropped, the
in-flight start() then resolved with no stream (qr-scanner discards it once
_active is false), and the hook reported ready over a dead <video>. The same
guard also swallowed Retry taps while a start was in flight — on Capacitor a
hung getUserMedia pinned it until unmount.

Resume now calls the scanner's own start(), which re-arms the instance the
hidden branch stopped, and only falls back to a full startCamera rebuild when
that rejects. The common foreground path no longer rebuilds the decode worker.

Also from the review:
- startCamera carries a generation counter re-checked after each await, so a
  superseded or closed start abandons instead of committing state.
- A start that resolves without a stream now fails closed to the error view
  rather than dismissing the spinner over a black video.
- Resume requires a live scannerRef again, so it no longer re-enters the
  camera flow while the permission modal or error view is up.
- retryCamera is wrapped, so callers wiring it to onClick can no longer pass a
  MouseEvent as preferredCamera.
- toggleCamera bails if the scanner was rebuilt during the handover.
- handleQRScan's two post-scan restarts get the same rebuild-on-rejection path.

Tests use fake timers instead of a real 150ms sleep, and the qr-scanner mock
now models the _active/srcObject contract the hook relies on.
Follow-up to #2822.

localeReady's catch was silent: neither caller attaches a handler, so the
unhandled rejection was the only reason PEANUT-UI-STC was ever seen. It now
warns before falling back, which captureConsoleIntegration reports, without
reintroducing the memoized-rejection wedge.

persistLocale's Cookies.set is guarded too. The sandboxed and opaque-origin
documents that make localStorage throw do the same for document.cookie, and
LocaleSwitcher calls persistLocale straight from an onClick with nothing
upstream to catch it.

Adds the first test that loads the real wagmi.config: jest's moduleNameMapper
stubs the alias for the whole suite, so nothing exercised the storage wiring
that keeps the app shell alive (PEANUT-UI-STF). Required by relative path,
which the anchored mapper pattern does not intercept. Dropping the storage
line now fails the suite.
…-resume

fix(qr-scanner): recover the camera when the app returns to foreground
…ew-followup

fix(startup): keep a signal when locale resolution falls back
…-minimum

fix(send): stop over-promising account-less claims and surface the $5 fiat minimum at send time
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants