From 34e3c2c8fbbb5d7b7b42b16112fcf38cbba482fa Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Tue, 18 Aug 2026 16:55:11 +0100 Subject: [PATCH 01/93] feat(support): surface live verification state to Crisp agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/components/Global/SupportDrawer/index.tsx | 12 ++ src/hooks/useCrispUserData.ts | 21 +++- .../__tests__/support-verification.test.ts | 107 ++++++++++++++++++ src/utils/crisp.ts | 12 ++ src/utils/support-verification.ts | 77 +++++++++++++ 5 files changed, 228 insertions(+), 1 deletion(-) create mode 100644 src/utils/__tests__/support-verification.test.ts create mode 100644 src/utils/support-verification.ts diff --git a/src/components/Global/SupportDrawer/index.tsx b/src/components/Global/SupportDrawer/index.tsx index e07ce2cfbd..fccb27a740 100644 --- a/src/components/Global/SupportDrawer/index.tsx +++ b/src/components/Global/SupportDrawer/index.tsx @@ -176,6 +176,18 @@ const SupportDrawer = () => { if (userData.userId) { CapacitorCrisp.setString({ key: 'user_id', value: userData.userId }) } + // live verification state so agents stop guessing (#2360). Always + // write (empty string when absent) so a prior user's values can't + // linger on the device-local Crisp session — matching web/proxy. + CapacitorCrisp.setString({ key: 'identity_status', value: userData.identityStatus || '' }) + CapacitorCrisp.setString({ + key: 'email_on_file', + value: userData.emailOnFile === undefined ? '' : userData.emailOnFile ? 'yes' : 'no', + }) + CapacitorCrisp.setString({ key: 'verification_gates', value: userData.verificationGates || '' }) + CapacitorCrisp.setString({ key: 'verification_rails', value: userData.verificationRails || '' }) + CapacitorCrisp.setString({ key: 'failure_reason', value: userData.failureReason || '' }) + CapacitorCrisp.setString({ key: 'pending_actions', value: userData.pendingActions || '' }) if (prefilledMessage) { CapacitorCrisp.sendMessage({ value: prefilledMessage }) } diff --git a/src/hooks/useCrispUserData.ts b/src/hooks/useCrispUserData.ts index dc41d2d72a..ff5457e781 100644 --- a/src/hooks/useCrispUserData.ts +++ b/src/hooks/useCrispUserData.ts @@ -1,3 +1,4 @@ +import { buildSupportVerificationSummary } from '@/utils/support-verification' import { useAuth } from '@/context/authContext' import { AccountType } from '@/interfaces/interfaces' import { useMemo } from 'react' @@ -14,6 +15,13 @@ export interface CrispUserData { bridgeCustomerLink: string | undefined mantecaUserId: string | undefined posthogPersonLink: string | undefined + // Live verification state so agents stop guessing where a user is stuck (#2360). + identityStatus: string | undefined + emailOnFile: boolean | undefined + verificationGates: string | undefined + verificationRails: string | undefined + failureReason: string | undefined + pendingActions: string | undefined } /** @@ -44,10 +52,15 @@ export function useCrispUserData(): CrispUserData { const posthogPersonLink = userId ? `${POSTHOG_PERSON_BASE_URL}/${userId}` : undefined + const email = user?.user?.email || undefined + const verification = user + ? buildSupportVerificationSummary(user.capabilities, user.identityVerification, email) + : undefined + return { username, userId, - email: user?.user?.email || undefined, + email, fullName: user?.user?.fullName, avatar: user?.user?.profile_picture || undefined, walletAddress, @@ -55,6 +68,12 @@ export function useCrispUserData(): CrispUserData { bridgeCustomerLink, mantecaUserId, posthogPersonLink, + identityStatus: verification?.identityStatus, + emailOnFile: verification?.emailOnFile, + verificationGates: verification?.gates, + verificationRails: verification?.verificationRails, + failureReason: verification?.failureReason, + pendingActions: verification?.pendingActions, } }, [username, userId, user]) } diff --git a/src/utils/__tests__/support-verification.test.ts b/src/utils/__tests__/support-verification.test.ts new file mode 100644 index 0000000000..c00ca4a34d --- /dev/null +++ b/src/utils/__tests__/support-verification.test.ts @@ -0,0 +1,107 @@ +import { buildSupportVerificationSummary } from '../support-verification' +import type { IdentityVerification, RailCapability, UserCapabilities } from '@/types/capabilities' + +function caps(rails: RailCapability[], nextActions: UserCapabilities['nextActions'] = []): UserCapabilities { + return { rails, nextActions, restrictions: [] } +} + +const enabledRail: RailCapability = { + id: 'bridge.ach_us', + provider: 'bridge', + method: 'ACH_US', + channel: 'bank', + country: 'US', + currency: 'USD', + status: 'enabled', + resolved: { status: 'enabled' }, +} + +const emailBlockedRail: RailCapability = { + id: 'manteca.pix_br', + provider: 'manteca', + method: 'PIX_BR', + channel: 'bank', + country: 'BR', + currency: 'BRL', + status: 'blocked', + reason: { + code: 'no_email_captured', + userMessage: 'We need your email', + details: 'No email captured during submission', + }, + resolved: { + status: 'fixable', + blocking: { + code: 'no_email_captured', + userMessage: 'We need your email', + selfHealable: true, + selfHealKind: 'provide-email', + details: 'No email captured during submission', + }, + }, +} + +describe('buildSupportVerificationSummary', () => { + test('reports identity status, email-on-file and per-op gates', () => { + const identity: IdentityVerification = { status: 'verified' } + const summary = buildSupportVerificationSummary(caps([enabledRail]), identity, 'a@b.com') + + expect(summary.identityStatus).toBe('verified') + expect(summary.emailOnFile).toBe(true) + expect(summary.gates).toContain('pay:ready') + }) + + test('surfaces the stuck rail + failure reason', () => { + const identity: IdentityVerification = { status: 'verified' } + const summary = buildSupportVerificationSummary(caps([emailBlockedRail]), identity, undefined) + + expect(summary.emailOnFile).toBe(false) + expect(summary.failureReason).toBe('manteca.pix_br · no_email_captured — No email captured during submission') + expect(summary.gates).toContain('deposit:provide-email') + expect(summary.verificationRails).toBe('manteca.pix_br:fixable(no_email_captured)') + }) + + test('names the rail behind a pending/waiting gate that has no failure reason', () => { + const waitingRail: RailCapability = { + id: 'manteca.bank_transfer_ar', + provider: 'manteca', + method: 'BANK_TRANSFER_AR', + channel: 'bank', + country: 'AR', + currency: 'ARS', + status: 'pending', + resolved: { status: 'pending' }, + } + const summary = buildSupportVerificationSummary(caps([waitingRail]), { status: 'verified' }, 'a@b.com') + + // no failure to report, but the agent must still see WHICH rail is stuck + expect(summary.failureReason).toBeUndefined() + expect(summary.verificationRails).toBe('manteca.bank_transfer_ar:pending') + }) + + test('lists pending next-actions as kind(purpose)', () => { + const summary = buildSupportVerificationSummary( + caps([emailBlockedRail], [{ key: 'k1', kind: 'provide-email', purpose: 'unlock-manteca-pix' }]), + { status: 'verified' }, + undefined + ) + expect(summary.pendingActions).toBe('provide-email(unlock-manteca-pix)') + }) + + test('degrades cleanly with no read-models', () => { + const summary = buildSupportVerificationSummary(undefined, undefined, undefined) + expect(summary.identityStatus).toBe('unknown') + expect(summary.emailOnFile).toBe(false) + expect(summary.failureReason).toBeUndefined() + expect(summary.pendingActions).toBeUndefined() + }) + + test('reports action_required identity status', () => { + const summary = buildSupportVerificationSummary( + caps([enabledRail]), + { status: 'action_required', actionMessage: 'Re-upload your document' }, + 'a@b.com' + ) + expect(summary.identityStatus).toBe('action_required') + }) +}) diff --git a/src/utils/crisp.ts b/src/utils/crisp.ts index aec45cc58a..d9f2e046b9 100644 --- a/src/utils/crisp.ts +++ b/src/utils/crisp.ts @@ -31,6 +31,12 @@ export function setCrispUserData( bridgeCustomerLink, mantecaUserId, posthogPersonLink, + identityStatus, + emailOnFile, + verificationGates, + verificationRails, + failureReason, + pendingActions, } = userData if (email) { @@ -59,6 +65,12 @@ export function setCrispUserData( ['bridge_user_id', bridgeCustomerLink || ''], ['manteca_user_id', mantecaUserId || ''], ['posthog_person', posthogPersonLink || ''], + ['identity_status', identityStatus || ''], + ['email_on_file', emailOnFile === undefined ? '' : emailOnFile ? 'yes' : 'no'], + ['verification_gates', verificationGates || ''], + ['verification_rails', verificationRails || ''], + ['failure_reason', failureReason || ''], + ['pending_actions', pendingActions || ''], ], ], ]) diff --git a/src/utils/support-verification.ts b/src/utils/support-verification.ts new file mode 100644 index 0000000000..54fcc05ef5 --- /dev/null +++ b/src/utils/support-verification.ts @@ -0,0 +1,77 @@ +/** + * Support-facing verification snapshot — the state a Crisp agent needs to stop + * guessing where a user is stuck (issue #2360). Reads the two backend read-models + * already on the /get-user response (`capabilities`, `identityVerification`) and + * the on-file email; derives nothing the FE doesn't already render for the user. + * + * Everything here is pushed into Crisp `session:data` — the agent sidebar, which + * only the support agent sees. The user's own message is never touched. + */ + +import { deriveGate, railVerdict } from '@/utils/capability-gate' +import type { IdentityVerification, RailOperation, UserCapabilities } from '@/types/capabilities' + +const SUMMARY_OPERATIONS: RailOperation[] = ['pay', 'deposit', 'withdraw'] + +export interface SupportVerificationSummary { + /** identityVerification.status, or 'unknown' when the read-model is absent. */ + identityStatus: string + /** whether an email is on file — provider submission can't run without one. */ + emailOnFile: boolean + /** per-operation gate kinds, e.g. "pay:ready deposit:provide-email withdraw:blocked-rejection". */ + gates: string + /** every non-enabled rail as "id:status(reasonCode)" — names which rail is stuck, even for pending/waiting gates. */ + verificationRails?: string + /** the stuck rail's id + normalized reason code + technical details, if any. */ + failureReason?: string + /** pending next-actions as "kind(purpose)", comma-joined. */ + pendingActions?: string +} + +export function buildSupportVerificationSummary( + capabilities: UserCapabilities | undefined, + identityVerification: IdentityVerification | undefined, + email: string | undefined +): SupportVerificationSummary { + const rails = capabilities?.rails ?? [] + const nextActions = capabilities?.nextActions ?? [] + const identityStatus = identityVerification?.status ?? 'unknown' + const identityVerified = identityStatus === 'verified' + const emailOnFile = Boolean(email) + + const gateState = { rails, nextActions, identityVerified, isLoading: false } + const gates = SUMMARY_OPERATIONS.map((op) => `${op}:${deriveGate(gateState, op).kind}`).join(' ') + + // Per-rail verdicts. `gates` gives the op-level kind but not the rail; this + // names every rail that isn't clear (blocked/fixable/pending/requires-info) + // so an agent can see WHICH rail is stuck even for pending/waiting gates, + // where there's no failureReason to fall back on. + const actionByKey = new Map(nextActions.map((action) => [action.key, action])) + const railStates = rails + .map((rail) => ({ rail, verdict: railVerdict(rail, actionByKey) })) + .filter(({ verdict }) => verdict.status !== 'enabled') + + const verificationRails = railStates.length + ? railStates + .map( + ({ rail, verdict }) => + `${rail.id}:${verdict.status}${verdict.blocking?.code ? `(${verdict.blocking.code})` : ''}` + ) + .join(' ') + : undefined + + // The one blocker worth its technical detail — provider `details` (e.g. + // "No email captured…") is what the support agent actually needs. + const blocked = railStates.find(({ verdict }) => verdict.status === 'blocked' || verdict.status === 'fixable') + let failureReason: string | undefined + if (blocked?.verdict.blocking) { + const { code, details } = blocked.verdict.blocking + failureReason = `${blocked.rail.id} · ${code}${details ? ` — ${details}` : ''}` + } + + const pendingActions = nextActions.length + ? nextActions.map((action) => `${action.kind}(${action.purpose})`).join(', ') + : undefined + + return { identityStatus, emailOnFile, gates, verificationRails, failureReason, pendingActions } +} From 216dab77e7aa26314d9247691d9ae9594f850d4e Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Tue, 18 Aug 2026 16:55:32 +0100 Subject: [PATCH 02/93] fix(sentry): match ignore patterns against every exception in a chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 10ee1601 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. --- sentry.utils.test.ts | 81 ++++++++++++++++++++++++++++++++++++++++++++ sentry.utils.ts | 33 +++++++++++++++--- 2 files changed, 110 insertions(+), 4 deletions(-) diff --git a/sentry.utils.test.ts b/sentry.utils.test.ts index 20754ee765..2e0a2dc59b 100644 --- a/sentry.utils.test.ts +++ b/sentry.utils.test.ts @@ -21,3 +21,84 @@ describe('shouldIgnoreError — alreadyReported (fetchWithSentry wrapper)', () = expect(shouldIgnoreError(eventWith({ type: 'TypeError', value: 'x is not a function' }))).toBe(false) }) }) + +// Sentry orders `exception.values` root-cause-first, so a wrapper carrying a +// `cause` lands at the end of the array — where the old values[0]-only lookup +// never saw it. +function chainedEvent(values: Array<{ type: string; value: string }>): ErrorEvent { + return { exception: { values } } as unknown as ErrorEvent +} + +describe('shouldIgnoreError — chained exceptions', () => { + it('ignores a wrapper that is not the first value (the real PEANUT-UI-SNP shape)', () => { + const event = chainedEvent([ + { type: 'Error', value: 'Request to https://api.peanut.me/bridge/exchange-rate timed out after 20000ms' }, + { type: 'ServiceUnavailableError', value: 'Service temporarily unavailable. Please try again.' }, + ]) + expect(shouldIgnoreError(event)).toBe(true) + }) + + it('ignores a ConnectionTimeoutError wrapper behind its cause', () => { + const event = chainedEvent([ + { type: 'Error', value: 'aborted' }, + { type: 'ConnectionTimeoutError', value: 'Peanut is taking too long to respond' }, + ]) + expect(shouldIgnoreError(event)).toBe(true) + }) + + it('still reports a chain with no ignorable link', () => { + const event = chainedEvent([ + { type: 'RangeError', value: 'invalid array length' }, + { type: 'CardIssuanceError', value: 'could not issue card' }, + ]) + expect(shouldIgnoreError(event)).toBe(false) + }) + + it('finds extension frames outside the first value', () => { + const event = { + exception: { + values: [ + { type: 'Error', value: 'inner' }, + { + type: 'TypeError', + value: 'outer', + stacktrace: { frames: [{ filename: 'chrome-extension://abc/content.js' }] }, + }, + ], + }, + } as unknown as ErrorEvent + expect(shouldIgnoreError(event)).toBe(true) + }) +}) + +describe('shouldIgnoreError — Capgo updater noise', () => { + it.each([ + '[CapgoUpdater] 🔴 Failed to send stats batch', + '[CapgoUpdater] 🔴 Error waiting for download', + '[CapgoUpdater] 🔴 Download error: unexpected end of stream', + '🔴 ✨ CapgoUpdater : Semaphore wait timed out after 15000ms', + '[capgo] update check failed: network_error', + ])('ignores transient updater failure: %s', (message) => { + expect(shouldIgnoreError(eventWith({ message }))).toBe(true) + }) + + it('keeps disable_auto_update_under_native — OTA is dead for that binary', () => { + expect( + shouldIgnoreError(eventWith({ message: '[capgo] update check failed: disable_auto_update_under_native' })) + ).toBe(false) + }) + + it('keeps disable_auto_update_under_native from the plugin channel too', () => { + const message = + '[CapgoUpdater] 🔴 getLatest failed with error: disable_auto_update_under_native, message: Cannot revert under native version' + expect(shouldIgnoreError(eventWith({ message }))).toBe(false) + }) + + it('keeps a checksum mismatch — the bundle arrived corrupt, not merely late', () => { + expect(shouldIgnoreError(eventWith({ message: '[CapgoUpdater] 🔴 Checksum mismatch' }))).toBe(false) + }) + + it('does not touch non-Capgo errors that mention a download', () => { + expect(shouldIgnoreError(eventWith({ type: 'Error', value: 'Download error: statement failed' }))).toBe(false) + }) +}) diff --git a/sentry.utils.ts b/sentry.utils.ts index b5191f2243..fd3a018bf9 100644 --- a/sentry.utils.ts +++ b/sentry.utils.ts @@ -59,18 +59,39 @@ const IGNORED_ERRORS = { ], } +/** + * Capgo's background updater logs every transient CDN/network hiccup at error + * level, and captureConsoleIntegration promotes each one into a Sentry event + * (~95/day on native). The user never sees them: the updater just retries on + * the next launch. Suppress those, but keep the failures that mean OTA is + * genuinely broken rather than merely flaky — a bundle that semver-sorts below + * the installed binary, or one that arrived corrupt. + */ +const CAPGO_LOG_PREFIXES = ['[CapgoUpdater]', 'CapgoUpdater :', '[capgo]'] +const CAPGO_ACTIONABLE = ['disable_auto_update_under_native', 'Checksum mismatch'] + +function isTransientCapgoNoise(searchTexts: string[]): boolean { + const fromCapgo = searchTexts.some((text) => CAPGO_LOG_PREFIXES.some((prefix) => text.includes(prefix))) + if (!fromCapgo) return false + return !searchTexts.some((text) => CAPGO_ACTIONABLE.some((pattern) => text.includes(pattern))) +} + /** * Check if error message matches any ignored pattern */ export function shouldIgnoreError(event: ErrorEvent): boolean { const message = event.message || '' - const exceptionValue = event.exception?.values?.[0]?.value || '' - const exceptionType = event.exception?.values?.[0]?.type || '' const culprit = (event as any).culprit || '' + // Every link in the chain, not just values[0]. Sentry orders `exception.values` + // root-cause-first, so for an error carrying a `cause` the wrapper we actually + // want to match sits at the END. fetchWithSentry always sets `userError.cause`, + // which left `alreadyReported` inert for every chained fetch failure — the case + // it was written for (PEANUT-UI-SNP double-counted PEANUT-UI-QEY for a month). + const exceptionTexts = (event.exception?.values ?? []).flatMap((v) => [v.value || '', v.type || '']) // Match each field independently — concatenating them would let a pattern // match across unrelated fields and suppress a legitimate event. - const searchTexts = [message, exceptionValue, exceptionType, culprit] + const searchTexts = [message, culprit, ...exceptionTexts] // Check all ignore patterns for (const patterns of Object.values(IGNORED_ERRORS)) { @@ -81,8 +102,12 @@ export function shouldIgnoreError(event: ErrorEvent): boolean { } } + if (isTransientCapgoNoise(searchTexts)) { + return true + } + // Ignore errors from browser extensions (client-side only, but safe to check everywhere) - const frames = event.exception?.values?.[0]?.stacktrace?.frames || [] + const frames = (event.exception?.values ?? []).flatMap((v) => v.stacktrace?.frames ?? []) for (const frame of frames) { const filename = frame.filename || '' if ( From c45f7655f1ebe318fcfaf0241742d06b4f84f16f Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Tue, 18 Aug 2026 17:01:25 +0100 Subject: [PATCH 03/93] chore(native): retire the transport-migration probes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/config/peanut.config.tsx | 2 - src/constants/analytics.consts.ts | 1 + src/services/onesignal/native.adapter.ts | 25 +-- src/utils/__tests__/native-canary.test.ts | 180 ------------------ src/utils/__tests__/sentry-fallback.test.ts | 13 +- .../__tests__/sentry-prefer-native.test.ts | 9 +- src/utils/native-canary.ts | 166 ---------------- src/utils/sentry.utils.ts | 34 ---- 8 files changed, 22 insertions(+), 408 deletions(-) delete mode 100644 src/utils/__tests__/native-canary.test.ts delete mode 100644 src/utils/native-canary.ts diff --git a/src/config/peanut.config.tsx b/src/config/peanut.config.tsx index 60108d821c..f434a05da3 100644 --- a/src/config/peanut.config.tsx +++ b/src/config/peanut.config.tsx @@ -10,7 +10,6 @@ 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' // 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 @@ -29,7 +28,6 @@ export function PeanutProvider({ children }: { children: React.ReactNode }) { 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/constants/analytics.consts.ts b/src/constants/analytics.consts.ts index 8aee82f25c..f5fe81692a 100644 --- a/src/constants/analytics.consts.ts +++ b/src/constants/analytics.consts.ts @@ -119,6 +119,7 @@ export const ANALYTICS_EVENTS = { NOTIFICATION_PERMISSION_DENIED: 'notification_permission_denied', NOTIFICATION_SUBSCRIBED: 'notification_subscribed', NOTIFICATION_CLICKED: 'notification_clicked', + NOTIFICATION_SUBSCRIPTION_SNAPSHOT: 'notification_subscription_snapshot', // ── Modal Fatigue ── MODAL_SHOWN: 'modal_shown', diff --git a/src/services/onesignal/native.adapter.ts b/src/services/onesignal/native.adapter.ts index eab0d38867..ea31414b44 100644 --- a/src/services/onesignal/native.adapter.ts +++ b/src/services/onesignal/native.adapter.ts @@ -1,8 +1,10 @@ import OneSignal, { LogLevel } from '@onesignal/capacitor-plugin' import { captureMessage } from '@sentry/nextjs' +import posthog from 'posthog-js' import type { NotificationClickEvent, PushSubscriptionChangedState } from '@onesignal/capacitor-plugin' import type { NotificationClickInfo, NotificationPermissionState, OneSignalAdapter } from './types' import { isOneSignalDebug } from './debug' +import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' async function nativePermission(): Promise { if (await OneSignal.Notifications.hasPermission()) return 'granted' @@ -37,6 +39,9 @@ const snapshotTriggersFired = new Set() * Captured once per session per trigger — ~10s after init (token registration * and login are async) and again on the first subscription change (opt-in * often lands after the init snapshot). Deliberately omits the raw token. + * + * This is a state fact, not a fault, so it goes to PostHog. Only a failure to + * read the state is an error worth Sentry. */ function captureSubscriptionSnapshot(trigger: string) { if (snapshotTriggersFired.has(trigger)) return @@ -51,18 +56,14 @@ function captureSubscriptionSnapshot(trigger: string) { OneSignal.User.getExternalId(), nativePermission(), ]) - captureMessage('onesignal subscription snapshot', { - level: 'info', - tags: { - feature: 'onesignal', - onesignal: 'subscription-snapshot', - 'onesignal.trigger': trigger, - 'onesignal.permission': permission, - 'onesignal.has_token': String(!!token), - 'onesignal.opted_in': String(optedIn), - 'onesignal.linked': String(!!externalId), - }, - extra: { subscriptionId, onesignalId }, + posthog.capture(ANALYTICS_EVENTS.NOTIFICATION_SUBSCRIPTION_SNAPSHOT, { + trigger, + permission, + has_token: !!token, + opted_in: optedIn, + linked: !!externalId, + subscription_id: subscriptionId, + onesignal_id: onesignalId, }) } catch (err) { captureMessage('onesignal subscription snapshot failed', { diff --git a/src/utils/__tests__/native-canary.test.ts b/src/utils/__tests__/native-canary.test.ts deleted file mode 100644 index a5994237ea..0000000000 --- a/src/utils/__tests__/native-canary.test.ts +++ /dev/null @@ -1,180 +0,0 @@ -import type { captureMessage } from '@sentry/nextjs' - -jest.mock('@sentry/nextjs', () => ({ - captureMessage: jest.fn(), -})) - -jest.mock('@/constants/general.consts', () => ({ - PEANUT_API_URL: 'https://api.test.com', -})) - -jest.mock('@/utils/capacitor', () => ({ - isCapacitor: jest.fn(() => true), -})) - -jest.mock('@/utils/auth-token', () => ({ - authReady: jest.fn(() => Promise.resolve()), - getAuthToken: jest.fn(() => 'test-token'), -})) - -jest.mock('@/utils/native-auth-capture', () => ({ - getUnderlyingFetch: jest.fn(() => null), -})) - -const mockNativeHttpRequest = jest.fn(() => Promise.resolve({ status: 200 } as Response)) -jest.mock('@/utils/native-http', () => ({ - nativeHttpRequest: (...args: unknown[]) => mockNativeHttpRequest(...(args as [])), -})) - -jest.mock( - '@capacitor/app', - () => ({ - App: { getInfo: jest.fn(() => Promise.resolve({ version: '1.0.31', build: '123' })) }, - }), - { virtual: true } -) - -const PROBE_NAMES = ['get-healthz', 'get-users-me', 'post-healthz', 'get-healthz-nocors', 'get-healthz-native'] - -// the module keeps a one-shot `scheduled` flag, so reload it for every test -// (jest.resetModules in beforeEach); resolve the mock from the same registry -const loadCanary = () => require('../native-canary') as typeof import('../native-canary') - -const getCaptureMessage = () => - (require('@sentry/nextjs') as { captureMessage: jest.MockedFunction }).captureMessage - -const flush = async () => { - for (let i = 0; i < 20; i++) { - await Promise.resolve() - jest.advanceTimersByTime(0) - } -} - -describe('scheduleDirectFetchCanary', () => { - beforeEach(() => { - jest.resetModules() - jest.useFakeTimers() - jest.clearAllMocks() - mockNativeHttpRequest.mockImplementation(() => Promise.resolve({ status: 200 } as Response)) - global.fetch = jest.fn(() => Promise.resolve({ status: 401 } as Response)) - Object.defineProperty(navigator, 'serviceWorker', { value: { controller: null }, configurable: true }) - }) - - afterEach(() => { - jest.useRealTimers() - }) - - it('runs all five probes with per-probe messages (dedupe cannot collapse them)', async () => { - loadCanary().scheduleDirectFetchCanary(0) - jest.advanceTimersByTime(0) - await flush() - - const mockCaptureMessage = getCaptureMessage() - expect(mockCaptureMessage).toHaveBeenCalledTimes(5) - const messages = mockCaptureMessage.mock.calls.map((c) => c[0]) - expect(messages).toEqual(PROBE_NAMES.map((name) => `direct-fetch canary ${name}`)) - expect(new Set(messages).size).toBe(5) - const probeNames = mockCaptureMessage.mock.calls.map((c) => (c[1] as any).tags.probe) - expect(probeNames).toEqual(PROBE_NAMES) - - const tags = (mockCaptureMessage.mock.calls[1][1] as any).tags - expect(tags).toMatchObject({ - canary: 'direct-fetch', - canaryVersion: '3', - outcome: 'http-401', - transport: 'direct', - authMode: 'bearer', - appVersion: '1.0.31', - appBuild: '123', - }) - // swControlled is a global tag set in instrumentation-client.ts, not here - expect(tags.swControlled).toBeUndefined() - }) - - it('sends Authorization only on the users/me probe and no credentials anywhere', async () => { - loadCanary().scheduleDirectFetchCanary(0) - jest.advanceTimersByTime(0) - await flush() - - const calls = (global.fetch as jest.Mock).mock.calls - expect(calls).toHaveLength(4) - expect(calls[0][0]).toBe('https://api.test.com/healthz') - expect(calls[1][0]).toBe('https://api.test.com/users/me') - expect(calls[1][1].headers).toEqual({ Authorization: 'Bearer test-token' }) - expect(calls[2][1].method).toBe('POST') - expect(calls[3][1].mode).toBe('no-cors') - for (const [, init] of calls) { - expect(init.credentials).toBeUndefined() - } - }) - - it('probes the native transport via nativeHttpRequest, not fetch', async () => { - loadCanary().scheduleDirectFetchCanary(0) - jest.advanceTimersByTime(0) - await flush() - - expect(mockNativeHttpRequest).toHaveBeenCalledTimes(1) - expect(mockNativeHttpRequest).toHaveBeenCalledWith('https://api.test.com/healthz', { method: 'GET' }, 10_000) - const nativeEvent = getCaptureMessage().mock.calls[4][1] as any - expect(nativeEvent.tags).toMatchObject({ - probe: 'get-healthz-native', - transport: 'cap-native-http', - outcome: 'http-200', - }) - }) - - it('reports an opaque outcome for the no-cors probe', async () => { - global.fetch = jest.fn((_url, init?: RequestInit) => - init?.mode === 'no-cors' - ? Promise.resolve({ status: 0, type: 'opaque' } as Response) - : Promise.reject(new TypeError('Failed to fetch')) - ) as unknown as typeof fetch - loadCanary().scheduleDirectFetchCanary(0) - jest.advanceTimersByTime(0) - await flush() - - const byProbe = Object.fromEntries( - getCaptureMessage().mock.calls.map((c) => [(c[1] as any).tags.probe, c[1] as any]) - ) - expect(byProbe['get-healthz'].tags.outcome).toBe('network-error') - expect(byProbe['get-healthz-nocors'].tags.outcome).toBe('opaque') - }) - - it('reports errorName and errorMessage on rejection', async () => { - global.fetch = jest.fn(() => Promise.reject(new TypeError('Failed to fetch: net::ERR_FAILED'))) - loadCanary().scheduleDirectFetchCanary(0) - jest.advanceTimersByTime(0) - await flush() - - const { tags, extra } = getCaptureMessage().mock.calls[0][1] as any - expect(tags.outcome).toBe('network-error') - expect(extra.errorName).toBe('TypeError') - expect(extra.errorMessage).toBe('Failed to fetch: net::ERR_FAILED') - }) - - it('reports proxied transport only when the pre-wrap fetch was patched', async () => { - const capFetch = jest.fn() - ;(window as any).CapacitorWebFetch = capFetch - // underlying fetch (pre-wrap) === CapacitorWebFetch → proxy NOT active, - // even though window.fetch has since been wrapped by auth-capture - const { getUnderlyingFetch } = require('@/utils/native-auth-capture') - ;(getUnderlyingFetch as jest.Mock).mockReturnValue(capFetch) - - loadCanary().scheduleDirectFetchCanary(0) - jest.advanceTimersByTime(0) - await flush() - - const tags = (getCaptureMessage().mock.calls[0][1] as any).tags - expect(tags.transport).toBe('direct') - delete (window as any).CapacitorWebFetch - }) - - it('is one-shot per module load', async () => { - const canary = loadCanary() - canary.scheduleDirectFetchCanary(0) - canary.scheduleDirectFetchCanary(0) - jest.advanceTimersByTime(0) - await flush() - expect(getCaptureMessage()).toHaveBeenCalledTimes(5) - }) -}) diff --git a/src/utils/__tests__/sentry-fallback.test.ts b/src/utils/__tests__/sentry-fallback.test.ts index 6946375c86..e91d46b5bb 100644 --- a/src/utils/__tests__/sentry-fallback.test.ts +++ b/src/utils/__tests__/sentry-fallback.test.ts @@ -50,17 +50,12 @@ describe('fetchWithSentry native fallback', () => { expect.any(Number) ) expect(reportNetworkError).not.toHaveBeenCalled() - // engaged notice fires once per session, not per request - const engaged = (Sentry.captureMessage as jest.Mock).mock.calls.filter( - (c) => c[0] === 'native http fallback engaged' - ) - expect(engaged).toHaveLength(1) - + // the rescue itself is not a Sentry event — only a genuine failure is await fetchWithSentry('https://api.test.com/misc', { method: 'GET' }) - const engagedAfter = (Sentry.captureMessage as jest.Mock).mock.calls.filter( - (c) => c[0] === 'native http fallback engaged' + const engaged = (Sentry.captureMessage as jest.Mock).mock.calls.filter((c) => + String(c[0]).includes('fallback engaged') ) - expect(engagedAfter).toHaveLength(1) + expect(engaged).toHaveLength(0) }) it('still reports non-ok statuses from the fallback transport', async () => { diff --git a/src/utils/__tests__/sentry-prefer-native.test.ts b/src/utils/__tests__/sentry-prefer-native.test.ts index d798c2ee70..b2400138bf 100644 --- a/src/utils/__tests__/sentry-prefer-native.test.ts +++ b/src/utils/__tests__/sentry-prefer-native.test.ts @@ -31,8 +31,8 @@ const fakeResponse = (status: number) => clone: () => ({ json: async () => ({ error: 'x' }), text: async () => 'x' }), }) as unknown as Response -const engagedNotices = () => - (Sentry.captureMessage as jest.Mock).mock.calls.filter((c) => c[0] === 'legacy-cookie native transport engaged') +const transportNotices = () => + (Sentry.captureMessage as jest.Mock).mock.calls.filter((c) => String(c[0]).includes('transport engaged')) describe('fetchWithSentry preferNativeTransport', () => { beforeEach(() => { @@ -59,10 +59,9 @@ describe('fetchWithSentry preferNativeTransport', () => { ) expect(reportNetworkError).not.toHaveBeenCalled() - // engaged notice fires once per session, not per request - expect(engagedNotices()).toHaveLength(1) + // which transport carried the request is not a Sentry event await fetchWithSentry('https://api.test.com/users/me', { method: 'GET', preferNativeTransport: true }) - expect(engagedNotices()).toHaveLength(1) + expect(transportNotices()).toHaveLength(0) }) it('still reports non-ok statuses from the preferred transport', async () => { diff --git a/src/utils/native-canary.ts b/src/utils/native-canary.ts deleted file mode 100644 index d04a7dea19..0000000000 --- a/src/utils/native-canary.ts +++ /dev/null @@ -1,166 +0,0 @@ -// One-shot startup probes for the WebView fetch path the app actually uses -// with CapacitorHttp disabled (PEANUT-UI-R44). Probes go through the live -// window.fetch rather than forcing the un-proxied path, so they measure what -// real traffic hits; the `transport` tag records whether a CapacitorHttp proxy -// was in front of it. Five probes discriminate the failure modes seen in the -// field (PEANUT-UI-R5F): -// get-healthz — unauthenticated simple GET: pure reachability, no preflight -// get-users-me — GET with Authorization, exactly like callApi (preflighted) -// post-healthz — POST with JSON content-type: any HTTP status (404/405 is -// fine) proves the method works, testing the GET-vs-POST -// asymmetry observed on-device -// get-healthz-nocors — mode:'no-cors' GET: an `opaque` outcome where the plain -// GET fails means the server DID respond but without CORS -// headers — i.e. an edge block/challenge page, not a dead -// network -// get-healthz-native — same GET over CapacitorHttp.request (OS HTTP client): -// succeeds where the WebView is fingerprint-blocked -// Each probe gets its own message ("direct-fetch canary ") — identical -// messages were collapsed by Sentry's dedupe integration in v2, which is why -// only the first probe's event ever arrived. -// Query: message:"direct-fetch canary" — tags: probe, outcome, transport, -// appVersion; extras carry durationMs and the rejection error. swControlled is -// set globally in instrumentation-client.ts, so it lands on these events too. - -import * as Sentry from '@sentry/nextjs' -import { PEANUT_API_URL } from '@/constants/general.consts' -import { authReady, getAuthToken } from './auth-token' -import { isCapacitor } from './capacitor' -import { getUnderlyingFetch } from './native-auth-capture' -import { nativeHttpRequest } from './native-http' - -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) -} - -interface ProbeResult { - outcome: string - durationMs: number - errorName?: string - errorMessage?: string -} - -function toProbeError(error: unknown, startedAt: number): ProbeResult { - const e = error instanceof Error ? error : new Error(String(error)) - return { - outcome: e.name === 'AbortError' ? 'timeout' : 'network-error', - durationMs: Date.now() - startedAt, - errorName: e.name, - // Android WebView TypeErrors carry net:: codes in the message - errorMessage: e.message, - } -} - -async function probe(path: string, init: RequestInit): Promise { - const controller = new AbortController() - const timeoutId = setTimeout(() => controller.abort(), CANARY_TIMEOUT_MS) - const startedAt = Date.now() - try { - const response = await fetch(`${PEANUT_API_URL}${path}`, { ...init, signal: controller.signal }) - // no-cors responses are status 0 by design; `opaque` = the server answered - return { - outcome: response.type === 'opaque' ? 'opaque' : `http-${response.status}`, - durationMs: Date.now() - startedAt, - } - } catch (error) { - return toProbeError(error, startedAt) - } finally { - clearTimeout(timeoutId) - } -} - -async function nativeProbe(path: string): Promise { - const startedAt = Date.now() - try { - const response = await nativeHttpRequest(`${PEANUT_API_URL}${path}`, { method: 'GET' }, CANARY_TIMEOUT_MS) - return { outcome: `http-${response.status}`, durationMs: Date.now() - startedAt } - } catch (error) { - return toProbeError(error, startedAt) - } -} - -async function getBinaryInfo(): Promise<{ appVersion: string; appBuild: string }> { - try { - const { App } = await import('@capacitor/app') - const info = await App.getInfo() - return { appVersion: info.version, appBuild: info.build } - } catch { - return { appVersion: 'unknown', appBuild: 'unknown' } - } -} - -async function runCanary(): Promise { - await authReady() - const token = getAuthToken() - - // CapacitorWebFetch is assigned unconditionally by the native bridge; only - // an actual patch of window.fetch means the CapacitorHttp proxy is active. - // Compare against the fetch that native-auth-capture found at install time — - // its own wrapper patches window.fetch too, which made v2 report - // cap-http-proxy on binaries where the proxy is off. - const capWebFetch = (window as unknown as { CapacitorWebFetch?: typeof fetch }).CapacitorWebFetch - const baseFetch = getUnderlyingFetch() ?? window.fetch - const proxied = !!capWebFetch && baseFetch !== capWebFetch - - const { appVersion, appBuild } = await getBinaryInfo() - - const webTransport = proxied ? 'cap-http-proxy' : 'direct' - const probes: Array<{ name: string; transport: string; run: () => Promise }> = [ - { name: 'get-healthz', transport: webTransport, run: () => probe('/healthz', { method: 'GET' }) }, - { - name: 'get-users-me', - transport: webTransport, - run: () => - probe('/users/me', { method: 'GET', headers: token ? { Authorization: `Bearer ${token}` } : {} }), - }, - { - name: 'post-healthz', - transport: webTransport, - run: () => - probe('/healthz', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }), - }, - { - name: 'get-healthz-nocors', - transport: webTransport, - run: () => probe('/healthz', { method: 'GET', mode: 'no-cors' }), - }, - { name: 'get-healthz-native', transport: 'cap-native-http', run: () => nativeProbe('/healthz') }, - ] - - // Fire in parallel so a dead network costs one CANARY_TIMEOUT_MS, not five, - // then report in probe order so the Sentry events stay comparable run to run. - const results = await Promise.all(probes.map(({ run }) => run())) - - probes.forEach(({ name, transport }, index) => { - const result = results[index] - // probe name in the message: identical messages hit Sentry's dedupe - // integration and only the first probe's event survives - Sentry.captureMessage(`direct-fetch canary ${name}`, { - level: 'info', - tags: { - canary: 'direct-fetch', - canaryVersion: '3', - probe: name, - outcome: result.outcome, - transport, - authMode: token ? 'bearer' : 'none', - appVersion, - appBuild, - online: String(navigator.onLine), - }, - extra: { - durationMs: result.durationMs, - errorName: result.errorName, - errorMessage: result.errorMessage, - }, - }) - }) -} diff --git a/src/utils/sentry.utils.ts b/src/utils/sentry.utils.ts index dde1cb1cc1..8ab54d86fa 100644 --- a/src/utils/sentry.utils.ts +++ b/src/utils/sentry.utils.ts @@ -449,38 +449,6 @@ const reportNonOkResponse = async (url: string, options: RequestInit, response: }) } -// One Sentry note per session when the native fallback rescues a request — -// enough to measure how often the WebView path is being rejected without -// producing an event per API call. -let nativeFallbackReported = false -const noteNativeFallback = (url: string, cause: unknown): void => { - if (nativeFallbackReported) return - nativeFallbackReported = true - const causeError = cause instanceof Error ? cause : null - Sentry.captureMessage('native http fallback engaged', { - level: 'warning', - tags: { transport: 'cap-native-http' }, - extra: { - url: sanitizeUrl(url), - causeName: causeError?.name, - causeMessage: causeError?.message, - }, - }) -} - -// One Sentry note per session when a tokenless session runs on the OS HTTP -// client — measures how many users still sit on legacy cookie-jar auth. -let legacyCookieTransportReported = false -const noteLegacyCookieTransport = (url: string): void => { - if (legacyCookieTransportReported) return - legacyCookieTransportReported = true - Sentry.captureMessage('legacy-cookie native transport engaged', { - level: 'info', - tags: { transport: 'cap-native-http', authMode: 'legacy-cookie' }, - extra: { url: sanitizeUrl(url) }, - }) -} - export type FetchWithSentryOptions = RequestInit & { preferNativeTransport?: boolean } export const fetchWithSentry = async ( @@ -503,7 +471,6 @@ export const fetchWithSentry = async ( if (preferNativeTransport && canUseNativeHttp(url, options)) { try { const response = await nativeHttpRequest(url, options, timeoutMs) - noteLegacyCookieTransport(url) await reportNonOkResponse(url, options, response) return response } catch { @@ -553,7 +520,6 @@ export const fetchWithSentry = async ( if (canUseNativeHttp(url, options)) { try { const response = await nativeHttpRequest(url, options, timeoutMs) - noteNativeFallback(url, error) await reportNonOkResponse(url, options, response) return response } catch { From dac4777197fdb5df37f54de4300f756aada62d86 Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Tue, 18 Aug 2026 17:06:42 +0100 Subject: [PATCH 04/93] fix(sentry): treat PasskeyError as already reported MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- sentry.utils.test.ts | 16 ++++++++++++++++ sentry.utils.ts | 7 ++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/sentry.utils.test.ts b/sentry.utils.test.ts index 2e0a2dc59b..2a82593d1b 100644 --- a/sentry.utils.test.ts +++ b/sentry.utils.test.ts @@ -102,3 +102,19 @@ describe('shouldIgnoreError — Capgo updater noise', () => { expect(shouldIgnoreError(eventWith({ type: 'Error', value: 'Download error: statement failed' }))).toBe(false) }) }) + +describe('shouldIgnoreError — passkey wrapper', () => { + it('ignores the curated PasskeyError wrapper (useZeroDev already reported the raw failure)', () => { + const event = eventWith({ + type: 'PasskeyError', + value: 'We couldn’t verify your passkey. Please try again, or contact support if it keeps happening.', + }) + expect(shouldIgnoreError(event)).toBe(true) + }) + + it('still reports the underlying WebAuthn failure', () => { + expect(shouldIgnoreError(eventWith({ type: 'NotReadableError', value: 'passkey prompt interrupted' }))).toBe( + false + ) + }) +}) diff --git a/sentry.utils.ts b/sentry.utils.ts index fd3a018bf9..f357f43ed5 100644 --- a/sentry.utils.ts +++ b/sentry.utils.ts @@ -46,7 +46,12 @@ const IGNORED_ERRORS = { // internal fetchWithSentry wrapper names, not generic strings that could // appear in an unrelated third-party error message. ConnectionTimeoutError // is the timeout-path wrapper; ServiceUnavailableError the generic one. - alreadyReported: ['ServiceUnavailableError', 'ConnectionTimeoutError'], + // PasskeyError is the same shape one layer up: useZeroDev classifies the raw + // WebAuthn failure, captures it with full context (or deliberately doesn't, + // for a plain user cancel), then throws a curated user-facing wrapper. Call + // sites that re-report that wrapper add a second, context-free event and + // undo the deliberate silence around LOGIN_CANCELED. + alreadyReported: ['ServiceUnavailableError', 'ConnectionTimeoutError', 'PasskeyError'], // Third-party SDK internal errors (not actionable) thirdPartySdkErrors: [ From 8f5a9f59aa96885f479c366c69db7ab13482f822 Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Tue, 18 Aug 2026 17:15:09 +0100 Subject: [PATCH 05/93] fix(passkey): stop reporting a cancelled passkey prompt as an error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/components/Global/GuestLoginModal/index.tsx | 5 +++-- src/components/Invites/InvitesPage.test.tsx | 4 ++++ src/components/Invites/InvitesPage.tsx | 8 +++++++- src/components/Setup/Views/JoinWaitlist.tsx | 5 ++++- src/components/Setup/Views/Landing.tsx | 5 ++++- src/utils/webauthn.utils.ts | 10 ++++++++++ 6 files changed, 32 insertions(+), 5 deletions(-) diff --git a/src/components/Global/GuestLoginModal/index.tsx b/src/components/Global/GuestLoginModal/index.tsx index a9f10f2f8c..97a54309f6 100644 --- a/src/components/Global/GuestLoginModal/index.tsx +++ b/src/components/Global/GuestLoginModal/index.tsx @@ -25,8 +25,9 @@ const GuestLoginModal = () => { onClick={() => { handleLogin() .then(closeModal) - .catch((e) => { - console.error(e) + .catch(() => { + // useZeroDev already reported the underlying failure; + // console.error here would capture the wrapper again. toast.error(t('guestLoginModal.loginError')) }) }} diff --git a/src/components/Invites/InvitesPage.test.tsx b/src/components/Invites/InvitesPage.test.tsx index df3b46729c..9309eb64a0 100644 --- a/src/components/Invites/InvitesPage.test.tsx +++ b/src/components/Invites/InvitesPage.test.tsx @@ -53,6 +53,10 @@ jest.mock('@/redux/slices/setup-slice', () => ({ }, })) jest.mock('@/hooks/useLogin', () => ({ useLogin: () => ({ handleLoginClick: mockLogin, isLoggingIn: false }) })) +jest.mock('@/components/0_Bruddle/Toast', () => ({ + ...jest.requireActual('@/components/0_Bruddle/Toast'), + useToast: () => ({ error: jest.fn(), success: jest.fn() }), +})) jest.mock('@/hooks/useGuestStoreHandoff', () => ({ useGuestStoreHandoff: (opts: { trackImpressionWhenGuest?: boolean }) => { mockUseGuestStoreHandoff(opts) diff --git a/src/components/Invites/InvitesPage.tsx b/src/components/Invites/InvitesPage.tsx index 02d94fc305..3dfbe1225f 100644 --- a/src/components/Invites/InvitesPage.tsx +++ b/src/components/Invites/InvitesPage.tsx @@ -16,6 +16,7 @@ import { EInviteType } from '@/services/services.types' import { getValidRedirectUrl, saveRedirectUrl, saveToCookie } from '@/utils/general.utils' import { useGuestStoreHandoff } from '@/hooks/useGuestStoreHandoff' import { useLogin } from '@/hooks/useLogin' +import { useToast } from '@/components/0_Bruddle/Toast' import UnsupportedBrowserModal from '../Global/UnsupportedBrowserModal' import posthog from 'posthog-js' import { useTranslations } from 'next-intl' @@ -38,6 +39,7 @@ import { destinationForInviteAcquisition } from '@/services/invite-acquisition' function InvitePageContent() { const t = useTranslations('invites') const tSetup = useTranslations('setup') + const toast = useToast() const searchParams = useSearchParams() // trim trailing '?' from invite code to handle qr codes with ? at the end const inviteCode = searchParams.get('code')?.toLowerCase().replace(/\?+$/, '') @@ -292,7 +294,11 @@ function InvitePageContent() { // normal-app fallback. saveRedirectUrl() } - void handleLoginClick() + // PasskeyError carries curated user-facing copy; without this catch a + // cancelled passkey prompt becomes an unhandled rejection and a Sentry event. + handleLoginClick().catch((error: unknown) => { + toast.error((error instanceof Error && error.message) || tSetup('loginFailed')) + }) } useEffect(() => { diff --git a/src/components/Setup/Views/JoinWaitlist.tsx b/src/components/Setup/Views/JoinWaitlist.tsx index ab8b0e6532..14f1aade61 100644 --- a/src/components/Setup/Views/JoinWaitlist.tsx +++ b/src/components/Setup/Views/JoinWaitlist.tsx @@ -1,6 +1,7 @@ 'use client' import { Button } from '@/components/0_Bruddle/Button' +import { isAlreadyReported } from '@/utils/webauthn.utils' import { useToast } from '@/components/0_Bruddle/Toast' import ValidatedInput from '@/components/Global/ValidatedInput' import { useEffect, useState } from 'react' @@ -89,7 +90,9 @@ const JoinWaitlist = () => { ? t('waitlist.noPasskey') : t('waitlist.loginUnexpectedError') toast.error(errorMessage) - Sentry.captureException(error, { extra: { errorCode } }) + if (!isAlreadyReported(error)) { + Sentry.captureException(error, { extra: { errorCode } }) + } } const _onLoginClick = async () => { diff --git a/src/components/Setup/Views/Landing.tsx b/src/components/Setup/Views/Landing.tsx index 6845dcab6a..ab0efe20a0 100644 --- a/src/components/Setup/Views/Landing.tsx +++ b/src/components/Setup/Views/Landing.tsx @@ -1,6 +1,7 @@ 'use client' import { useToast } from '@/components/0_Bruddle/Toast' +import { isAlreadyReported } from '@/utils/webauthn.utils' import { useSetupFlow } from '@/hooks/useSetupFlow' import { useLogin } from '@/hooks/useLogin' import * as Sentry from '@sentry/nextjs' @@ -45,7 +46,9 @@ const LandingStep = () => { const handleError = (error: unknown) => { const errorCode = error instanceof Error && 'code' in error ? String(error.code) : undefined toast.error((error instanceof Error && error.message) || t('loginFailed')) - Sentry.captureException(error, { extra: { errorCode } }) + if (!isAlreadyReported(error)) { + Sentry.captureException(error, { extra: { errorCode } }) + } posthog.capture(ANALYTICS_EVENTS.SIGNUP_LOGIN_ERROR, { error_code: errorCode }) } diff --git a/src/utils/webauthn.utils.ts b/src/utils/webauthn.utils.ts index 24b80e6b52..14da5792c8 100644 --- a/src/utils/webauthn.utils.ts +++ b/src/utils/webauthn.utils.ts @@ -227,6 +227,16 @@ export const PASSKEY_WARNINGS = { */ const WEBAUTHN_ERROR_NAMES = new Set(Object.values(WebAuthnErrorName)) +/** + * useZeroDev captures the raw WebAuthn failure with full context before throwing + * the curated PasskeyError — and for a plain user cancel it deliberately captures + * nothing on web. Re-reporting the wrapper at a call site adds a context-free + * duplicate and undoes that silence. + */ +export function isAlreadyReported(error: unknown): boolean { + return error instanceof Error && error.name === 'PasskeyError' +} + export function capturePasskeySignFailure(error: unknown, context: string): void { if (!(error instanceof Error) || !WEBAUTHN_ERROR_NAMES.has(error.name)) return posthog.capture(ANALYTICS_EVENTS.PASSKEY_SIGN_FAILED, { error_name: error.name, context }) From 69c103cb2a1d5553c896e5dcbeea5a9678b5a772 Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Tue, 18 Aug 2026 17:27:44 +0100 Subject: [PATCH 06/93] fix(support): don't report an absent capability read-model as needs-identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- src/utils/__tests__/support-verification.test.ts | 9 +++++++++ src/utils/support-verification.ts | 7 ++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/utils/__tests__/support-verification.test.ts b/src/utils/__tests__/support-verification.test.ts index c00ca4a34d..f0161c562f 100644 --- a/src/utils/__tests__/support-verification.test.ts +++ b/src/utils/__tests__/support-verification.test.ts @@ -92,10 +92,19 @@ describe('buildSupportVerificationSummary', () => { const summary = buildSupportVerificationSummary(undefined, undefined, undefined) expect(summary.identityStatus).toBe('unknown') expect(summary.emailOnFile).toBe(false) + // no capability read-model — must NOT read as "needs-identity on everything", + // which an agent can't tell apart from a genuinely unverified user + expect(summary.gates).toBe('') + expect(summary.verificationRails).toBeUndefined() expect(summary.failureReason).toBeUndefined() expect(summary.pendingActions).toBeUndefined() }) + test('still reports gates for a user whose read-model is present but empty', () => { + const summary = buildSupportVerificationSummary(caps([]), undefined, undefined) + expect(summary.gates).toBe('pay:needs-identity deposit:needs-identity withdraw:needs-identity') + }) + test('reports action_required identity status', () => { const summary = buildSupportVerificationSummary( caps([enabledRail]), diff --git a/src/utils/support-verification.ts b/src/utils/support-verification.ts index 54fcc05ef5..39aa500924 100644 --- a/src/utils/support-verification.ts +++ b/src/utils/support-verification.ts @@ -39,8 +39,13 @@ export function buildSupportVerificationSummary( const identityVerified = identityStatus === 'verified' const emailOnFile = Boolean(email) + // An absent capability read-model is NOT a verified-identity signal: deriving + // over the empty state would report `needs-identity` on every op, which an + // agent cannot tell apart from a genuinely unverified user. Report nothing. const gateState = { rails, nextActions, identityVerified, isLoading: false } - const gates = SUMMARY_OPERATIONS.map((op) => `${op}:${deriveGate(gateState, op).kind}`).join(' ') + const gates = capabilities + ? SUMMARY_OPERATIONS.map((op) => `${op}:${deriveGate(gateState, op).kind}`).join(' ') + : '' // Per-rail verdicts. `gates` gives the op-level kind but not the rail; this // names every rail that isn't clear (blocked/fixable/pending/requires-info) From b78b3f4dc2444ae88661ce1a37745d99368ca6a2 Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Wed, 19 Aug 2026 14:18:10 +0100 Subject: [PATCH 07/93] fix(native): accept Bridge ToS via the system browser on android MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../__tests__/IframeWrapper.test.tsx | 89 +++++++++++++++++ src/components/Global/IframeWrapper/index.tsx | 60 +++++++++++- src/components/Kyc/BridgeTosStep.tsx | 17 +++- .../Kyc/__tests__/BridgeTosStep.test.tsx | 97 +++++++++++++++++++ .../confirmBridgeTosAndAwaitRails.test.ts | 85 ++++++++++++++++ src/hooks/useMultiPhaseKycFlow.ts | 54 +++++++++-- src/i18n/app/messages/en.json | 1 + src/i18n/app/messages/es-419.json | 1 + src/i18n/app/messages/es-AR.json | 3 +- src/i18n/app/messages/pt-BR.json | 1 + src/utils/capacitor.ts | 11 +++ 11 files changed, 405 insertions(+), 14 deletions(-) create mode 100644 src/components/Kyc/__tests__/BridgeTosStep.test.tsx create mode 100644 src/hooks/__tests__/confirmBridgeTosAndAwaitRails.test.ts diff --git a/src/components/Global/IframeWrapper/__tests__/IframeWrapper.test.tsx b/src/components/Global/IframeWrapper/__tests__/IframeWrapper.test.tsx index 70882cfd7a..bf11f4bf70 100644 --- a/src/components/Global/IframeWrapper/__tests__/IframeWrapper.test.tsx +++ b/src/components/Global/IframeWrapper/__tests__/IframeWrapper.test.tsx @@ -19,6 +19,35 @@ jest.mock('@/context/ModalsContext', () => ({ useModalsContext: () => ({ setIsSupportModalOpen: jest.fn() }), })) +let mockIsAndroidNativeBridge = false +jest.mock('@/utils/capacitor', () => ({ + isAndroidNativeBridge: () => mockIsAndroidNativeBridge, +})) + +const mockBrowserOpen = jest.fn, [{ url: string }]>(() => Promise.resolve()) +const mockRemoveListener = jest.fn() +let browserFinished: (() => void) | undefined +jest.mock( + '@capacitor/browser', + () => ({ + Browser: { + open: (options: { url: string }) => mockBrowserOpen(options), + addListener: (_event: string, cb: () => void) => { + browserFinished = cb + return Promise.resolve({ remove: mockRemoveListener }) + }, + }, + }), + { virtual: true } +) + +beforeEach(() => { + mockIsAndroidNativeBridge = false + mockBrowserOpen.mockClear() + mockRemoveListener.mockClear() + browserFinished = undefined +}) + function findIframe(src: string): HTMLIFrameElement { // headlessui Dialog portals into document.body — search the document. const iframe = Array.from(document.querySelectorAll('iframe')).find((f) => f.getAttribute('src') === src) @@ -69,3 +98,63 @@ describe('IframeWrapper message routing', () => { expect(onClose).not.toHaveBeenCalled() }) }) + +/** + * Android's Capacitor WebView cancels third-party SUBFRAME navigations + * (BridgeWebViewClient hands every request to launchIntent without checking + * isForMainFrame), so the ToS iframe painted pure white and acceptance was + * impossible in the native app. Android renders no iframe at all now — the + * page opens in the system browser and the close event drives the flow. + */ +describe('IframeWrapper on android native', () => { + const flush = () => act(async () => undefined) + + it('opens the system browser instead of framing the page', async () => { + mockIsAndroidNativeBridge = true + const onClose = jest.fn() + render() + await flush() + + expect(document.querySelectorAll('iframe')).toHaveLength(0) + expect(mockBrowserOpen).toHaveBeenCalledWith({ url: 'https://compliance.test/tos' }) + }) + + it("reports the return as 'returned', never as an acceptance it did not observe", async () => { + mockIsAndroidNativeBridge = true + const onClose = jest.fn() + render() + await flush() + + act(() => browserFinished?.()) + expect(onClose).toHaveBeenCalledWith('returned') + expect(onClose).not.toHaveBeenCalledWith('tos_accepted') + }) + + it('does not open the browser while hidden, and drops the listener on unmount', async () => { + mockIsAndroidNativeBridge = true + const { rerender, unmount } = render( + + ) + await flush() + expect(mockBrowserOpen).not.toHaveBeenCalled() + + rerender( + + + + ) + await flush() + expect(mockBrowserOpen).toHaveBeenCalledTimes(1) + + unmount() + expect(mockRemoveListener).toHaveBeenCalled() + }) + + it('still frames the page on every other platform', async () => { + render() + await flush() + + expect(findIframe('https://compliance.test/tos')).toBeTruthy() + expect(mockBrowserOpen).not.toHaveBeenCalled() + }) +}) diff --git a/src/components/Global/IframeWrapper/index.tsx b/src/components/Global/IframeWrapper/index.tsx index a5285dc2cd..6c97b1b530 100644 --- a/src/components/Global/IframeWrapper/index.tsx +++ b/src/components/Global/IframeWrapper/index.tsx @@ -6,11 +6,22 @@ import ActionModal from '../ActionModal' import { useRouter } from 'next/navigation' import { useModalsContext } from '@/context/ModalsContext' import { Button, type ButtonVariant } from '@/components/0_Bruddle/Button' +import { isAndroidNativeBridge } from '@/utils/capacitor' + +/** + * Why the wrapper closed: + * - `manual` — the user backed out + * - `completed` — the embedded flow reported completion (postMessage) + * - `tos_accepted` — Bridge's iframe reported a signed agreement (postMessage) + * - `returned` — the android system-browser tab closed; carries NO + * acceptance claim, the caller must ask the provider + */ +export type IframeCloseSource = 'manual' | 'completed' | 'tos_accepted' | 'returned' export type IFrameWrapperProps = { src: string visible: boolean - onClose: (source?: 'manual' | 'completed' | 'tos_accepted') => void + onClose: (source?: IframeCloseSource) => void closeConfirmMessage?: string } @@ -24,6 +35,51 @@ const IframeWrapper = ({ src, visible, onClose, closeConfirmMessage }: IFrameWra const router = useRouter() const { setIsSupportModalOpen } = useModalsContext() + /* + * Android's Capacitor WebView cannot host a third-party subframe: + * BridgeWebViewClient.shouldOverrideUrlLoading hands EVERY navigation to + * Bridge.launchIntent — it never checks request.isForMainFrame() — which + * cancels the load for any host outside the app origin that isn't in + * server.allowNavigation. The frame paints pure white and the provider + * never records an acceptance. iOS gates the same detour on + * targetFrame.isMainFrame, so only Android needs the system-browser + * detour. Coming back from that tab means "the user returned", not "the + * user accepted" — hence a `returned` source the caller resolves against + * the provider rather than a fabricated `tos_accepted`. + */ + const [useSystemBrowser] = useState(isAndroidNativeBridge) + const onCloseRef = useRef(onClose) + useEffect(() => { + onCloseRef.current = onClose + }) + + useEffect(() => { + if (!useSystemBrowser || !visible) return + let disposed = false + let remove: (() => void) | undefined + void import('@capacitor/browser') + .then(({ Browser }) => + // Listener first: a tab the user dismisses immediately must not + // close before we are listening, or the flow hangs forever. + Browser.addListener('browserFinished', () => onCloseRef.current('returned')).then((handle) => { + // Cleanup can run while the dynamic import is still in + // flight; without this the listener registers after the + // fact and nobody ever removes it. + if (disposed) handle.remove() + else remove = () => handle.remove() + return Browser.open({ url: src }) + }) + ) + .catch((error) => { + console.error('[iframe-wrapper] system browser open failed', error) + onCloseRef.current('manual') + }) + return () => { + disposed = true + remove?.() + } + }, [useSystemBrowser, visible, src]) + const handleCopy = (textToCopy: string) => { navigator.clipboard.writeText(textToCopy).then(() => { setCopied(true) @@ -124,6 +180,8 @@ const IframeWrapper = ({ src, visible, onClose, closeConfirmMessage }: IFrameWra return () => window.removeEventListener('message', handleMessage) }, [onClose, visible]) + if (useSystemBrowser) return null + return ( { - if (source === 'tos_accepted') { + async (source?: IframeCloseSource) => { + if (source === 'tos_accepted' || source === 'returned') { setIsConfirming(true) setShowIframe(false) try { - await confirmBridgeTosAndAwaitRails(fetchUser) + const accepted = await confirmBridgeTosAndAwaitRails(fetchUser, { + observedAcceptance: source === 'tos_accepted', + }) + // `returned` only means the system browser closed, so a user + // who backed out lands back on the prompt instead of being + // told the step is done. + if (source === 'returned' && !accepted) { + setError(t('bridgeTos.notAcceptedYet')) + return + } onComplete() } catch { setError(t('bridgeTos.confirmError')) diff --git a/src/components/Kyc/__tests__/BridgeTosStep.test.tsx b/src/components/Kyc/__tests__/BridgeTosStep.test.tsx new file mode 100644 index 0000000000..1dc67d45ac --- /dev/null +++ b/src/components/Kyc/__tests__/BridgeTosStep.test.tsx @@ -0,0 +1,97 @@ +/** + * The android system-browser detour (Capacitor's WebView cancels third-party + * subframe navigations, so the ToS iframe painted blank) gives the step no + * acceptance signal — only "the user came back". These cover the resulting + * contract: Bridge's own answer, not the return itself, decides whether the + * step is done. + */ +import React from 'react' +import { render, screen, act } from '@testing-library/react' +import { IntlWrapper } from '@/test-utils/intl' +import { BridgeTosStep } from '../BridgeTosStep' +// type-only import — the module itself is mocked below +import { type IframeCloseSource } from '@/components/Global/IframeWrapper' + +const mockGetBridgeTosLink = jest.fn() +jest.mock('@/app/actions/users', () => ({ + getBridgeTosLink: () => mockGetBridgeTosLink(), +})) + +const mockFetchUser = jest.fn().mockResolvedValue(null) +jest.mock('@/context/authContext', () => ({ + useAuth: () => ({ fetchUser: mockFetchUser }), +})) + +const mockConfirm = jest.fn, [unknown, { observedAcceptance?: boolean }?]>() +jest.mock('@/hooks/useMultiPhaseKycFlow', () => ({ + confirmBridgeTosAndAwaitRails: (fetchUser: unknown, options?: { observedAcceptance?: boolean }) => + mockConfirm(fetchUser, options), +})) + +let closeIframe: ((source?: IframeCloseSource) => void) | undefined +jest.mock('@/components/Global/IframeWrapper', () => ({ + __esModule: true, + default: ({ visible, onClose }: { visible: boolean; onClose: (source?: IframeCloseSource) => void }) => { + closeIframe = onClose + return visible ?
: null + }, +})) + +const openTos = async () => { + await act(async () => { + screen.getByRole('button', { name: 'Accept Terms' }).click() + }) +} + +describe('BridgeTosStep', () => { + beforeEach(() => { + closeIframe = undefined + mockConfirm.mockReset() + mockGetBridgeTosLink.mockReset() + mockGetBridgeTosLink.mockResolvedValue({ data: { tosLink: 'https://compliance.test/tos' } }) + }) + + const renderStep = (onComplete = jest.fn(), onSkip = jest.fn()) => { + render( + + + + ) + return { onComplete, onSkip } + } + + it('completes when Bridge confirms the terms were signed', async () => { + mockConfirm.mockResolvedValue(true) + const { onComplete } = renderStep() + await openTos() + + await act(async () => closeIframe?.('returned')) + expect(onComplete).toHaveBeenCalled() + // a return is not an observation — the helper must not treat a + // confirm miss as webhook lag on this path + expect(mockConfirm).toHaveBeenCalledWith(expect.anything(), { observedAcceptance: false }) + }) + + it('keeps the prompt up when the user came back without signing', async () => { + mockConfirm.mockResolvedValue(false) + const { onComplete, onSkip } = renderStep() + await openTos() + + await act(async () => closeIframe?.('returned')) + expect(onComplete).not.toHaveBeenCalled() + expect(onSkip).not.toHaveBeenCalled() + expect(screen.getByText(/haven't been accepted yet/i)).toBeInTheDocument() + }) + + it('trusts an observed acceptance even if the confirm race says otherwise', async () => { + // `tos_accepted` comes from Bridge's own postMessage (web iframe), so a + // still-propagating confirm must not bounce the user back to the prompt. + mockConfirm.mockResolvedValue(false) + const { onComplete } = renderStep() + await openTos() + + await act(async () => closeIframe?.('tos_accepted')) + expect(onComplete).toHaveBeenCalled() + expect(mockConfirm).toHaveBeenCalledWith(expect.anything(), { observedAcceptance: true }) + }) +}) diff --git a/src/hooks/__tests__/confirmBridgeTosAndAwaitRails.test.ts b/src/hooks/__tests__/confirmBridgeTosAndAwaitRails.test.ts new file mode 100644 index 0000000000..b7fb7e2c93 --- /dev/null +++ b/src/hooks/__tests__/confirmBridgeTosAndAwaitRails.test.ts @@ -0,0 +1,85 @@ +/** + * The android ToS flow runs in the system browser (Capacitor's WebView cancels + * third-party subframe navigations), so nothing in-app observes the signature — + * the caller's only source of truth is this helper's return value. + */ +import { confirmBridgeTosAndAwaitRails } from '@/hooks/useMultiPhaseKycFlow' +import { markSubmitted } from '@/hooks/useSubmissionWindow' + +const mockConfirmBridgeTos = jest.fn() +jest.mock('@/app/actions/users', () => ({ + getBridgeTosLink: jest.fn(), + confirmBridgeTos: () => mockConfirmBridgeTos(), +})) +jest.mock('@/hooks/useSubmissionWindow', () => ({ markSubmitted: jest.fn(), useSubmissionWindow: jest.fn() })) +jest.mock('posthog-js', () => ({ __esModule: true, default: { capture: jest.fn() } })) + +const fetchUser = jest.fn().mockResolvedValue(null) + +describe('confirmBridgeTosAndAwaitRails', () => { + beforeEach(() => { + jest.useFakeTimers() + mockConfirmBridgeTos.mockReset() + fetchUser.mockClear() + ;(markSubmitted as jest.Mock).mockClear() + }) + afterEach(() => jest.useRealTimers()) + + // Drives the helper to completion while its sleeps are faked away. + const run = async (options?: { observedAcceptance?: boolean }) => { + const pending = confirmBridgeTosAndAwaitRails(fetchUser, options) + await jest.runAllTimersAsync() + return pending + } + + it("reports Bridge's yes", async () => { + mockConfirmBridgeTos.mockResolvedValue({ data: { accepted: true } }) + await expect(run()).resolves.toBe(true) + expect(mockConfirmBridgeTos).toHaveBeenCalledTimes(1) + }) + + it('retries once and reports the retry verdict, not the first miss', async () => { + mockConfirmBridgeTos + .mockResolvedValueOnce({ data: { accepted: false } }) + .mockResolvedValueOnce({ data: { accepted: true } }) + await expect(run()).resolves.toBe(true) + expect(mockConfirmBridgeTos).toHaveBeenCalledTimes(2) + }) + + it("reports Bridge's no after the retry", async () => { + mockConfirmBridgeTos.mockResolvedValue({ data: { accepted: false } }) + await expect(run()).resolves.toBe(false) + expect(mockConfirmBridgeTos).toHaveBeenCalledTimes(2) + }) + + it('reports no when the confirm call errored out', async () => { + mockConfirmBridgeTos.mockResolvedValue({ error: 'Failed to confirm Bridge ToS' }) + await expect(run()).resolves.toBe(false) + }) + + it('still arms the submission window and polls on a lagging confirm when the acceptance was observed', async () => { + // web iframe race: Bridge's postMessage said "signed", the confirm + // endpoint hasn't caught up — the historical behavior must survive. + mockConfirmBridgeTos.mockResolvedValue({ data: { accepted: false } }) + await expect(run({ observedAcceptance: true })).resolves.toBe(false) + expect(markSubmitted).toHaveBeenCalled() + expect(fetchUser).toHaveBeenCalled() + }) + + it('stops after the retry when nothing observed an acceptance and Bridge says no', async () => { + // android `returned` abandonment: no submission happened, so no grace + // window to arm and no rail change to poll for — fail fast instead. + mockConfirmBridgeTos.mockResolvedValue({ data: { accepted: false } }) + await expect(run({ observedAcceptance: false })).resolves.toBe(false) + expect(mockConfirmBridgeTos).toHaveBeenCalledTimes(2) + expect(markSubmitted).not.toHaveBeenCalled() + expect(fetchUser).not.toHaveBeenCalled() + }) + + it('proceeds normally when nothing observed an acceptance but Bridge says yes', async () => { + mockConfirmBridgeTos.mockResolvedValue({ data: { accepted: true } }) + await expect(run({ observedAcceptance: false })).resolves.toBe(true) + expect(markSubmitted).toHaveBeenCalled() + expect(fetchUser).toHaveBeenCalled() + }) +}) diff --git a/src/hooks/useMultiPhaseKycFlow.ts b/src/hooks/useMultiPhaseKycFlow.ts index 96ff5195e5..dfb0c762a2 100644 --- a/src/hooks/useMultiPhaseKycFlow.ts +++ b/src/hooks/useMultiPhaseKycFlow.ts @@ -6,6 +6,7 @@ import { useCapabilities } from '@/hooks/useCapabilities' import { markSubmitted } from '@/hooks/useSubmissionWindow' import { deriveGate } from '@/utils/capability-gate' import { getBridgeTosLink, confirmBridgeTos } from '@/app/actions/users' +import { type IframeCloseSource } from '@/components/Global/IframeWrapper' import { type KycModalPhase, type IUserProfile } from '@/interfaces/interfaces' import { type UserCapabilities } from '@/types/capabilities' import { type KYCRegionIntent } from '@/app/actions/types/sumsub.types' @@ -47,14 +48,32 @@ function deriveCapabilityPhaseSignals(capabilities: UserCapabilities | undefined /** * confirms bridge ToS acceptance (with one retry) then polls fetchUser * until bridge rails leave the TOS-required state. max 3 attempts × 2s. + * + * Returns Bridge's own verdict on whether the terms are signed. Callers that + * never saw an acceptance signal (the android system-browser detour, where + * the only event is "the user came back") need it to tell a real acceptance + * from an abandoned one. + * + * `observedAcceptance` says whether the caller SAW Bridge report a signature + * (the iframe's `tos_accepted` postMessage). When it did, a "no" from the + * confirm endpoint is treated as webhook lag: we still arm the submission + * window and poll the rails, exactly as the web flow always has. Without it + * (android's `returned`), a "no" after the retry means the user backed out — + * marking a submission or polling for rails that will never change would arm + * the 30s grace window for nothing and stall the error by ~6s, so we stop. */ -export async function confirmBridgeTosAndAwaitRails(fetchUser: () => Promise) { - const result = await confirmBridgeTos() - if (!result.data?.accepted) { +export async function confirmBridgeTosAndAwaitRails( + fetchUser: () => Promise, + { observedAcceptance = true }: { observedAcceptance?: boolean } = {} +): Promise { + let accepted = !!(await confirmBridgeTos()).data?.accepted + if (!accepted) { await new Promise((resolve) => setTimeout(resolve, 2000)) - await confirmBridgeTos() + accepted = !!(await confirmBridgeTos()).data?.accepted } + if (!accepted && !observedAcceptance) return false + // Arm the post-submission window only after the ToS POST has actually // completed (CodeRabbit feedback on #2131). Doing it before // `confirmBridgeTos()` would burn part of the 30s grace period waiting @@ -72,6 +91,8 @@ export async function confirmBridgeTosAndAwaitRails(fetchUser: () => Promise setTimeout(resolve, 2000)) } + + return accepted } interface UseMultiPhaseKycFlowOptions { @@ -390,15 +411,32 @@ export const useMultiPhaseKycFlow = ({ // handle ToS iframe close const handleTosIframeClose = useCallback( - async (source?: 'manual' | 'completed' | 'tos_accepted') => { + async (source?: IframeCloseSource) => { setShowTosIframe(false) - if (source === 'tos_accepted') { - posthog.capture(ANALYTICS_EVENTS.KYC_TOS_ACCEPTED) + if (source === 'tos_accepted' || source === 'returned') { + if (source === 'tos_accepted') posthog.capture(ANALYTICS_EVENTS.KYC_TOS_ACCEPTED) // show loading state while confirming + polling setModalPhase('preparing') try { - await confirmBridgeTosAndAwaitRails(fetchUser) + const accepted = await confirmBridgeTosAndAwaitRails(fetchUser, { + observedAcceptance: source === 'tos_accepted', + }) + if (source === 'returned') { + // `returned` carries no acceptance claim, so neither the + // analytics event nor the flow's completion can be taken + // on faith — Bridge's answer decides both. The recovery + // copy stays dismissal-shaped because this modal's error + // CTA closes the flow; the home ToS card owns the retry. + if (!accepted) { + setModalPhase('bridge_tos') + setTosError( + "The terms weren't accepted. You can accept them later from your activity feed." + ) + return + } + posthog.capture(ANALYTICS_EVENTS.KYC_TOS_ACCEPTED) + } completeFlow() } catch { // Don't leave the modal frozen on 'preparing' with no feedback diff --git a/src/i18n/app/messages/en.json b/src/i18n/app/messages/en.json index 152b99d0e1..273214a0fa 100644 --- a/src/i18n/app/messages/en.json +++ b/src/i18n/app/messages/en.json @@ -2284,6 +2284,7 @@ "loadFailed": "Could not load terms. You can accept them later from your activity feed.", "genericError": "Something went wrong. You can accept terms later from your activity feed.", "confirmError": "Something went wrong confirming your terms. Please try again.", + "notAcceptedYet": "It looks like the terms haven't been accepted yet. Please try again.", "acceptTerms": "Accept Terms", "notNow": "Not now" }, diff --git a/src/i18n/app/messages/es-419.json b/src/i18n/app/messages/es-419.json index 4359467324..fd264130bb 100644 --- a/src/i18n/app/messages/es-419.json +++ b/src/i18n/app/messages/es-419.json @@ -2284,6 +2284,7 @@ "loadFailed": "No se pudieron cargar los términos. Puedes aceptarlos más tarde desde tu historial de actividad.", "genericError": "Algo salió mal. Puedes aceptar los términos más tarde desde tu historial de actividad.", "confirmError": "Algo salió mal al confirmar tus términos. Inténtalo de nuevo.", + "notAcceptedYet": "Parece que todavía no aceptaste los términos. Inténtalo de nuevo.", "acceptTerms": "Aceptar términos", "notNow": "Ahora no" }, diff --git a/src/i18n/app/messages/es-AR.json b/src/i18n/app/messages/es-AR.json index d153f9729d..c79733a2ce 100644 --- a/src/i18n/app/messages/es-AR.json +++ b/src/i18n/app/messages/es-AR.json @@ -825,7 +825,8 @@ "sepaDescription": "Para habilitar las transferencias bancarias en EUR (SEPA) y GBP (Faster Payments), debés aceptar los términos de servicio actualizados de nuestro socio de pagos.", "loadFailed": "No se pudieron cargar los términos. Podés aceptarlos más tarde desde tu historial de actividad.", "genericError": "Algo salió mal. Podés aceptar los términos más tarde desde tu historial de actividad.", - "confirmError": "Algo salió mal al confirmar tus términos. Intentalo de nuevo." + "confirmError": "Algo salió mal al confirmar tus términos. Intentalo de nuevo.", + "notAcceptedYet": "Parece que todavía no aceptaste los términos. Intentalo de nuevo." }, "reverificationPending": { "description": "Tu verificación está en revisión — normalmente toma unos minutos. Te avisaremos apenas puedas continuar. Podés esperar aquí o volver al inicio." diff --git a/src/i18n/app/messages/pt-BR.json b/src/i18n/app/messages/pt-BR.json index 2df53d1981..d12c5c4828 100644 --- a/src/i18n/app/messages/pt-BR.json +++ b/src/i18n/app/messages/pt-BR.json @@ -2284,6 +2284,7 @@ "loadFailed": "Não foi possível carregar os termos. Você pode aceitá-los depois pelo seu feed de atividades.", "genericError": "Algo deu errado. Você pode aceitar os termos depois pelo seu feed de atividades.", "confirmError": "Algo deu errado ao confirmar seus termos. Tente novamente.", + "notAcceptedYet": "Parece que os termos ainda não foram aceitos. Tente novamente.", "acceptTerms": "Aceitar termos", "notNow": "Agora não" }, diff --git a/src/utils/capacitor.ts b/src/utils/capacitor.ts index 1ce3b319bf..781b4cb254 100644 --- a/src/utils/capacitor.ts +++ b/src/utils/capacitor.ts @@ -74,6 +74,17 @@ export function isAndroidNative(): boolean { return getPlatform() === 'android-native' } +/** + * true only when the native bridge is live AND the platform is android. + * + * Unlike {@link isAndroidNative} this is false on capacitor-flavoured WEB + * builds (vercel previews opened in android chrome), where native-only + * signals such as the in-app browser's `browserFinished` never arrive. + */ +export function isAndroidNativeBridge(): boolean { + return isNativeBridge() && window.Capacitor?.getPlatform?.() === 'android' +} + /** * returns true when running on ios inside capacitor */ From f05f0b1e44be67c1783c29d43d3293e6c50787cd Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Wed, 19 Aug 2026 15:41:52 +0100 Subject: [PATCH 08/93] fix: skip Browser.open when the effect was cleaned up mid-registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../__tests__/IframeWrapper.test.tsx | 26 ++++++++++++++++++- src/components/Global/IframeWrapper/index.tsx | 14 ++++++---- 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/src/components/Global/IframeWrapper/__tests__/IframeWrapper.test.tsx b/src/components/Global/IframeWrapper/__tests__/IframeWrapper.test.tsx index bf11f4bf70..2122850696 100644 --- a/src/components/Global/IframeWrapper/__tests__/IframeWrapper.test.tsx +++ b/src/components/Global/IframeWrapper/__tests__/IframeWrapper.test.tsx @@ -27,6 +27,10 @@ jest.mock('@/utils/capacitor', () => ({ const mockBrowserOpen = jest.fn, [{ url: string }]>(() => Promise.resolve()) const mockRemoveListener = jest.fn() let browserFinished: (() => void) | undefined +// when set, addListener does not resolve until resolveListener() is called — +// lets tests unmount while registration is still in flight +let deferListener = false +let resolveListener: (() => void) | undefined jest.mock( '@capacitor/browser', () => ({ @@ -34,7 +38,11 @@ jest.mock( open: (options: { url: string }) => mockBrowserOpen(options), addListener: (_event: string, cb: () => void) => { browserFinished = cb - return Promise.resolve({ remove: mockRemoveListener }) + const handle = { remove: mockRemoveListener } + if (!deferListener) return Promise.resolve(handle) + return new Promise((resolve) => { + resolveListener = () => resolve(handle) + }) }, }, }), @@ -46,6 +54,8 @@ beforeEach(() => { mockBrowserOpen.mockClear() mockRemoveListener.mockClear() browserFinished = undefined + deferListener = false + resolveListener = undefined }) function findIframe(src: string): HTMLIFrameElement { @@ -150,6 +160,20 @@ describe('IframeWrapper on android native', () => { expect(mockRemoveListener).toHaveBeenCalled() }) + it('does not open the browser when unmounted while listener registration is in flight', async () => { + mockIsAndroidNativeBridge = true + deferListener = true + const { unmount } = render() + await flush() + + unmount() + resolveListener?.() + await flush() + + expect(mockBrowserOpen).not.toHaveBeenCalled() + expect(mockRemoveListener).toHaveBeenCalled() + }) + it('still frames the page on every other platform', async () => { render() await flush() diff --git a/src/components/Global/IframeWrapper/index.tsx b/src/components/Global/IframeWrapper/index.tsx index 6c97b1b530..aed4ae163e 100644 --- a/src/components/Global/IframeWrapper/index.tsx +++ b/src/components/Global/IframeWrapper/index.tsx @@ -62,11 +62,15 @@ const IframeWrapper = ({ src, visible, onClose, closeConfirmMessage }: IFrameWra // Listener first: a tab the user dismisses immediately must not // close before we are listening, or the flow hangs forever. Browser.addListener('browserFinished', () => onCloseRef.current('returned')).then((handle) => { - // Cleanup can run while the dynamic import is still in - // flight; without this the listener registers after the - // fact and nobody ever removes it. - if (disposed) handle.remove() - else remove = () => handle.remove() + // Cleanup can run while the dynamic import / listener + // registration is still in flight; a late arrival must both + // drop its listener AND skip the open, or a fast unmount + // pops the tab after its owning flow has closed. + if (disposed) { + handle.remove() + return + } + remove = () => handle.remove() return Browser.open({ url: src }) }) ) From 2d27be7fd90d855cf256c8cdd42817cbf7b1d6f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 16:59:29 +0000 Subject: [PATCH 09/93] fix(request): unblur the QR code as soon as a positive amount is entered 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 Claude-Session: https://claude.ai/code/session_01YQHgKufMZzqzwCviP4gXKs --- .../Request/__tests__/request-states.test.tsx | 25 ++++++++++++++++++- .../link/views/Create.request.link.view.tsx | 5 +++- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/components/Request/__tests__/request-states.test.tsx b/src/components/Request/__tests__/request-states.test.tsx index 35082dacef..7ffb6c47d2 100644 --- a/src/components/Request/__tests__/request-states.test.tsx +++ b/src/components/Request/__tests__/request-states.test.tsx @@ -513,12 +513,35 @@ describe('GROUP 1: Initial Form States', () => { expect(mockRouterPush).toHaveBeenCalledWith('/home') }) - test('QR code is blurred before request is created', () => { + test('QR code is blurred before an amount is entered', () => { renderCreateRequest() expect(screen.getByTestId('qr-code-wrapper')).toHaveAttribute('data-blurred', 'true') }) + test('QR code unblurs as soon as a positive amount is entered', () => { + renderCreateRequest() + + fireEvent.change(screen.getByTestId('amount-field'), { target: { value: '10' } }) + + expect(screen.getByTestId('qr-code-wrapper')).toHaveAttribute('data-blurred', 'false') + }) + + test('QR code stays blurred for a zero amount and re-blurs when the amount is cleared', () => { + renderCreateRequest() + + const field = screen.getByTestId('amount-field') + + fireEvent.change(field, { target: { value: '0' } }) + expect(screen.getByTestId('qr-code-wrapper')).toHaveAttribute('data-blurred', 'true') + + fireEvent.change(field, { target: { value: '10' } }) + expect(screen.getByTestId('qr-code-wrapper')).toHaveAttribute('data-blurred', 'false') + + fireEvent.change(field, { target: { value: '' } }) + expect(screen.getByTestId('qr-code-wrapper')).toHaveAttribute('data-blurred', 'true') + }) + test('amount input is enabled before request is created', () => { renderCreateRequest() 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 83490c4544..b0e1f1f041 100644 --- a/src/components/Request/link/views/Create.request.link.view.tsx +++ b/src/components/Request/link/views/Create.request.link.view.tsx @@ -373,8 +373,11 @@ export const CreateRequestLinkView = () => {
+ {/* Before a request exists the QR already encodes the profile + payment link for the entered amount, so it only stays + blurred while there's neither a request nor an amount. */} 0)} url={qrCodeLink} isLoading={isCreatingLink || isUpdatingRequest} /> From 29b3b1713d04b2857f4c9841fbf923a9b75bad91 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 17:11:13 +0000 Subject: [PATCH 10/93] feat(pull-to-refresh): make a native refresh visibly land 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 Claude-Session: https://claude.ai/code/session_011zVw46ZCvXYp7b4dpXvJiq --- src/hooks/__tests__/usePullToRefresh.test.tsx | 163 ++++++++++++++++++ src/hooks/usePullToRefresh.ts | 141 +++++++++++++-- 2 files changed, 285 insertions(+), 19 deletions(-) create mode 100644 src/hooks/__tests__/usePullToRefresh.test.tsx diff --git a/src/hooks/__tests__/usePullToRefresh.test.tsx b/src/hooks/__tests__/usePullToRefresh.test.tsx new file mode 100644 index 0000000000..618761f482 --- /dev/null +++ b/src/hooks/__tests__/usePullToRefresh.test.tsx @@ -0,0 +1,163 @@ +/** + * usePullToRefresh — the feedback that tells a native user the pull worked. + * + * On native the refresh is a react-query invalidation rather than a page + * reload, so nothing on screen blinks: if the indicator doesn't run the + * arrow → spinner → checkmark sequence (with haptics), the gesture reads as + * having done nothing at all. + */ +import { renderHook, act } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { usePullToRefresh } from '../usePullToRefresh' + +jest.mock('@/utils/capacitor', () => ({ isCapacitor: jest.fn(() => true) })) +jest.mock('@/utils/haptics', () => ({ + impactHaptic: jest.fn(), + notifyHaptic: jest.fn(), +})) + +import { isCapacitor } from '@/utils/capacitor' +import { impactHaptic, notifyHaptic } from '@/utils/haptics' + +// jsdom implements no Web Animations API — record calls so the spinner/check +// flourishes can be asserted without them throwing. +const animateCalls: { element: Element; keyframes: unknown }[] = [] + +let queryClient: QueryClient + +const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +) + +const indicator = () => document.querySelector('div[aria-hidden="true"]') +const iconHtml = () => indicator()?.firstElementChild?.innerHTML ?? '' + +const touch = (type: string, clientY: number) => { + const event = new Event(type, { bubbles: true }) as TouchEvent & { touches: unknown } + Object.defineProperty(event, 'touches', { + value: type === 'touchend' ? [] : [{ clientX: 0, clientY }], + }) + act(() => { + document.dispatchEvent(event) + }) +} + +// pull past the release threshold: the hook damps the gesture by 0.5, so 200px +// of finger travel is 100px of pull against an 80px threshold +const pullPastThreshold = () => { + touch('touchstart', 0) + touch('touchmove', 200) +} + +beforeEach(() => { + jest.useFakeTimers() + animateCalls.length = 0 + Element.prototype.animate = jest.fn(function (this: Element, keyframes: unknown) { + animateCalls.push({ element: this, keyframes }) + return { cancel: jest.fn() } as unknown as Animation + }) as unknown as Element['animate'] + queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + // the layout's scroll container — the element that gets the settle fade + const content = document.createElement('div') + content.id = 'scrollable-content' + document.body.appendChild(content) + ;(isCapacitor as jest.Mock).mockReturnValue(true) + jest.clearAllMocks() +}) + +afterEach(() => { + jest.useRealTimers() + queryClient.clear() + document.body.innerHTML = '' +}) + +describe('usePullToRefresh', () => { + it('mounts an arrow indicator and removes it on unmount', () => { + const { unmount } = renderHook(() => usePullToRefresh(), { wrapper }) + + expect(indicator()).not.toBeNull() + expect(iconHtml()).toContain('M12 5v14') + + unmount() + expect(indicator()).toBeNull() + }) + + it('taps once when the pull crosses the release threshold', () => { + renderHook(() => usePullToRefresh(), { wrapper }) + + touch('touchstart', 0) + touch('touchmove', 100) // 50px of pull — below the 80px threshold + expect(impactHaptic).not.toHaveBeenCalled() + + touch('touchmove', 200) // 100px of pull — armed + touch('touchmove', 220) // still armed: no second tap + expect(impactHaptic).toHaveBeenCalledTimes(1) + }) + + it('runs spinner → checkmark → success haptic after the refetch lands', async () => { + const invalidateQueries = jest.spyOn(queryClient, 'invalidateQueries').mockResolvedValue(undefined) + renderHook(() => usePullToRefresh(), { wrapper }) + + pullPastThreshold() + touch('touchend', 200) + + expect(invalidateQueries).toHaveBeenCalled() + expect(iconHtml()).toContain('stroke-dasharray="24 33"') // spinner arc + + // the spinner is held for a minimum duration so a warm-cache refetch + // doesn't flash by unnoticed + await act(async () => {}) // let the invalidation promise chain settle + act(() => { + jest.advanceTimersByTime(600) + }) + + expect(iconHtml()).toContain('M5 13l4 4L19 7') // checkmark + expect(indicator()?.style.background).toBe('rgb(152, 233, 171)') + expect(notifyHaptic).toHaveBeenCalledWith('success') + + // the refreshed content fades back in — the "it reloaded" signal on a + // screen that never blinks + const content = document.querySelector('#scrollable-content') + expect(animateCalls.some((call) => call.element === content)).toBe(true) + + // ...then the indicator retracts and resets to the arrow + act(() => { + jest.advanceTimersByTime(550 + 220) + }) + expect(iconHtml()).toContain('M12 5v14') + expect(indicator()?.style.background).toBe('rgb(255, 255, 255)') + }) + + it('reloads the page instead of invalidating on web', () => { + ;(isCapacitor as jest.Mock).mockReturnValue(false) + const reload = jest.fn() + Object.defineProperty(window, 'location', { + value: { ...window.location, reload }, + writable: true, + }) + renderHook(() => usePullToRefresh(), { wrapper }) + + pullPastThreshold() + touch('touchend', 200) + + expect(reload).toHaveBeenCalled() + }) + + it('ignores horizontal gestures so carousels keep working', () => { + const invalidateQueries = jest.spyOn(queryClient, 'invalidateQueries') + renderHook(() => usePullToRefresh(), { wrapper }) + + const start = new Event('touchstart', { bubbles: true }) + Object.defineProperty(start, 'touches', { value: [{ clientX: 0, clientY: 0 }] }) + act(() => document.dispatchEvent(start)) + + const move = new Event('touchmove', { bubbles: true }) + Object.defineProperty(move, 'touches', { value: [{ clientX: 200, clientY: 30 }] }) + act(() => document.dispatchEvent(move)) + + touch('touchend', 30) + + expect(invalidateQueries).not.toHaveBeenCalled() + expect(impactHaptic).not.toHaveBeenCalled() + }) +}) diff --git a/src/hooks/usePullToRefresh.ts b/src/hooks/usePullToRefresh.ts index d66e4aaeaa..0ec15296d8 100644 --- a/src/hooks/usePullToRefresh.ts +++ b/src/hooks/usePullToRefresh.ts @@ -1,6 +1,7 @@ import { useEffect, useRef } from 'react' import { useQueryClient } from '@tanstack/react-query' import { isCapacitor } from '@/utils/capacitor' +import { impactHaptic, notifyHaptic } from '@/utils/haptics' // pull-to-refresh configuration constants const DIST_MAX = 120 // maximum pull distance (visual limit) @@ -9,6 +10,24 @@ const PULL_DAMPING = 0.5 const AXIS_LOCK_SLOP_PX = 10 const INDICATOR_HIDDEN_Y = -48 const MIN_SPIN_MS = 600 +const SUCCESS_HOLD_MS = 550 // how long the checkmark stays up before retracting +const RETRACT_MS = 220 // matches the indicator's transform transition +const CHECK_POP_MS = 260 +const CONTENT_SETTLE_MS = 340 +const DEFAULT_REFRESH_TARGET = '#scrollable-content' + +const IDLE_BG = '#ffffff' +const SUCCESS_BG = '#98E9AB' // green-1 + +const SVG_OPEN = + '` +// r=9 → circumference ~56.5, so a 24-long dash is a ~150deg arc over the track +const SPINNER_ICON = + `${SVG_OPEN} stroke-width="2.5">` + + '' +const CHECK_ICON = `${SVG_OPEN} stroke-width="3">` interface UsePullToRefreshOptions { // custom function to determine if pull-to-refresh should be enabled @@ -16,6 +35,8 @@ interface UsePullToRefreshOptions { shouldPullToRefresh?: () => boolean // whether to enable pull-to-refresh (defaults to true) enabled?: boolean + // element that gets the "content settled" fade once the refetch lands + refreshTargetSelector?: string } /** @@ -29,14 +50,26 @@ interface UsePullToRefreshOptions { * work here because a pull only happens at scrollY 0, where the gesture causes * no scrolling that would need preventDefault; the indicator animates with * compositor-only transform/opacity. + * + * On native the refresh is a react-query invalidation, NOT a page reload + * (window.location.reload() breaks the static export's SPA fallback), so the + * screen never blinks and the gesture can feel like it did nothing. The + * feedback below is what tells the user it happened: + * pulling → the arrow rotates toward "release to refresh" and flips at the + * threshold, with a light haptic on the crossing + * refreshing→ spinner, held for MIN_SPIN_MS so a cache-warm refetch is still + * legible + * done → green checkmark + success haptic, and the content fades back + * in so the screen visibly re-renders */ export const usePullToRefresh = (options: UsePullToRefreshOptions = {}) => { - const { shouldPullToRefresh, enabled = true } = options + const { shouldPullToRefresh, enabled = true, refreshTargetSelector = DEFAULT_REFRESH_TARGET } = options const queryClient = useQueryClient() // store in refs so listener registration survives re-renders const shouldPullToRefreshRef = useRef(shouldPullToRefresh) const queryClientRef = useRef(queryClient) + const refreshTargetRef = useRef(refreshTargetSelector) useEffect(() => { shouldPullToRefreshRef.current = shouldPullToRefresh @@ -46,38 +79,71 @@ export const usePullToRefresh = (options: UsePullToRefreshOptions = {}) => { queryClientRef.current = queryClient }, [queryClient]) + useEffect(() => { + refreshTargetRef.current = refreshTargetSelector + }, [refreshTargetSelector]) + useEffect(() => { if (typeof window === 'undefined' || !enabled) return const indicator = document.createElement('div') indicator.setAttribute('aria-hidden', 'true') indicator.style.cssText = - 'position:fixed;top:0;left:50%;z-index:1000;width:36px;height:36px;margin-left:-18px;' + - 'border-radius:50%;background:#fff;box-shadow:0 2px 8px rgba(0,0,0,0.2);' + + 'position:fixed;top:0;left:50%;z-index:1000;width:40px;height:40px;margin-left:-20px;' + + `border-radius:50%;background:${IDLE_BG};border:2px solid #000;box-shadow:2px 2px 0 #000;` + 'display:flex;align-items:center;justify-content:center;pointer-events:none;' + - `transform:translateY(${INDICATOR_HIDDEN_Y}px);opacity:0;will-change:transform` - indicator.innerHTML = - '' + `transform:translateY(${INDICATOR_HIDDEN_Y}px);opacity:0;will-change:transform,opacity` + + // the icon lives in its own wrapper so the arrow's rotation (and the + // spinner animation) compose with the indicator's translate/scale + const icon = document.createElement('div') + icon.style.cssText = + 'display:flex;align-items:center;justify-content:center;width:18px;height:18px;will-change:transform' + icon.innerHTML = ARROW_ICON + indicator.appendChild(icon) document.body.appendChild(indicator) let pulling = false let refreshing = false + let armed = false let startX = 0 let startY = 0 let pullDistance = 0 let axisLock: 'x' | 'y' | null = null let spinAnimation: Animation | null = null + const timers: ReturnType[] = [] + + // Element.animate is missing in jsdom and in older WebViews — the + // indicator must still work without it, just without the flourish + const animate = (element: Element, keyframes: Keyframe[], animationOptions: KeyframeAnimationOptions) => + typeof element.animate === 'function' ? element.animate(keyframes, animationOptions) : null - const setIndicator = (pull: number, animate: boolean) => { - indicator.style.transition = animate ? 'transform 0.2s ease, opacity 0.2s ease' : 'none' + const prefersReducedMotion = () => + typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches + + const setIndicator = (pull: number, withTransition: boolean) => { + indicator.style.transition = withTransition + ? 'transform 0.2s ease, opacity 0.2s ease, background-color 0.2s ease' + : 'background-color 0.2s ease' + const progress = Math.min(pull / DIST_RELOAD, 1) const y = Math.min(pull, DIST_MAX) + INDICATOR_HIDDEN_Y - indicator.style.transform = `translateY(${y}px)` + // the indicator grows into place as the gesture approaches the threshold + indicator.style.transform = `translateY(${y}px) scale(${0.6 + 0.4 * progress})` indicator.style.opacity = pull > 10 ? '1' : '0' + // arrow points down at rest and is upright at the threshold — the + // standard "release to refresh" affordance + if (!refreshing) icon.style.transform = `rotate(${progress * 180}deg)` + } + + const restoreIdleIndicator = () => { + indicator.style.background = IDLE_BG + icon.innerHTML = ARROW_ICON + icon.style.transform = 'rotate(0deg)' } const resetPull = () => { pulling = false + armed = false axisLock = null pullDistance = 0 setIndicator(0, true) @@ -87,9 +153,13 @@ export const usePullToRefresh = (options: UsePullToRefreshOptions = {}) => { if (refreshing || e.touches.length !== 1) return const allowed = shouldPullToRefreshRef.current ? shouldPullToRefreshRef.current() : window.scrollY === 0 if (!allowed) return + // a new pull can start inside the retract window — put the arrow back + // now, so the previous run's checkmark doesn't get swapped mid-gesture + restoreIdleIndicator() startX = e.touches[0].clientX startY = e.touches[0].clientY pulling = true + armed = false axisLock = null pullDistance = 0 } @@ -108,13 +178,44 @@ export const usePullToRefresh = (options: UsePullToRefreshOptions = {}) => { } pullDistance = dy * PULL_DAMPING if (pullDistance > 0) setIndicator(pullDistance, false) + // a tap on the threshold tells the user the release will do something, + // before they let go — fired once per crossing, not per touchmove + const nowArmed = pullDistance >= DIST_RELOAD + if (nowArmed !== armed) { + armed = nowArmed + if (nowArmed) impactHaptic() + } + } + + // the refreshed screen fades back in — on native nothing else on screen + // changes when the refetch resolves, so this is the "it reloaded" signal + const settleContent = () => { + if (prefersReducedMotion()) return + const target = document.querySelector(refreshTargetRef.current) + if (!target) return + animate(target, [{ opacity: 0.35 }, { opacity: 1 }], { duration: CONTENT_SETTLE_MS, easing: 'ease-out' }) } const finishRefresh = () => { spinAnimation?.cancel() spinAnimation = null - refreshing = false - resetPull() + icon.innerHTML = CHECK_ICON + icon.style.transform = 'none' + indicator.style.background = SUCCESS_BG + animate(icon, [{ transform: 'scale(0.3)' }, { transform: 'scale(1.15)' }, { transform: 'scale(1)' }], { + duration: CHECK_POP_MS, + easing: 'ease-out', + }) + notifyHaptic('success') + settleContent() + timers.push( + setTimeout(() => { + refreshing = false + resetPull() + // swap back to the arrow only once the indicator is off-screen + timers.push(setTimeout(restoreIdleIndicator, RETRACT_MS)) + }, SUCCESS_HOLD_MS) + ) } const onTouchEnd = () => { @@ -126,13 +227,14 @@ export const usePullToRefresh = (options: UsePullToRefreshOptions = {}) => { } refreshing = true + armed = false setIndicator(DIST_RELOAD, true) - const svg = indicator.firstElementChild - spinAnimation = - svg?.animate([{ transform: 'rotate(0deg)' }, { transform: 'rotate(360deg)' }], { - duration: 800, - iterations: Infinity, - }) ?? null + icon.innerHTML = SPINNER_ICON + icon.style.transform = 'none' + spinAnimation = animate(icon, [{ transform: 'rotate(0deg)' }, { transform: 'rotate(360deg)' }], { + duration: 800, + iterations: Infinity, + }) if (isCapacitor()) { // in native app, invalidate queries to refetch the visible screen's @@ -143,7 +245,7 @@ export const usePullToRefresh = (options: UsePullToRefreshOptions = {}) => { .catch(() => {}) .then(() => { const remaining = Math.max(0, MIN_SPIN_MS - (Date.now() - startedAt)) - setTimeout(finishRefresh, remaining) + timers.push(setTimeout(finishRefresh, remaining)) }) } else { window.location.reload() @@ -161,6 +263,7 @@ export const usePullToRefresh = (options: UsePullToRefreshOptions = {}) => { document.removeEventListener('touchmove', onTouchMove) document.removeEventListener('touchend', onTouchEnd) document.removeEventListener('touchcancel', onTouchEnd) + timers.forEach(clearTimeout) spinAnimation?.cancel() indicator.remove() } From 6b30067278c9c9716179570167112ad0058c58f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 17:14:57 +0000 Subject: [PATCH 11/93] fix(profile): make back work after the exchange-rate widget CTA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01SbALh4dJvcBUAinjnwQ53q --- .../__tests__/add-money-states.test.tsx | 33 ++++++ src/app/(mobile-ui)/add-money/page.tsx | 9 ++ .../__tests__/exchange-rate-page.test.tsx | 106 ++++++++++++++++++ .../profile/exchange-rate/page.tsx | 9 +- .../__tests__/withdraw-states.test.tsx | 28 +++++ src/app/(mobile-ui)/withdraw/page.tsx | 8 +- src/utils/__tests__/return-to.utils.test.ts | 62 ++++++++++ src/utils/return-to.utils.ts | 48 ++++++++ 8 files changed, 300 insertions(+), 3 deletions(-) create mode 100644 src/app/(mobile-ui)/profile/exchange-rate/__tests__/exchange-rate-page.test.tsx create mode 100644 src/utils/__tests__/return-to.utils.test.ts create mode 100644 src/utils/return-to.utils.ts diff --git a/src/app/(mobile-ui)/add-money/__tests__/add-money-states.test.tsx b/src/app/(mobile-ui)/add-money/__tests__/add-money-states.test.tsx index 8c1a147584..3834422c49 100644 --- a/src/app/(mobile-ui)/add-money/__tests__/add-money-states.test.tsx +++ b/src/app/(mobile-ui)/add-money/__tests__/add-money-states.test.tsx @@ -329,6 +329,10 @@ jest.mock('@/utils/general.utils', () => ({ formatCurrency: jest.fn((v: any) => v?.toString() ?? '0'), checkIfInternalNavigation: jest.fn(() => false), formatNumberForDisplay: jest.fn((v: any) => v ?? '0'), + // real implementation: same-origin paths pass, everything else is rejected + sanitizeRedirectURL: jest.fn((url: string) => + url.startsWith('/') && !url.startsWith('//') && !url.includes('://') ? url : null + ), })) jest.mock('@/utils/currency', () => ({ @@ -1093,6 +1097,35 @@ describe('GROUP 1: Landing / Method Selection', () => { fireEvent.click(screen.getByTestId('nav-header')) expect(mockRouterPush).toHaveBeenCalledWith('/home') }) + + // Entering add-money from the exchange-rate widget's "Try it!" CTA used to + // strand the user: back reset to /home instead of the screen they came from. + test('back honours ?returnTo when the flow was entered from another screen', () => { + mockSearchParams.set('returnTo', '/profile/exchange-rate?from=USD&to=EUR') + renderWithProviders() + + fireEvent.click(screen.getByTestId('nav-header')) + expect(mockRouterPush).toHaveBeenCalledWith('/profile/exchange-rate?from=USD&to=EUR') + expect(mockRouterPush).not.toHaveBeenCalledWith('/home') + }) + + test('back ignores an off-origin ?returnTo and still resets to /home', () => { + mockSearchParams.set('returnTo', 'https://evil.example/phish') + renderWithProviders() + + fireEvent.click(screen.getByTestId('nav-header')) + expect(mockRouterPush).toHaveBeenCalledWith('/home') + }) + + test('back on the country list still collapses to method selection first', () => { + mockSearchParams.set('returnTo', '/profile/exchange-rate') + resetQueryState({ method: 'bank' }) + renderWithProviders() + + fireEvent.click(screen.getByTestId('nav-header')) + expect(mockSetQueryState).toHaveBeenCalledWith({ method: null }) + expect(mockRouterPush).not.toHaveBeenCalled() + }) }) // ============================================================ diff --git a/src/app/(mobile-ui)/add-money/page.tsx b/src/app/(mobile-ui)/add-money/page.tsx index 22b8672f2e..638061a39c 100644 --- a/src/app/(mobile-ui)/add-money/page.tsx +++ b/src/app/(mobile-ui)/add-money/page.tsx @@ -15,6 +15,7 @@ import { useRouter, useSearchParams } from 'next/navigation' import { useEffect } from 'react' import { useQueryState, parseAsStringEnum } from 'nuqs' import { getRedirectUrl, clearRedirectUrl, getFromLocalStorage } from '@/utils/general.utils' +import { readReturnTo } from '@/utils/return-to.utils' import { isBridgeSupportedCountry } from '@/utils/regions.utils' import { isMantecaSupportedCountryCode } from '@/constants/manteca.consts' import posthog from 'posthog-js' @@ -52,6 +53,14 @@ export default function AddMoneyPage() { return } + // an explicit origin (e.g. the exchange-rate widget's "Try it!" CTA) wins over + // the /home reset below — that reset is only right for tab-bar entries + const returnTo = readReturnTo(searchParams, '/add-money') + if (returnTo) { + router.push(returnTo) + return + } + // check if we have a saved redirect url (from request fulfillment or similar flows) const redirectUrl = getRedirectUrl() const fromRequestFulfillment = getFromLocalStorage('fromRequestFulfillment') diff --git a/src/app/(mobile-ui)/profile/exchange-rate/__tests__/exchange-rate-page.test.tsx b/src/app/(mobile-ui)/profile/exchange-rate/__tests__/exchange-rate-page.test.tsx new file mode 100644 index 0000000000..db847022e7 --- /dev/null +++ b/src/app/(mobile-ui)/profile/exchange-rate/__tests__/exchange-rate-page.test.tsx @@ -0,0 +1,106 @@ +/** + * /profile/exchange-rate — "Try it!" CTA navigation. + * + * The CTA drops the user into the add-money / withdraw roots, whose back buttons + * reset to /home. Without an explicit origin the user is stranded there, which is + * the bug these tests lock down: every CTA target carries ?returnTo back here. + */ +import React from 'react' +import { render, screen, fireEvent } from '@testing-library/react' +import { IntlWrapper } from '@/test-utils/intl' + +const mockRouterPush = jest.fn() +jest.mock('next/navigation', () => ({ + useRouter: () => ({ push: mockRouterPush, back: jest.fn(), replace: jest.fn(), prefetch: jest.fn() }), +})) + +jest.mock('@/hooks/useSafeBack', () => ({ + useSafeBack: () => jest.fn(), +})) + +const mockUseWallet = jest.fn() +jest.mock('@/hooks/wallet/useWallet', () => ({ + useWallet: () => mockUseWallet(), +})) + +const mockUseCapabilities = jest.fn() +jest.mock('@/hooks/useCapabilities', () => ({ + useCapabilities: () => mockUseCapabilities(), +})) + +jest.mock('@/utils/regions.utils', () => ({ + deriveRegionAccess: () => ({ unlockedRegions: [] }), +})) + +const mockGetRedirectRoute = jest.fn() +jest.mock('@/utils/exchangeRateWidget.utils', () => ({ + getExchangeRateWidgetRedirectRoute: (...args: any[]) => mockGetRedirectRoute(...args), +})) + +jest.mock('@/components/0_Bruddle/PageContainer', () => ({ + __esModule: true, + default: ({ children }: any) =>
{children}
, +})) + +jest.mock('@/components/Global/NavHeader', () => ({ + __esModule: true, + default: ({ onPrev }: any) => ( + + ), +})) + +jest.mock('@/components/Global/ExchangeRateWidget', () => ({ + __esModule: true, + default: ({ ctaAction }: any) => ( + + ), +})) + +import ExchangeRatePage from '../page' + +const renderPage = (search = '') => { + window.history.replaceState({}, '', `/profile/exchange-rate${search}`) + return render( + + + + ) +} + +beforeEach(() => { + jest.clearAllMocks() + mockUseWallet.mockReturnValue({ balance: 0n }) + mockUseCapabilities.mockReturnValue({ rails: [] }) + mockGetRedirectRoute.mockReturnValue('/add-money') +}) + +describe('exchange-rate CTA', () => { + it('tells the destination to send the user back here', () => { + renderPage() + + fireEvent.click(screen.getByTestId('widget-cta')) + expect(mockRouterPush).toHaveBeenCalledWith('/add-money?returnTo=%2Fprofile%2Fexchange-rate') + }) + + it('carries the widget state so back restores the pair the user was looking at', () => { + renderPage('?from=USD&to=EUR&amount=25') + + fireEvent.click(screen.getByTestId('widget-cta')) + const pushed: string = mockRouterPush.mock.calls[0][0] + expect(new URLSearchParams(pushed.split('?')[1]).get('returnTo')).toBe( + '/profile/exchange-rate?from=USD&to=EUR&amount=25' + ) + }) + + it('preserves a query string the destination route already has', () => { + mockGetRedirectRoute.mockReturnValue('/withdraw?currencyCode=EUR') + renderPage() + + fireEvent.click(screen.getByTestId('widget-cta')) + expect(mockRouterPush).toHaveBeenCalledWith('/withdraw?currencyCode=EUR&returnTo=%2Fprofile%2Fexchange-rate') + }) +}) diff --git a/src/app/(mobile-ui)/profile/exchange-rate/page.tsx b/src/app/(mobile-ui)/profile/exchange-rate/page.tsx index 7c229f8f2a..d2e6dd2cba 100644 --- a/src/app/(mobile-ui)/profile/exchange-rate/page.tsx +++ b/src/app/(mobile-ui)/profile/exchange-rate/page.tsx @@ -6,6 +6,7 @@ import NavHeader from '@/components/Global/NavHeader' import { useWallet } from '@/hooks/wallet/useWallet' import { printableUsdc } from '@/utils/balance.utils' import { getExchangeRateWidgetRedirectRoute } from '@/utils/exchangeRateWidget.utils' +import { withReturnTo } from '@/utils/return-to.utils' import { useCapabilities } from '@/hooks/useCapabilities' import { deriveRegionAccess } from '@/utils/regions.utils' import { useTranslations } from 'next-intl' @@ -34,7 +35,13 @@ export default function ExchangeRatePage() { formattedBalance, unlockedRegionPaths ) - router.push(redirectRoute) + + // The CTA drops the user into the add-money / withdraw roots, whose back + // buttons reset to /home. Tell them where the user actually came from so + // back returns to this widget — query string included, so the currency + // pair and amount they were looking at are still there. + const returnTo = `${window.location.pathname}${window.location.search}` + router.push(withReturnTo(redirectRoute, returnTo)) } return ( 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 2f5fe90a5f..1cc0b8e0ca 100644 --- a/src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx +++ b/src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx @@ -95,6 +95,10 @@ jest.mock('@/context/tokenSelector.context', () => ({ jest.mock('@/utils/general.utils', () => ({ formatAmount: jest.fn((v: any) => v ?? '0'), formatNumberForDisplay: jest.fn((v: any) => v ?? '0'), + // real implementation: same-origin paths pass, everything else is rejected + sanitizeRedirectURL: jest.fn((url: string) => + url.startsWith('/') && !url.startsWith('//') && !url.includes('://') ? url : null + ), })) const mockGetCountryFromAccount = jest.fn( @@ -299,6 +303,30 @@ describe('GROUP 1: Method Selection', () => { expect(mockRouterPush).toHaveBeenCalledWith('/home') }) + // The exchange-rate widget's "Try it!" CTA lands here for users with a + // balance; back used to reset to /home instead of the widget they came from. + test('Back honours ?returnTo when the flow was entered from another screen', () => { + renderWithdraw({ returnTo: '/profile/exchange-rate?from=USD&to=EUR' }) + + fireEvent.click(screen.getByTestId('router-view-back')) + expect(mockRouterPush).toHaveBeenCalledWith('/profile/exchange-rate?from=USD&to=EUR') + expect(mockRouterPush).not.toHaveBeenCalledWith('/home') + }) + + test('Back ignores an off-origin ?returnTo and still resets to /home', () => { + renderWithdraw({ returnTo: 'https://evil.example/phish' }) + + fireEvent.click(screen.getByTestId('router-view-back')) + expect(mockRouterPush).toHaveBeenCalledWith('/home') + }) + + test('Back from the send flow still goes to /send, ignoring ?returnTo', () => { + renderWithdraw({ method: 'bank', returnTo: '/profile/exchange-rate' }) + + fireEvent.click(screen.getByTestId('router-view-back')) + expect(mockRouterPush).toHaveBeenCalledWith('/send') + }) + test('Back from bank send method selection navigates to /send', () => { renderWithdraw({ method: 'bank' }) diff --git a/src/app/(mobile-ui)/withdraw/page.tsx b/src/app/(mobile-ui)/withdraw/page.tsx index 5241bf86f8..8ed07d5f41 100644 --- a/src/app/(mobile-ui)/withdraw/page.tsx +++ b/src/app/(mobile-ui)/withdraw/page.tsx @@ -22,6 +22,7 @@ import { getLimitsWarningCardProps } from '@/features/limits/utils' import posthog from 'posthog-js' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' import { withdrawBankUrl, withdrawCountryUrl } from '@/utils/native-routes' +import { readReturnTo } from '@/utils/return-to.utils' import { useTranslations } from 'next-intl' type WithdrawStep = 'inputAmount' | 'selectMethod' @@ -473,9 +474,12 @@ export default function WithdrawPage() { // if bank from send flow, go back to send page if (isBankFromSend) { router.push('/send') - } else { - router.push('/home') + return } + // an explicit origin (e.g. the exchange-rate widget's "Try it!" CTA) + // wins over the /home reset, which only fits tab-bar entries + const returnTo = readReturnTo(searchParams, '/withdraw') + router.push(returnTo ?? '/home') }} /> ) diff --git a/src/utils/__tests__/return-to.utils.test.ts b/src/utils/__tests__/return-to.utils.test.ts new file mode 100644 index 0000000000..d85013b506 --- /dev/null +++ b/src/utils/__tests__/return-to.utils.test.ts @@ -0,0 +1,62 @@ +import { RETURN_TO_PARAM, readReturnTo, withReturnTo } from '../return-to.utils' + +const params = (entries: Record) => new URLSearchParams(entries) + +describe('withReturnTo', () => { + it('appends the param to a route with no query string', () => { + expect(withReturnTo('/add-money', '/profile/exchange-rate')).toBe( + '/add-money?returnTo=%2Fprofile%2Fexchange-rate' + ) + }) + + it('preserves a query string the route already carries', () => { + expect(withReturnTo('/withdraw?currencyCode=EUR', '/profile/exchange-rate')).toBe( + '/withdraw?currencyCode=EUR&returnTo=%2Fprofile%2Fexchange-rate' + ) + }) + + it('keeps the origin query string of the returnTo target', () => { + const url = withReturnTo('/add-money', '/profile/exchange-rate?from=USD&to=EUR&amount=25') + expect(new URLSearchParams(url.split('?')[1]).get(RETURN_TO_PARAM)).toBe( + '/profile/exchange-rate?from=USD&to=EUR&amount=25' + ) + }) + + it('drops an off-origin target rather than encoding it', () => { + expect(withReturnTo('/add-money', 'https://evil.example/phish')).toBe('/add-money') + expect(withReturnTo('/add-money', '//evil.example/phish')).toBe('/add-money') + }) +}) + +describe('readReturnTo', () => { + it('returns null when the param is absent', () => { + expect(readReturnTo(params({}), '/add-money')).toBeNull() + }) + + it('returns the sanitized internal path', () => { + expect(readReturnTo(params({ returnTo: '/profile/exchange-rate?from=USD' }), '/add-money')).toBe( + '/profile/exchange-rate?from=USD' + ) + }) + + it('rejects off-origin targets', () => { + expect(readReturnTo(params({ returnTo: 'https://evil.example/phish' }), '/add-money')).toBeNull() + expect(readReturnTo(params({ returnTo: '//evil.example/phish' }), '/add-money')).toBeNull() + }) + + // A self-referential value would re-push the page the user is already on — + // i.e. a back button that does nothing, the bug this param exists to fix. + it('rejects a target pointing at the current page', () => { + expect(readReturnTo(params({ returnTo: '/add-money' }), '/add-money')).toBeNull() + expect(readReturnTo(params({ returnTo: '/add-money/?method=bank' }), '/add-money')).toBeNull() + }) + + it('allows a sub-path of the current page', () => { + expect(readReturnTo(params({ returnTo: '/add-money/germany' }), '/add-money')).toBe('/add-money/germany') + }) + + it('tolerates a missing search params object', () => { + expect(readReturnTo(null, '/add-money')).toBeNull() + expect(readReturnTo(undefined, '/add-money')).toBeNull() + }) +}) diff --git a/src/utils/return-to.utils.ts b/src/utils/return-to.utils.ts new file mode 100644 index 0000000000..73a0d7784f --- /dev/null +++ b/src/utils/return-to.utils.ts @@ -0,0 +1,48 @@ +import { sanitizeRedirectURL } from './general.utils' + +/** + * `?returnTo=` — the origin a flow root should send the user back to. + * + * The add-money and withdraw roots are entry points: their back buttons reset to + * `/home` rather than calling `router.back()`, because their own sub-pages push + * back to the root and `back()` there ping-pongs. That's correct when the flow + * was entered from the tab bar, but it strands anyone who arrived from another + * screen — e.g. the exchange-rate widget's "Try it!" CTA, after which back used + * to drop the user on /home instead of the widget they came from. + * + * So the *caller* states where back should go, and the flow root honours it. + * Same-origin only, and never the page the user is already on (a self-referential + * value would make back a no-op — the very bug this fixes). + */ +export const RETURN_TO_PARAM = 'returnTo' + +/** Appends `?returnTo=` to `route`, preserving any query string it already has. */ +export const withReturnTo = (route: string, returnTo: string): string => { + const sanitized = sanitizeRedirectURL(returnTo) + if (!sanitized) return route + const separator = route.includes('?') ? '&' : '?' + return `${route}${separator}${RETURN_TO_PARAM}=${encodeURIComponent(sanitized)}` +} + +type ReadonlyParams = Pick | null | undefined + +/** + * Reads a safe `returnTo` target out of the current query string. + * Returns null when absent, off-origin, or pointing at `currentPathname`. + */ +export const readReturnTo = (searchParams: ReadonlyParams, currentPathname?: string | null): string | null => { + const raw = searchParams?.get(RETURN_TO_PARAM) + if (!raw) return null + + const sanitized = sanitizeRedirectURL(raw) + if (!sanitized) return null + + if (currentPathname) { + const targetPathname = sanitized.split(/[?#]/)[0] + // trailing slash is not a meaningful difference for Next.js routes + const normalize = (path: string) => (path.length > 1 ? path.replace(/\/+$/, '') : path) + if (normalize(targetPathname) === normalize(currentPathname)) return null + } + + return sanitized +} From 134a221597e37ee2e7d387a498157dcd67e95968 Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Thu, 20 Aug 2026 18:23:55 +0100 Subject: [PATCH 12/93] feat(rewards): iOS-only cashback copy, referral surfaces restored MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverses the iOS gate from 4564b401 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! 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.` 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. --- src/app/(mobile-ui)/home/page.tsx | 6 +- src/app/(mobile-ui)/qr-pay/page.tsx | 5 +- src/app/(mobile-ui)/rewards/invites/page.tsx | 17 +-- src/app/(mobile-ui)/rewards/page.tsx | 34 +++-- .../Global/InviteFriendsModal/index.tsx | 11 +- src/components/Home/PerkClaimModal.tsx | 31 ++-- src/components/Profile/index.tsx | 22 ++- .../TransactionDetailsReceipt.tsx | 6 +- src/config/__tests__/ios-store-gating.test.ts | 15 +- src/config/appStoreCompliance.ts | 25 ---- src/hooks/useHomeCarouselCTAs.tsx | 8 +- .../app/__tests__/useAppTranslations.test.tsx | 141 ++++++++++++++++++ src/i18n/app/messages/en.json | 102 ++++++++++++- src/i18n/app/messages/es-419.json | 102 ++++++++++++- src/i18n/app/messages/es-AR.json | 63 +++++++- src/i18n/app/messages/pt-BR.json | 102 ++++++++++++- src/i18n/app/useAppTranslations.ts | 51 +++++++ 17 files changed, 608 insertions(+), 133 deletions(-) delete mode 100644 src/config/appStoreCompliance.ts create mode 100644 src/i18n/app/__tests__/useAppTranslations.test.tsx create mode 100644 src/i18n/app/useAppTranslations.ts diff --git a/src/app/(mobile-ui)/home/page.tsx b/src/app/(mobile-ui)/home/page.tsx index 64daa0fbb2..44c2c40006 100644 --- a/src/app/(mobile-ui)/home/page.tsx +++ b/src/app/(mobile-ui)/home/page.tsx @@ -33,8 +33,8 @@ import NavigationArrow from '@/components/Global/NavigationArrow' import { updateUserById } from '@/app/actions/users' import { useAppHaptic } from '@/hooks/useAppHaptic' import { useTranslations } from 'next-intl' +import { useAppTranslations } from '@/i18n/app/useAppTranslations' import { useActivationStatus } from '@/hooks/useActivationStatus' -import { isReferralRewardsHidden } from '@/config/appStoreCompliance' import ActivationCTAs from '@/components/Home/ActivationCTAs' import PendingVerificationTasks from '@/components/Home/PendingVerificationTasks' import LazyLoadErrorBoundary from '@/components/Global/LazyLoadErrorBoundary' @@ -60,7 +60,7 @@ 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 t = useAppTranslations('home') const tNav = useTranslations('navigation') const { showPermissionModal } = useNotifications() const { isGetAppModalOpen, setIsGetAppModalOpen } = useModalsContext() @@ -203,7 +203,7 @@ export default function Home() {
- {isActivated && !isReferralRewardsHidden() && ( + {isActivated && ( triggerHaptic()} href="/rewards" className="flex items-center gap-0"> diff --git a/src/app/(mobile-ui)/qr-pay/page.tsx b/src/app/(mobile-ui)/qr-pay/page.tsx index 998ffda6fc..fc02d5fa32 100644 --- a/src/app/(mobile-ui)/qr-pay/page.tsx +++ b/src/app/(mobile-ui)/qr-pay/page.tsx @@ -3,6 +3,7 @@ import { railUserMessage, railVerdict } from '@/utils/capability-gate' import { useSearchParams, useRouter } from 'next/navigation' import { useTranslations } from 'next-intl' +import { useAppTranslations } from '@/i18n/app/useAppTranslations' import { useState, useCallback, useMemo, useEffect, useContext, useRef } from 'react' import { useSafeBack } from '@/hooks/useSafeBack' import { PeanutDoesntStoreAnyPersonalInformation } from '@/components/Kyc/PeanutDoesntStoreAnyPersonalInformation' @@ -92,7 +93,7 @@ const NON_RETRYABLE_QR_PAY_ERRORS = [ type PaymentProcessor = 'MANTECA' export default function QRPayPage() { - const t = useTranslations('qrPay') + const t = useAppTranslations('qrPay') const tNav = useTranslations('navigation') const tCommon = useTranslations('common') const tErrors = useTranslations('errors') @@ -1679,7 +1680,7 @@ export default function QRPayPage() { } const QrPayPageLoading = ({ message }: { message: string }) => { - const t = useTranslations('qrPay') + const t = useAppTranslations('qrPay') return (
diff --git a/src/app/(mobile-ui)/rewards/invites/page.tsx b/src/app/(mobile-ui)/rewards/invites/page.tsx index d93850c41a..e742af12ed 100644 --- a/src/app/(mobile-ui)/rewards/invites/page.tsx +++ b/src/app/(mobile-ui)/rewards/invites/page.tsx @@ -21,13 +21,13 @@ import { formatPoints } from '@/utils/format.utils' import { useCountUp } from '@/hooks/useCountUp' import { useInView } from 'framer-motion' import { useEffect, useRef } from 'react' -import { useTranslations } from 'next-intl' +import { useAppTranslations } from '@/i18n/app/useAppTranslations' +import { isIOSNative } from '@/utils/capacitor' import InviteePointsBadge from '@/components/Points/InviteePointsBadge' import { profileUrl } from '@/utils/native-routes' -import { isReferralRewardsHidden } from '@/config/appStoreCompliance' const InvitesPage = () => { - const t = useTranslations('rewards') + const t = useAppTranslations('rewards') const router = useRouter() const onBack = useSafeBack('/rewards') const { user } = useAuth() @@ -56,14 +56,6 @@ const InvitesPage = () => { enabled: !isLoading && !isError, }) - // Guideline 3.1.5(ii) — see /rewards; the deep link has to close too. - const hideReferralRewards = isReferralRewardsHidden() - useEffect(() => { - if (hideReferralRewards) router.replace('/home') - }, [hideReferralRewards, router]) - - if (hideReferralRewards) return null - if (isLoading) { return } @@ -79,7 +71,7 @@ const InvitesPage = () => { return ( - +
@@ -90,6 +82,7 @@ const InvitesPage = () => { ${invites.summary.totalLifetimeEarnedUsd.toFixed(2)} + {isIOSNative() && {t('lifetimeCaption')}} {t('starAlt')} {formatPoints(totalPointsEarned)} {t('pointsLabel', { count: totalPointsEarned })} diff --git a/src/app/(mobile-ui)/rewards/page.tsx b/src/app/(mobile-ui)/rewards/page.tsx index ff3a450aa6..fffb733727 100644 --- a/src/app/(mobile-ui)/rewards/page.tsx +++ b/src/app/(mobile-ui)/rewards/page.tsx @@ -34,12 +34,12 @@ import { profileUrl } from '@/utils/native-routes' import { Button } from '@/components/0_Bruddle/Button' import { useCountUp } from '@/hooks/useCountUp' import { useInView } from 'framer-motion' -import { useTranslations } from 'next-intl' +import { useAppTranslations } from '@/i18n/app/useAppTranslations' +import { isIOSNative } from '@/utils/capacitor' import InviteePointsBadge from '@/components/Points/InviteePointsBadge' -import { isReferralRewardsHidden } from '@/config/appStoreCompliance' const PointsPage = () => { - const t = useTranslations('rewards') + const t = useAppTranslations('rewards') const router = useRouter() const onBack = useSafeBack('/home') const { user, fetchUser } = useAuth() @@ -96,14 +96,6 @@ const PointsPage = () => { enabled: !!tierInfo?.data, }) - // Guideline 3.1.5(ii): the referral programme is unreachable in the iOS app, - // including by deep link — hiding only the entry points would leave /rewards - // one URL away. - const hideReferralRewards = isReferralRewardsHidden() - useEffect(() => { - if (hideReferralRewards) router.replace('/home') - }, [hideReferralRewards, router]) - useEffect(() => { posthog.capture(ANALYTICS_EVENTS.POINTS_PAGE_VIEWED) }, []) @@ -113,8 +105,6 @@ const PointsPage = () => { fetchUser() }, []) - if (hideReferralRewards) return null - if (isLoading || isTierInfoLoading || !tierInfo?.data) { return } @@ -235,6 +225,24 @@ const PointsPage = () => {
+ {/* iOS presents the programme as cashback (see useAppTranslations); + the explainer is part of that framing, so web and Android skip it */} + {isIOSNative() && ( + +

{t('howItWorks.title')}

+
    + {(['step1', 'step2', 'step3', 'step4'] as const).map((step, i) => ( +
  1. + + {i + 1} + + {t(`howItWorks.${step}`)} +
  2. + ))} +
+
+ )} + {/* invite graph with consolidated explanation */} {myGraphResult?.data && ( <> diff --git a/src/components/Global/InviteFriendsModal/index.tsx b/src/components/Global/InviteFriendsModal/index.tsx index cb0412afff..70d8356673 100644 --- a/src/components/Global/InviteFriendsModal/index.tsx +++ b/src/components/Global/InviteFriendsModal/index.tsx @@ -5,10 +5,9 @@ import ShareButton from '@/components/Global/ShareButton' import { generateInviteCodeLink } from '@/utils/general.utils' import { ANALYTICS_EVENTS, MODAL_TYPES, REFERRAL_SOURCES } from '@/constants/analytics.consts' import posthog from 'posthog-js' -import { useTranslations } from 'next-intl' +import { useAppTranslations } from '@/i18n/app/useAppTranslations' import { useEffect, useRef } from 'react' import QRCode from 'react-qr-code' -import { isReferralRewardsHidden } from '@/config/appStoreCompliance' interface InviteFriendsModalProps { visible: boolean @@ -25,7 +24,7 @@ interface InviteFriendsModalProps { * Used in: CardSuccessScreen, Profile, PointsPage */ export default function InviteFriendsModal({ visible, onClose, username, source }: InviteFriendsModalProps) { - const t = useTranslations('global') + const t = useAppTranslations('global') const { inviteLink } = generateInviteCodeLink(username) const hasTrackedShow = useRef(false) @@ -52,11 +51,7 @@ export default function InviteFriendsModal({ visible, onClose, username, source visible={visible} onClose={handleClose} title={t('inviteFriendsModal.title')} - // Inviting is fine under guideline 3.1.5(ii); promising payment for it - // is not. iOS gets the same share flow minus the earnings claim. - description={t( - isReferralRewardsHidden() ? 'inviteFriendsModal.descriptionNoRewards' : 'inviteFriendsModal.description' - )} + description={t('inviteFriendsModal.description')} icon="user-plus" content={ <> diff --git a/src/components/Home/PerkClaimModal.tsx b/src/components/Home/PerkClaimModal.tsx index f2518e4f86..15a4cfef99 100644 --- a/src/components/Home/PerkClaimModal.tsx +++ b/src/components/Home/PerkClaimModal.tsx @@ -2,6 +2,7 @@ import { useState, useCallback, useRef, useEffect } from 'react' import { useTranslations } from 'next-intl' +import { useAppTranslations } from '@/i18n/app/useAppTranslations' import { useQueryClient } from '@tanstack/react-query' import { perksApi, type PendingPerk } from '@/services/perks' import { Icon } from '@/components/Global/Icons/Icon' @@ -20,7 +21,6 @@ import { getUserPreferences, updateUserPreferences } from '@/utils/general.utils import { useAuth } from '@/context/authContext' import posthog from 'posthog-js' import { ANALYTICS_EVENTS, REFERRAL_SOURCES } from '@/constants/analytics.consts' -import { isReferralRewardsHidden } from '@/config/appStoreCompliance' type ClaimPhase = 'idle' | 'holding' | 'opening' | 'revealed' | 'exiting' @@ -176,7 +176,7 @@ 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 t = useAppTranslations('home.perk') const tCommon = useTranslations('common') const inviteeName = perk.inviteeName ?? extractInviteeName(perk.reason) const { triggerHaptic } = useAppHaptic() @@ -185,7 +185,6 @@ function SuccessModal({ perk, claimPhase, onClose, onDismiss }: SuccessModalProp const [canDismiss, setCanDismiss] = useState(false) const [isInviteModalOpen, setIsInviteModalOpen] = useState(false) const isExiting = claimPhase === 'exiting' - const hideReferralRewards = isReferralRewardsHidden() // Surprise moment claim count: read synchronously so first render has correct copy. // 0=first surprise, 1=second, 2+=normal referral claim. @@ -206,9 +205,7 @@ function SuccessModal({ perk, claimPhase, onClose, onDismiss }: SuccessModalProp return () => clearTimeout(dismissTimer) }, []) // eslint-disable-line react-hooks/exhaustive-deps -- triggerHaptic is stable - // The surprise-moment treatment is pure reward messaging ("You just earned - // $X", "share & earn"), so iOS falls through to the plain claimed state. - const isSurpriseMoment = claimCount < 2 && !hideReferralRewards + const isSurpriseMoment = claimCount < 2 return ( <> @@ -277,17 +274,15 @@ function SuccessModal({ perk, claimPhase, onClose, onDismiss }: SuccessModalProp - {!hideReferralRewards && ( -

{ - onDismiss() - router.push('/rewards') - }} - > - {t('inviteFriendsToEarnMore')} -

- )} +

{ + onDismiss() + router.push('/rewards') + }} + > + {t('inviteFriendsToEarnMore')} +

)}
@@ -317,7 +312,7 @@ interface GiftBoxContentProps { * Gift box with hold-to-claim interaction */ function GiftBoxContent({ perk, onHoldComplete, claimPhase }: GiftBoxContentProps) { - const t = useTranslations('home.perk') + const t = useAppTranslations('home.perk') const { holdProgress, isShaking, shakeIntensity, buttonProps } = useHoldToClaim({ onComplete: onHoldComplete, disabled: claimPhase !== 'idle', diff --git a/src/components/Profile/index.tsx b/src/components/Profile/index.tsx index 7ce8d8b569..cacfd2ba23 100644 --- a/src/components/Profile/index.tsx +++ b/src/components/Profile/index.tsx @@ -8,7 +8,7 @@ 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 { useAppTranslations } from '@/i18n/app/useAppTranslations' import { LOCALE_LABELS } from '@/i18n/app/config' import { useAppLocale } from '@/i18n/app/AppIntlProvider' import { useIdentityVerification } from '@/hooks/useIdentityVerification' @@ -20,7 +20,6 @@ import ShowNameToggle from './components/ShowNameToggle' import InviteFriendsModal from '../Global/InviteFriendsModal' import STAR_STRAIGHT_ICON from '@/assets/icons/starStraight.svg' import Image from 'next/image' -import { isReferralRewardsHidden } from '@/config/appStoreCompliance' export const Profile = () => { const { logoutUser, isLoggingOut, user } = useAuth() @@ -33,9 +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 t = useAppTranslations('profile') const { locale } = useAppLocale() - const hideReferralRewards = isReferralRewardsHidden() const logout = async () => { await logoutUser() @@ -78,16 +76,14 @@ export const Profile = () => { icon="achievements" label={t('menu.yourBadges')} href="/badges" - position={hideReferralRewards ? 'last' : 'middle'} + position="middle" + /> + } + label={t('menu.points')} + href="/rewards" + position="last" /> - {!hideReferralRewards && ( - } - label={t('menu.points')} - href="/rewards" - position="last" - /> - )}
{ @@ -670,7 +670,7 @@ export const TransactionDetailsReceipt = ({ {/* Onramp deposit instructions for bridge_onramp transactions */} {rowVisibilityConfig.depositInstructions && } - {rowVisibilityConfig.points && transaction.points && !isReferralRewardsHidden() && ( + {rowVisibilityConfig.points && transaction.points && ( ({ isIOSNative: () => mockIsIOSNative(), })) -import { isReferralRewardsHidden } from '../appStoreCompliance' import underMaintenanceConfig from '../underMaintenance.config' -describe('iOS App Store gating', () => { +describe('iOS cross-chain withdraw gate', () => { beforeEach(() => { mockIsIOSNative.mockReset() }) @@ -33,16 +32,4 @@ describe('iOS App Store gating', () => { expect(underMaintenanceConfig.disableXchainWithdraw).toBe(true) }) }) - - describe('isReferralRewardsHidden (guideline 3.1.5(ii))', () => { - it('hides the referral programme inside the iOS app', () => { - mockIsIOSNative.mockReturnValue(true) - expect(isReferralRewardsHidden()).toBe(true) - }) - - it('leaves web and Android untouched', () => { - mockIsIOSNative.mockReturnValue(false) - expect(isReferralRewardsHidden()).toBe(false) - }) - }) }) diff --git a/src/config/appStoreCompliance.ts b/src/config/appStoreCompliance.ts deleted file mode 100644 index a3335e4f35..0000000000 --- a/src/config/appStoreCompliance.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { isIOSNative } from '@/utils/capacitor' - -/** - * App Store Review Guideline 3.1.5(ii) forbids an app from offering currency — - * crypto included — as compensation for completing tasks. Peanut's referral - * programme ("You earn rewards whenever your friends use Peanut", points, tiers, - * per-invite payouts) is exactly that, so inside the iOS app we hide every - * surface that advertises, links to, or pays out referral rewards: the /rewards - * and /points routes, their entry points, the invite-and-earn carousel CTAs, the - * surprise-moment reward treatment, and the per-transaction points row. - * - * Inviting itself is NOT restricted and stays fully available on iOS — sharing - * an invite link is only a problem when it comes with an offer of payment, so - * the invite modal swaps its copy rather than disappearing. The programme still - * pays out server-side; this gates presentation only. - * - * Deliberately iOS-only. Web and Android keep the full programme — this is an - * App Store constraint, not a product decision. - * - * Call at render time, never at module scope: the platform is only knowable once - * the Capacitor bridge is on `window`, and is `false` during prerender. - */ -export function isReferralRewardsHidden(): boolean { - return isIOSNative() -} diff --git a/src/hooks/useHomeCarouselCTAs.tsx b/src/hooks/useHomeCarouselCTAs.tsx index ff49f92265..d214a4c7cf 100644 --- a/src/hooks/useHomeCarouselCTAs.tsx +++ b/src/hooks/useHomeCarouselCTAs.tsx @@ -3,6 +3,7 @@ import { type IconName } from '@/components/Global/Icons/Icon' import { useAuth } from '@/context/authContext' import { useTranslations } from 'next-intl' +import { useAppTranslations } from '@/i18n/app/useAppTranslations' import { useEffect, useMemo, useState, useCallback, useRef } from 'react' import { getUserPreferences, updateUserPreferences } from '@/utils/general.utils' import { useNotifications } from './useNotifications' @@ -19,7 +20,6 @@ import { useActivationStatus } from './useActivationStatus' import { useTransactionHistory } from './useTransactionHistory' import STAR_STRAIGHT_ICON from '@/assets/icons/starStraight.svg' import underMaintenanceConfig from '@/config/underMaintenance.config' -import { isReferralRewardsHidden } from '@/config/appStoreCompliance' import { useToast } from '@/components/0_Bruddle/Toast' import { PEANUTMAN_MOBILE, PeanutWavingHello } from '@/assets/mascot' import { MIGRATION_SURFACES } from '@/constants/migration.consts' @@ -81,7 +81,7 @@ const getDismissedCTAs = (userId: string | undefined): Map => { } export const useHomeCarouselCTAs = () => { - const t = useTranslations('home.carousel') + const t = useAppTranslations('home.carousel') const tMigration = useTranslations('migration') const migrationOn = useMigrationFlag() const flagEnabled = useFeatureFlags() @@ -222,7 +222,7 @@ export const useHomeCarouselCTAs = () => { } // Generic invite CTA for non-LATAM activated users who haven't invited yet. - if (!isLatamUser && isActivated && !hasSentInvites && !isReferralRewardsHidden()) { + if (!isLatamUser && isActivated && !hasSentInvites) { _carouselCTAs.push({ id: 'invite-friends', title: t('invite.title'), @@ -310,7 +310,7 @@ export const useHomeCarouselCTAs = () => { // ------------------------------------------------------------------------------------------------ // LATAM rewards CTA - show to activated users in Argentina or Brazil who haven't // invited anyone yet. Encourages first-invite; we hide once they've sent at least one. - if (isLatamUser && isActivated && !hasSentInvites && !isReferralRewardsHidden()) { + if (isLatamUser && isActivated && !hasSentInvites) { _carouselCTAs.push({ id: 'latam-cashback-invite', title: {t.rich('latamInvite.title', { b })}, diff --git a/src/i18n/app/__tests__/useAppTranslations.test.tsx b/src/i18n/app/__tests__/useAppTranslations.test.tsx new file mode 100644 index 0000000000..5a9c9ee758 --- /dev/null +++ b/src/i18n/app/__tests__/useAppTranslations.test.tsx @@ -0,0 +1,141 @@ +/** + * The iOS-only cashback copy layer. + * + * App Store Review Guideline 3.1.5 (v) forbids cryptocurrency apps from + * offering currency for "encouraging other users to download". The native iOS + * build presents the referral programme as cashback; web and Android keep the + * rewards vocabulary. The whole contract is: overrides apply on iOS only, and + * every other platform renders byte-for-byte what it rendered before. + */ +import React, { type ReactNode } from 'react' +import { renderHook } from '@testing-library/react' +import { NextIntlClientProvider } from 'next-intl' +import type { AppLocale } from '../config' +import { deepMerge, type DeepPartial, type AppMessages } from '../messages' +import en from '../messages/en.json' +import es419 from '../messages/es-419.json' +import esAR from '../messages/es-AR.json' +import ptBR from '../messages/pt-BR.json' +import { useAppTranslations } from '../useAppTranslations' + +const mockIsIOSNative = jest.fn() +jest.mock('@/utils/capacitor', () => ({ + isIOSNative: () => mockIsIOSNative(), +})) + +const CATALOGS: Record = { + en, + 'es-419': deepMerge(en, es419 as DeepPartial), + 'es-AR': deepMerge(deepMerge(en, es419 as DeepPartial), esAR as DeepPartial), + 'pt-BR': deepMerge(en, ptBR as DeepPartial), +} + +const wrapperFor = (locale: AppLocale) => + function Wrapper({ children }: { children: ReactNode }) { + return ( + + {children} + + ) + } + +const t = [0]>(namespace: N, locale: AppLocale = 'en') => + renderHook(() => useAppTranslations(namespace), { wrapper: wrapperFor(locale) }).result.current + +describe('useAppTranslations', () => { + beforeEach(() => mockIsIOSNative.mockReset()) + + describe('off iOS', () => { + beforeEach(() => mockIsIOSNative.mockReturnValue(false)) + + it('renders the rewards vocabulary untouched', () => { + expect(t('rewards')('title')).toBe('Rewards') + expect(t('rewards')('lifetimeRewards', { amount: '$10.00' })).toBe( + 'Lifetime rewards: $10.00. To earn more, invite friends.' + ) + expect(t('home.perk').raw('usedPeanut' as never)).toContain('used Peanut') + }) + + it('never reaches for an ios override even where one exists', () => { + expect(t('qrPay')('success.earnedRewardTitle')).toBe('You earned a reward!') + expect(t('transaction')('type.reward')).toBe('Reward') + expect(t('profile')('menu.points')).toBe('Points') + }) + }) + + describe('on iOS', () => { + beforeEach(() => mockIsIOSNative.mockReturnValue(true)) + + it('prefers the ios override when the namespace has one', () => { + expect(t('rewards')('title')).toBe('Cashback') + expect(t('rewards')('lifetimeRewards', { amount: '$10.00' })).toBe('Lifetime cashback: $10.00') + expect(t('qrPay')('success.earnedRewardTitle')).toBe('You earned cashback!') + expect(t('transaction')('type.reward')).toBe('Cashback') + expect(t('profile')('menu.points')).toBe('Cashback') + }) + + it('attributes cashback to the payment, not the signup', () => { + expect(t('home.perk').raw('usedPeanut' as never)).toContain('paid with Peanut') + }) + + it('falls through to the base string when there is no override', () => { + // same namespace as an overridden key, deliberately not overridden + expect(t('rewards')('inviteNow')).toBe('Invite Now') + expect(t('rewards')('peopleYouInvited')).toBe('People you invited') + expect(t('qrPay')('success.splitThisBill')).toBe('Split this bill') + }) + + it('leaves namespaces without an ios block alone', () => { + expect(t('navigation')).toBeDefined() + expect(t('common')('done')).toBe(t('common')('done')) + }) + + it('resolves overrides in every locale, es-AR through the es-419 layer', () => { + expect(t('rewards', 'es-419')('title')).toBe('Cashback') + expect(t('rewards', 'pt-BR')('title')).toBe('Cashback') + // es-AR overrides only what voseo changes; `title` comes from es-419 + expect(t('rewards', 'es-AR')('title')).toBe('Cashback') + expect(t('rewards', 'es-AR')('earnWhenFriendsUse')).toBe( + '¡Ganás cashback cada vez que tus amigos pagan con Peanut!' + ) + }) + + it('reports has() against both layers', () => { + expect(t('rewards').has('howItWorks.title' as never)).toBe(true) + expect(t('rewards').has('inviteNow' as never)).toBe(true) + expect(t('rewards').has('nopeNotAKey' as never)).toBe(false) + }) + }) +}) + +describe('iosCopy catalog invariants', () => { + const OVERRIDDEN = ['rewards', 'home', 'home.perk', 'home.carousel', 'qrPay', 'transaction', 'profile', 'global'] + + const at = (root: unknown, path: string): unknown => + path.split('.').reduce((node, key) => (node as Record)?.[key], root) + + const leaves = (node: unknown, prefix = ''): string[] => + node && typeof node === 'object' + ? Object.entries(node as Record).flatMap(([k, v]) => + leaves(v, prefix ? `${prefix}.${k}` : k) + ) + : [prefix] + + // A typo in an override is invisible at runtime — it just never resolves — + // so every override must shadow a key that actually exists in the namespace. + it.each(OVERRIDDEN)('every %s.iosCopy key shadows a real base key', (namespace) => { + const ns = at(en, namespace) as Record + const overrides = ns.iosCopy + expect(overrides).toBeDefined() + const orphans = leaves(overrides).filter((key) => at(ns, key) === undefined) + expect(orphans).toEqual([]) + }) + + // `ios` is already a content key (profile.backup.steps.ios), which is why the + // override block is called iosCopy — a namespace whose own content sits under + // `ios` would otherwise have every key silently redirected on iOS. + it('does not reuse the pre-existing ios content key', () => { + expect(at(en, 'profile.backup.steps.ios')).toBeDefined() + expect(at(en, 'profile.backup.steps.iosCopy')).toBeUndefined() + }) +}) diff --git a/src/i18n/app/messages/en.json b/src/i18n/app/messages/en.json index 152b99d0e1..92492e8aca 100644 --- a/src/i18n/app/messages/en.json +++ b/src/i18n/app/messages/en.json @@ -172,7 +172,14 @@ "rewardClaimed": "Reward claimed!", "shareAndEarn": "Share & earn", "inviteFriendsToEarnMore": "Invite friends to earn more", - "holdToUnwrap": "Hold to unwrap your reward" + "holdToUnwrap": "Hold to unwrap your reward", + "iosCopy": { + "surpriseDescriptionFirst": "Check out your cashback and how to earn more.", + "surpriseDescriptionNext": "Check out your cashback and how to earn more.", + "usedPeanut": "{inviteeName} paid with Peanut", + "rewardClaimed": "Cashback claimed!", + "holdToUnwrap": "Hold to unwrap your cashback" + } }, "carousel": { "rewardReady": "+${amount} reward ready!", @@ -219,11 +226,30 @@ "closeTitled": "Close {title}", "closeVerificationPrompt": "Close verification prompt", "closeNotificationPrompt": "Close notification prompt", - "claimablePerk": "Claimable perk" + "claimablePerk": "Claimable perk", + "iosCopy": { + "rewardReady": "+${amount} cashback ready!", + "usedPeanutTapToClaim": "{inviteeName} paid with Peanut. Tap to claim.", + "tapToClaim": "Tap to claim your cashback.", + "invite": { + "title": "Invite friends. Earn cashback", + "description": "Earn cashback every time your friends pay with Peanut." + }, + "latamInvite": { + "title": "Earn cashback on QR payments", + "description": "Invite friends to earn more cashback. The more they pay, the more you earn!" + }, + "qrPay": { + "description": "Get the best exchange rate, pay like a local and earn cashback." + } + } }, "pendingTasks": { "completeBefore": "Complete before {deadline}", "dismiss": "Dismiss {task}" + }, + "iosCopy": { + "rewards": "Cashback" } }, "profile": { @@ -384,6 +410,11 @@ "begForInvite": "Beg for an invite", "activityPrivateNote": "Activity is only visible for you, it is not public.", "noInviteTitle": "No invite, no Peanut" + }, + "iosCopy": { + "menu": { + "points": "Cashback" + } } }, "settings": { @@ -1059,6 +1090,22 @@ "linkCopied": "Link copied", "shareTitle": "My Peanut QR Code", "shareText": "Scan my QR code to connect with me on Peanut!" + }, + "iosCopy": { + "success": { + "earnedRewardTitle": "You earned cashback!", + "earnedHoldToClaim": "You earned ${amount}! Hold to unwrap your cashback.", + "holdToClaim": "You earned cashback! Hold to unwrap.", + "earnedInviteFriends": "You earned ${amount}! Invite friends to earn even more cashback.", + "inviteFriends": "Invite friends to earn even more cashback.", + "claimReward": "Unwrap Cashback" + }, + "claim": { + "inviteQrDescription": "Share anywhere. Anyone who scans can join you on Peanut." + }, + "claimSuccess": { + "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." + } } }, "card": { @@ -1872,7 +1919,25 @@ }, "enjoyPeanut": "Enjoy Peanut!", "memoTestDeposit": "Your peanut wallet is ready to use!", - "adjustedSuffix": "· Adjusted" + "adjustedSuffix": "· Adjusted", + "iosCopy": { + "type": { + "reward": "Cashback" + }, + "perkBanner": { + "title": "You earned cashback!", + "capped": "${amount} cashback — campaign limit reached!", + "received": "You received ${amount} cashback!", + "generic": "You received Peanut cashback!" + }, + "perk": { + "title": "Peanut Cashback", + "subtitle": "Earn cashback every time your friends pay with Peanut." + }, + "actions": { + "inviteFriends": "Invite friends to earn more cashback" + } + } }, "history": { "title": "Activity", @@ -1905,7 +1970,29 @@ "loadInvitesFailed": "Error loading invites!", "contactSupport": "Please contact Support.", "friendsEarnedYou": "Your friends earned you", - "starAlt": "star" + "starAlt": "star", + "invitesTitle": "Rewards", + "lifetimeCaption": "in rewards so far", + "howItWorks": { + "title": "How rewards work", + "step1": "Invite friends to Peanut.", + "step2": "They pay with Peanut like normal.", + "step3": "Rewards build up in your balance.", + "step4": "Make any payment to claim it." + }, + "iosCopy": { + "title": "Cashback", + "invitesTitle": "Your Network", + "noPendingRewards": "No cashback waiting right now.", + "lifetimeRewards": "Lifetime cashback: {amount}", + "earnWhenFriendsUse": "You earn cashback whenever your friends pay with Peanut!", + "shareInviteLinkPrompt": "Send your invite link to start earning more cashback", + "howItWorks": { + "title": "How cashback works", + "step3": "Cashback builds up in your balance." + }, + "lifetimeCaption": "in cashback so far" + } }, "invites": { "illustrationAlt": "Section illustration", @@ -3013,7 +3100,12 @@ "whatChanged": "We've rewritten the documents below in plain language so they match what Peanut is today, including the Peanut Card and Rewards. There's no rush, read them whenever, and keep using Peanut as usual.", "title": "A small update to our terms" }, - "maintenanceBanner": "Maintenance mode, some functionalities won't be available. Funds safe" + "maintenanceBanner": "Maintenance mode, some functionalities won't be available. Funds safe", + "iosCopy": { + "inviteFriendsModal": { + "description": "Share your link. Every time a friend you brought makes a payment, you earn cashback." + } + } }, "errors": { "balanceSettling": "Your balance isn't fully available yet. Please try again in a few seconds.", diff --git a/src/i18n/app/messages/es-419.json b/src/i18n/app/messages/es-419.json index 4359467324..1966abbf48 100644 --- a/src/i18n/app/messages/es-419.json +++ b/src/i18n/app/messages/es-419.json @@ -172,7 +172,14 @@ "rewardClaimed": "¡Recompensa reclamada!", "shareAndEarn": "Comparte y gana", "inviteFriendsToEarnMore": "Invita amigos para ganar más", - "holdToUnwrap": "Mantén presionado para abrir tu recompensa" + "holdToUnwrap": "Mantén presionado para abrir tu recompensa", + "iosCopy": { + "surpriseDescriptionFirst": "Mira tu cashback y cómo ganar más.", + "surpriseDescriptionNext": "Mira tu cashback y cómo ganar más.", + "usedPeanut": "{inviteeName} pagó con Peanut", + "rewardClaimed": "¡Cashback reclamado!", + "holdToUnwrap": "Mantén presionado para abrir tu cashback" + } }, "carousel": { "rewardReady": "¡Recompensa de +${amount} lista!", @@ -219,11 +226,30 @@ "closeTitled": "Cerrar {title}", "closeVerificationPrompt": "Cerrar aviso de verificación", "closeNotificationPrompt": "Cerrar aviso de notificaciones", - "claimablePerk": "Recompensa disponible" + "claimablePerk": "Recompensa disponible", + "iosCopy": { + "rewardReady": "¡Cashback de +${amount} listo!", + "usedPeanutTapToClaim": "{inviteeName} pagó con Peanut. Toca para reclamar.", + "tapToClaim": "Toca para reclamar tu cashback.", + "invite": { + "title": "Invita amigos. Gana cashback", + "description": "Gana cashback cada vez que tus amigos pagan con Peanut." + }, + "latamInvite": { + "title": "Gana cashback en pagos QR", + "description": "Invita amigos para ganar más cashback. ¡Cuanto más pagan, más ganas!" + }, + "qrPay": { + "description": "Obtén el mejor tipo de cambio, paga como un local y gana cashback." + } + } }, "pendingTasks": { "completeBefore": "Complete before {deadline}", "dismiss": "Descartar {task}" + }, + "iosCopy": { + "rewards": "Cashback" } }, "profile": { @@ -384,6 +410,11 @@ "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" + }, + "iosCopy": { + "menu": { + "points": "Cashback" + } } }, "settings": { @@ -1059,6 +1090,22 @@ "linkCopied": "Enlace copiado", "shareTitle": "Mi código QR de Peanut", "shareText": "¡Escanea mi código QR para conectar conmigo en Peanut!" + }, + "iosCopy": { + "success": { + "earnedRewardTitle": "¡Ganaste cashback!", + "earnedHoldToClaim": "¡Ganaste ${amount}! Mantén presionado para abrir tu cashback.", + "holdToClaim": "¡Ganaste cashback! Mantén presionado para abrirlo.", + "earnedInviteFriends": "¡Ganaste ${amount}! Invita amigos para ganar aún más cashback.", + "inviteFriends": "Invita amigos para ganar aún más cashback.", + "claimReward": "Abrir cashback" + }, + "claim": { + "inviteQrDescription": "Compártelo donde sea. Cualquiera que lo escanee puede unirse a Peanut contigo." + }, + "claimSuccess": { + "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." + } } }, "card": { @@ -1872,7 +1919,25 @@ }, "enjoyPeanut": "¡Disfruta Peanut!", "memoTestDeposit": "¡Tu billetera peanut está lista para usar!", - "adjustedSuffix": "· Ajustado" + "adjustedSuffix": "· Ajustado", + "iosCopy": { + "type": { + "reward": "Cashback" + }, + "perkBanner": { + "title": "¡Ganaste cashback!", + "capped": "Cashback de ${amount} — ¡se alcanzó el límite de la campaña!", + "received": "¡Recibiste ${amount} de cashback!", + "generic": "¡Recibiste cashback de Peanut!" + }, + "perk": { + "title": "Cashback Peanut", + "subtitle": "Gana cashback cada vez que tus amigos pagan con Peanut." + }, + "actions": { + "inviteFriends": "Invita amigos y gana más cashback" + } + } }, "history": { "title": "Actividad", @@ -1905,7 +1970,29 @@ "loadInvitesFailed": "¡Error al cargar las invitaciones!", "contactSupport": "Por favor contacta a Soporte.", "friendsEarnedYou": "Tus amigos te hicieron ganar", - "starAlt": "estrella" + "starAlt": "estrella", + "invitesTitle": "Recompensas", + "lifetimeCaption": "en recompensas hasta ahora", + "howItWorks": { + "title": "Cómo funcionan las recompensas", + "step1": "Invita a tus amigos a Peanut.", + "step2": "Ellos pagan con Peanut como siempre.", + "step3": "Tus recompensas se acumulan en tu saldo.", + "step4": "Haz cualquier pago para reclamarlo." + }, + "iosCopy": { + "title": "Cashback", + "invitesTitle": "Tu red", + "noPendingRewards": "No tienes cashback pendiente por ahora.", + "lifetimeRewards": "Cashback total: {amount}", + "earnWhenFriendsUse": "¡Ganas cashback cada vez que tus amigos pagan con Peanut!", + "shareInviteLinkPrompt": "Envía tu link de invitación para empezar a ganar más cashback", + "howItWorks": { + "title": "Cómo funciona el cashback", + "step3": "Tu cashback se acumula en tu saldo." + }, + "lifetimeCaption": "en cashback hasta ahora" + } }, "invites": { "illustrationAlt": "Ilustración de la sección", @@ -3013,7 +3100,12 @@ "whatChanged": "We've rewritten the documents below in plain language so they match what Peanut is today, including the Peanut Card and Rewards. There's no rush, read them whenever, and keep using Peanut as usual.", "title": "Una pequeña actualización de nuestros términos" }, - "maintenanceBanner": "Modo mantenimiento: algunas funciones no estarán disponibles. Tus fondos están seguros." + "maintenanceBanner": "Modo mantenimiento: algunas funciones no estarán disponibles. Tus fondos están seguros.", + "iosCopy": { + "inviteFriendsModal": { + "description": "Comparte tu enlace. Cada vez que un amigo que trajiste haga un pago, ganas cashback." + } + } }, "errors": { "balanceSettling": "Tu saldo aún no está totalmente disponible. Inténtalo de nuevo en unos segundos.", diff --git a/src/i18n/app/messages/es-AR.json b/src/i18n/app/messages/es-AR.json index d153f9729d..50197a88b1 100644 --- a/src/i18n/app/messages/es-AR.json +++ b/src/i18n/app/messages/es-AR.json @@ -65,7 +65,12 @@ "surpriseDescriptionNext": "Mirá tus recompensas y cómo ganar más.", "shareAndEarn": "Compartí y ganá", "inviteFriendsToEarnMore": "Invitá amigos para ganar más", - "holdToUnwrap": "Mantené presionado para abrir tu recompensa" + "holdToUnwrap": "Mantené presionado para abrir tu recompensa", + "iosCopy": { + "surpriseDescriptionFirst": "Mirá tu cashback y cómo ganar más.", + "surpriseDescriptionNext": "Mirá tu cashback y cómo ganar más.", + "holdToUnwrap": "Mantené presionado para abrir tu cashback" + } }, "carousel": { "usedPeanutTapToClaim": "{inviteeName} usó Peanut. Tocá para reclamar.", @@ -106,6 +111,21 @@ "kyc": { "title": "Desbloqueá pagos con QR", "description": "Confirmá tu identidad para pagar códigos QR de Mercado Pago y PIX" + }, + "iosCopy": { + "usedPeanutTapToClaim": "{inviteeName} pagó con Peanut. Tocá para reclamar.", + "tapToClaim": "Tocá para reclamar tu cashback.", + "invite": { + "title": "Invitá amigos. Ganá cashback", + "description": "Ganá cashback cada vez que tus amigos pagan con Peanut." + }, + "latamInvite": { + "title": "Ganá cashback en pagos QR", + "description": "Invitá amigos para ganar más cashback. ¡Cuanto más pagan, más ganás!" + }, + "qrPay": { + "description": "Obtené el mejor tipo de cambio, pagá como un local y ganá cashback." + } } } }, @@ -475,6 +495,20 @@ "putItAnywhere": "¡Pegalo donde quieras!", "stickerDescription": "Pegalo 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.", "shareText": "¡Escaneá mi código QR para conectar conmigo en Peanut!" + }, + "iosCopy": { + "success": { + "earnedHoldToClaim": "¡Ganaste ${amount}! Mantené presionado para abrir tu cashback.", + "holdToClaim": "¡Ganaste cashback! Mantené presionado para abrirlo.", + "earnedInviteFriends": "¡Ganaste ${amount}! Invitá amigos para ganar aún más cashback.", + "inviteFriends": "Invitá amigos para ganar aún más cashback." + }, + "claim": { + "inviteQrDescription": "Compartilo donde sea. Cualquiera que lo escanee puede unirse a Peanut con vos." + }, + "claimSuccess": { + "stickerDescription": "Pegalo 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." + } } }, "card": { @@ -728,6 +762,14 @@ "enjoyPeanut": "¡Disfrutá Peanut!", "cardRows": { "settlementAdjustedNotice": "El monto final fue {amount} mayor que la retención inicial. Es común con propinas y totales actualizados. ¿No lo reconocés? Contactá al comercio." + }, + "iosCopy": { + "perk": { + "subtitle": "Ganá cashback cada vez que tus amigos pagan con Peanut." + }, + "actions": { + "inviteFriends": "Invitá amigos y ganá más cashback" + } } }, "history": { @@ -741,7 +783,17 @@ "lifetimeRewards": "Recompensas totales: {amount}. Para ganar más, invitá a tus amigos.", "earnWhenFriendsUse": "¡Ganás recompensas cada vez que tus amigos usan Peanut!", "shareInviteLinkPrompt": "Enviá tu link de invitación para empezar a ganar más recompensas", - "contactSupport": "Por favor contactá a Soporte." + "contactSupport": "Por favor contactá a Soporte.", + "howItWorks": { + "step1": "Invitá a tus amigos a Peanut.", + "step4": "Hacé cualquier pago para reclamarlo." + }, + "iosCopy": { + "noPendingRewards": "No tenés cashback pendiente por ahora.", + "earnWhenFriendsUse": "¡Ganás cashback cada vez que tus amigos pagan con Peanut!", + "shareInviteLinkPrompt": "Enviá tu link de invitación para empezar a ganar más cashback", + "howItWorks": {} + } }, "invites": { "invalidCodeMessage": "El código de invitación que intentás usar no es válido. Revisá la URL e intentalo de nuevo.", @@ -1155,7 +1207,12 @@ "payWhatYouWant": "Pagá lo que quieras", "sendExactAmount": "¡Enviá el monto exacto!" }, - "betaBanner": "¡Peanut está en beta! Gracias por ser un usuario temprano, compartí tu opinión aquí" + "betaBanner": "¡Peanut está en beta! Gracias por ser un usuario temprano, compartí tu opinión aquí", + "iosCopy": { + "inviteFriendsModal": { + "description": "Compartí tu enlace. Cada vez que un amigo que trajiste haga un pago, ganás cashback." + } + } }, "errors": { "balanceSettling": "Tu saldo aún no está totalmente disponible. Intentalo de nuevo en unos segundos.", diff --git a/src/i18n/app/messages/pt-BR.json b/src/i18n/app/messages/pt-BR.json index 2df53d1981..53d0c5bd88 100644 --- a/src/i18n/app/messages/pt-BR.json +++ b/src/i18n/app/messages/pt-BR.json @@ -172,7 +172,14 @@ "rewardClaimed": "Recompensa resgatada!", "shareAndEarn": "Compartilhe e ganhe", "inviteFriendsToEarnMore": "Convide amigos para ganhar mais", - "holdToUnwrap": "Segure para abrir sua recompensa" + "holdToUnwrap": "Segure para abrir sua recompensa", + "iosCopy": { + "surpriseDescriptionFirst": "Confira seu cashback e como ganhar mais.", + "surpriseDescriptionNext": "Confira seu cashback e como ganhar mais.", + "usedPeanut": "{inviteeName} pagou com o Peanut", + "rewardClaimed": "Cashback resgatado!", + "holdToUnwrap": "Segure para abrir seu cashback" + } }, "carousel": { "rewardReady": "Recompensa de +${amount} pronta!", @@ -219,11 +226,30 @@ "closeTitled": "Fechar {title}", "closeVerificationPrompt": "Fechar aviso de verificação", "closeNotificationPrompt": "Fechar aviso de notificações", - "claimablePerk": "Recompensa disponível" + "claimablePerk": "Recompensa disponível", + "iosCopy": { + "rewardReady": "Cashback de +${amount} pronto!", + "usedPeanutTapToClaim": "{inviteeName} pagou com o Peanut. Toque para resgatar.", + "tapToClaim": "Toque para resgatar seu cashback.", + "invite": { + "title": "Convide amigos. Ganhe cashback", + "description": "Ganhe cashback sempre que seus amigos pagarem com o Peanut." + }, + "latamInvite": { + "title": "Ganhe cashback em pagamentos QR", + "description": "Convide amigos para ganhar mais cashback. Quanto mais eles pagam, mais você ganha!" + }, + "qrPay": { + "description": "Tenha a melhor taxa de câmbio, pague como um local e ganhe cashback." + } + } }, "pendingTasks": { "completeBefore": "Complete before {deadline}", "dismiss": "Dispensar {task}" + }, + "iosCopy": { + "rewards": "Cashback" } }, "profile": { @@ -384,6 +410,11 @@ "begForInvite": "Implorar por um convite", "activityPrivateNote": "A atividade é visível só para você, não é pública.", "noInviteTitle": "Sem convite, sem Peanut" + }, + "iosCopy": { + "menu": { + "points": "Cashback" + } } }, "settings": { @@ -1059,6 +1090,22 @@ "linkCopied": "Link copiado", "shareTitle": "Meu código QR do Peanut", "shareText": "Escaneie meu código QR para se conectar comigo no Peanut!" + }, + "iosCopy": { + "success": { + "earnedRewardTitle": "Você ganhou cashback!", + "earnedHoldToClaim": "Você ganhou ${amount}! Segure para abrir seu cashback.", + "holdToClaim": "Você ganhou cashback! Segure para abrir.", + "earnedInviteFriends": "Você ganhou ${amount}! Convide amigos para ganhar ainda mais cashback.", + "inviteFriends": "Convide amigos para ganhar ainda mais cashback.", + "claimReward": "Abrir cashback" + }, + "claim": { + "inviteQrDescription": "Compartilhe onde quiser. Quem escanear pode entrar no Peanut com você." + }, + "claimSuccess": { + "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." + } } }, "card": { @@ -1872,7 +1919,25 @@ }, "enjoyPeanut": "Aproveite o Peanut!", "memoTestDeposit": "Sua carteira peanut está pronta para usar!", - "adjustedSuffix": "· Ajustado" + "adjustedSuffix": "· Ajustado", + "iosCopy": { + "type": { + "reward": "Cashback" + }, + "perkBanner": { + "title": "Você ganhou cashback!", + "capped": "Cashback de ${amount} — limite da campanha atingido!", + "received": "Você recebeu ${amount} de cashback!", + "generic": "Você recebeu cashback da Peanut!" + }, + "perk": { + "title": "Cashback Peanut", + "subtitle": "Ganhe cashback sempre que seus amigos pagarem com o Peanut." + }, + "actions": { + "inviteFriends": "Convide amigos e ganhe mais cashback" + } + } }, "history": { "title": "Atividade", @@ -1905,7 +1970,29 @@ "loadInvitesFailed": "Erro ao carregar os convites!", "contactSupport": "Por favor, fale com o Suporte.", "friendsEarnedYou": "Seus amigos fizeram você ganhar", - "starAlt": "estrela" + "starAlt": "estrela", + "invitesTitle": "Recompensas", + "lifetimeCaption": "em recompensas até agora", + "howItWorks": { + "title": "Como funcionam as recompensas", + "step1": "Convide amigos para o Peanut.", + "step2": "Eles pagam com o Peanut normalmente.", + "step3": "Suas recompensas vão se acumulando no saldo.", + "step4": "Faça qualquer pagamento para resgatar." + }, + "iosCopy": { + "title": "Cashback", + "invitesTitle": "Sua rede", + "noPendingRewards": "Nenhum cashback pendente por enquanto.", + "lifetimeRewards": "Cashback total: {amount}", + "earnWhenFriendsUse": "Você ganha cashback sempre que seus amigos pagam com o Peanut!", + "shareInviteLinkPrompt": "Envie seu link de convite para começar a ganhar mais cashback", + "howItWorks": { + "title": "Como funciona o cashback", + "step3": "Seu cashback vai se acumulando no saldo." + }, + "lifetimeCaption": "em cashback até agora" + } }, "invites": { "illustrationAlt": "Ilustração da seção", @@ -3013,7 +3100,12 @@ "whatChanged": "We've rewritten the documents below in plain language so they match what Peanut is today, including the Peanut Card and Rewards. There's no rush, read them whenever, and keep using Peanut as usual.", "title": "Uma pequena atualização dos nossos termos" }, - "maintenanceBanner": "Modo manutenção: algumas funções ficarão indisponíveis. Seu dinheiro está seguro." + "maintenanceBanner": "Modo manutenção: algumas funções ficarão indisponíveis. Seu dinheiro está seguro.", + "iosCopy": { + "inviteFriendsModal": { + "description": "Compartilhe seu link. Toda vez que um amigo que você trouxe fizer um pagamento, você ganha cashback." + } + } }, "errors": { "balanceSettling": "Seu saldo ainda não está totalmente disponível. Tente novamente em alguns segundos.", diff --git a/src/i18n/app/useAppTranslations.ts b/src/i18n/app/useAppTranslations.ts new file mode 100644 index 0000000000..57d63758c5 --- /dev/null +++ b/src/i18n/app/useAppTranslations.ts @@ -0,0 +1,51 @@ +'use client' + +import { useTranslations } from 'next-intl' +import { useMemo } from 'react' +import { isIOSNative } from '@/utils/capacitor' + +type Namespace = NonNullable[0]> + +/** + * `useTranslations` with an iOS-only copy layer. + * + * App Store Review Guideline 3.1.5 (v) forbids cryptocurrency apps from + * offering currency for "encouraging other users to download". The native iOS + * build therefore presents the referral programme as cashback, attributed to + * the invitee's payment rather than to their signup. Web and Android keep the + * rewards vocabulary and render byte-for-byte what they rendered before. + * + * Overrides live in an `iosCopy` block inside the namespace they belong to, so + * `t('title')` resolves `rewards.iosCopy.title` on iOS and `rewards.title` + * everywhere else. Keys with no override fall through untouched, which is why + * the blocks carry only the strings that actually differ. + * + * Platform is read at render time, never at module scope: the Capacitor bridge + * only lands on `window` after module eval, and is absent during prerender. + */ +export function useAppTranslations(namespace: N): ReturnType> { + const t = useTranslations(namespace) + const ios = isIOSNative() + + return useMemo(() => { + if (!ios) return t + + type Loose = { + (key: string, ...rest: unknown[]): string + rich: (key: string, ...rest: unknown[]) => unknown + markup: (key: string, ...rest: unknown[]) => string + raw: (key: string) => unknown + has: (key: string) => boolean + } + const base = t as unknown as Loose + const resolve = (key: string): string => (base.has(`iosCopy.${key}`) ? `iosCopy.${key}` : key) + + const wrapped = ((key: string, ...rest: unknown[]) => base(resolve(key), ...rest)) as Loose + wrapped.rich = (key, ...rest) => base.rich(resolve(key), ...rest) + wrapped.markup = (key, ...rest) => base.markup(resolve(key), ...rest) + wrapped.raw = (key) => base.raw(resolve(key)) + wrapped.has = (key) => base.has(key) || base.has(`iosCopy.${key}`) + + return wrapped as unknown as typeof t + }, [t, ios]) +} From e9064bd2b2554fba6020f32a3233e49be5253ecd Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Thu, 20 Aug 2026 18:29:05 +0100 Subject: [PATCH 13/93] fix(rewards): drop useEffect import the invites page no longer uses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/app/(mobile-ui)/rewards/invites/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/(mobile-ui)/rewards/invites/page.tsx b/src/app/(mobile-ui)/rewards/invites/page.tsx index e742af12ed..6f92b2d57a 100644 --- a/src/app/(mobile-ui)/rewards/invites/page.tsx +++ b/src/app/(mobile-ui)/rewards/invites/page.tsx @@ -20,7 +20,7 @@ import { type PointsInvite } from '@/services/services.types' import { formatPoints } from '@/utils/format.utils' import { useCountUp } from '@/hooks/useCountUp' import { useInView } from 'framer-motion' -import { useEffect, useRef } from 'react' +import { useRef } from 'react' import { useAppTranslations } from '@/i18n/app/useAppTranslations' import { isIOSNative } from '@/utils/capacitor' import InviteePointsBadge from '@/components/Points/InviteePointsBadge' From f20e0539308be3ea678fd5999994488e4ad49ff3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 17:51:35 +0000 Subject: [PATCH 14/93] fix(mobile): nav active state, withdraw re-suspend, receipt rule, cancel drawer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01SbALh4dJvcBUAinjnwQ53q --- .../__tests__/withdraw-states.test.tsx | 53 ++++++++++++ src/app/(mobile-ui)/withdraw/page.tsx | 14 +++- .../__tests__/CancelSendLinkDrawer.test.tsx | 84 +++++++++++++++++++ .../Global/CancelSendLinkDrawer/index.tsx | 77 +++++++++++++++++ .../Global/CancelSendLinkModal/index.tsx | 72 ---------------- src/components/Global/Drawer/index.tsx | 14 +++- .../Global/WalletNavigation/index.tsx | 9 +- .../link/views/Success.link.send.view.tsx | 18 ++-- .../TransactionDetailsDrawer.tsx | 5 +- .../TransactionDetailsReceipt.tsx | 34 +++++--- .../receipt-trailing-divider.test.ts | 68 +++++++++++++++ src/constants/__tests__/routes.test.ts | 30 ++++++- src/constants/routes.ts | 13 +++ 13 files changed, 388 insertions(+), 103 deletions(-) create mode 100644 src/components/Global/CancelSendLinkDrawer/__tests__/CancelSendLinkDrawer.test.tsx create mode 100644 src/components/Global/CancelSendLinkDrawer/index.tsx delete mode 100644 src/components/Global/CancelSendLinkModal/index.tsx create mode 100644 src/components/TransactionDetails/__tests__/receipt-trailing-divider.test.ts 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 1cc0b8e0ca..b581a5d02d 100644 --- a/src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx +++ b/src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx @@ -195,6 +195,22 @@ jest.mock('@/components/0_Bruddle/Button', () => ({ ), })) +// Native (?country=…) views are React.lazy'd. These stubs count mounts so the +// remount regression below can see a torn-down + rebuilt subtree. +const mockBankViewMounts = jest.fn() +jest.mock('../_withdraw-bank', () => { + const NativeBankView = () => { + React.useEffect(() => mockBankViewMounts(), []) + return
+ } + return { __esModule: true, default: NativeBankView } +}) + +jest.mock('@/components/AddWithdraw/AddWithdrawCountriesList', () => ({ + __esModule: true, + default: () =>
, +})) + jest.mock('@/components/AddWithdraw/AddWithdrawRouterView', () => ({ AddWithdrawRouterView: (props: any) => (
@@ -619,3 +635,40 @@ describe('GROUP 6: Continue never dead-buttons', () => { expect(mockRouterPush).toHaveBeenCalledWith(expect.stringContaining('country=argentina')) }) }) + +// ============================================================ +// GROUP 7: Native sub-views (?country=…) must stay on screen +// ============================================================ +describe('GROUP 7: Native sub-view mounting', () => { + // React.lazy() called inside the render body hands back a fresh, unresolved + // lazy every time, so each re-render re-suspended: React hid the rendered + // view and showed the Suspense fallback (null) until the import re-resolved. + // The user saw the withdraw screen blank and load a second time. + const isHidden = (el: HTMLElement) => { + let node: HTMLElement | null = el + while (node) { + if (node.style?.display === 'none') return true + node = node.parentElement + } + return false + } + + test('the lazy bank view survives a re-render without blanking', async () => { + const { rerender } = renderWithdraw({ country: 'us', view: 'bank' }) + expect(await screen.findByTestId('native-bank-view')).toBeInTheDocument() + expect(mockBankViewMounts).toHaveBeenCalledTimes(1) + + const queryClient = createQueryClient() + rerender( + + + + + + ) + + // synchronously after the re-render — no awaiting a second import + expect(isHidden(screen.getByTestId('native-bank-view'))).toBe(false) + expect(mockBankViewMounts).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/app/(mobile-ui)/withdraw/page.tsx b/src/app/(mobile-ui)/withdraw/page.tsx index 8ed07d5f41..21cbf77a0d 100644 --- a/src/app/(mobile-ui)/withdraw/page.tsx +++ b/src/app/(mobile-ui)/withdraw/page.tsx @@ -25,6 +25,18 @@ import { withdrawBankUrl, withdrawCountryUrl } from '@/utils/native-routes' import { readReturnTo } from '@/utils/return-to.utils' import { useTranslations } from 'next-intl' +// Module scope on purpose. React.lazy() mints a fresh, unresolved lazy on every +// call, so creating these inside the render body made the subtree suspend again +// on EVERY re-render: React hid the rendered view and swapped in the Suspense +// fallback (null) until the import re-resolved a microtask later. On the native +// ?country=…&view=bank route that showed up as the withdraw screen blanking and +// loading a second time — once on arrival, then again on the next re-render, +// which the success view triggers itself when it invalidates the transactions +// query. Hoisted, the lazy resolves once and later renders pass straight +// through. +const WithdrawBankPage = React.lazy(() => import('./_withdraw-bank')) +const AddWithdrawCountriesList = React.lazy(() => import('@/components/AddWithdraw/AddWithdrawCountriesList')) + type WithdrawStep = 'inputAmount' | 'selectMethod' export default function WithdrawPage() { @@ -375,14 +387,12 @@ export default function WithdrawPage() { // native app: render country-specific views. // stub exists for web build; real component is injected by native build script. if (viewFromQuery === 'bank') { - const WithdrawBankPage = React.lazy(() => import('./_withdraw-bank')) return ( ) } - const AddWithdrawCountriesList = React.lazy(() => import('@/components/AddWithdraw/AddWithdrawCountriesList')) return ( diff --git a/src/components/Global/CancelSendLinkDrawer/__tests__/CancelSendLinkDrawer.test.tsx b/src/components/Global/CancelSendLinkDrawer/__tests__/CancelSendLinkDrawer.test.tsx new file mode 100644 index 0000000000..c177366e66 --- /dev/null +++ b/src/components/Global/CancelSendLinkDrawer/__tests__/CancelSendLinkDrawer.test.tsx @@ -0,0 +1,84 @@ +import React from 'react' +import { fireEvent, render, screen } from '@testing-library/react' +import { IntlWrapper } from '@/test-utils/intl' +import { Drawer, DrawerContent, DrawerTitle } from '@/components/Global/Drawer' +import CancelSendLinkDrawer from '../index' + +const setup = (props: Partial> = {}) => { + const onClick = jest.fn() + const setShowCancelLinkDrawer = jest.fn() + render( + + + + ) + return { onClick, setShowCancelLinkDrawer } +} + +describe('CancelSendLinkDrawer', () => { + it('renders as a bottom drawer, not a centered modal', () => { + setup() + + const dialog = screen.getByRole('dialog') + expect(dialog).toHaveAttribute('data-vaul-drawer') + expect(dialog).toHaveAttribute('data-vaul-drawer-direction', 'bottom') + }) + + it('keeps the confirmation copy and the amount at risk', () => { + setup() + + expect(screen.getByText('Cancel this link?')).toBeInTheDocument() + expect(screen.getByText('$ 25.00')).toBeInTheDocument() + expect(screen.getByText(/nobody will be able to claim it/i)).toBeInTheDocument() + }) + + it('runs the cancel when the CTA is pressed', () => { + const { onClick } = setup() + + fireEvent.click(screen.getByRole('button', { name: /cancel & return funds/i })) + expect(onClick).toHaveBeenCalledTimes(1) + }) + + // The cancel is an on-chain claim-back — once it is in flight there is nothing + // to back out to, so the drawer must not be dismissible mid-flight. + it('locks the CTA and blocks dismissal while cancelling', () => { + setup({ isLoading: true }) + + expect(screen.getByRole('button')).toBeDisabled() + expect(screen.getByRole('dialog')).toHaveAttribute('data-vaul-drawer') + }) + + // Opened from the transaction details drawer. A plain Root nested in a Root + // double-applies vaul's background scale and fights over the scroll lock, so + // this path has to go through NestedRoot. + it('opens on top of the transaction drawer when nested', () => { + render( + + + + Transaction + + + + + ) + + // `hidden: true` because vaul aria-hides the parent while the child has focus + expect(screen.getAllByRole('dialog', { hidden: true })).toHaveLength(2) + expect(screen.getByText('Cancel this link?')).toBeInTheDocument() + // the parent drawer is still mounted underneath, not replaced + expect(screen.getByText('Transaction')).toBeInTheDocument() + }) +}) diff --git a/src/components/Global/CancelSendLinkDrawer/index.tsx b/src/components/Global/CancelSendLinkDrawer/index.tsx new file mode 100644 index 0000000000..4063668559 --- /dev/null +++ b/src/components/Global/CancelSendLinkDrawer/index.tsx @@ -0,0 +1,77 @@ +'use client' + +import { Button } from '@/components/0_Bruddle/Button' +import { Drawer, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle } from '@/components/Global/Drawer' +import { Icon } from '@/components/Global/Icons/Icon' +import { useTranslations } from 'next-intl' + +interface CancelSendLinkDrawerProps { + showCancelLinkDrawer: boolean + setShowCancelLinkDrawer: (showCancelLinkDrawer: boolean) => void + amount: string + onClick: () => void | Promise + isLoading?: boolean + /** True when opened from inside another drawer (the transaction details drawer). */ + nested?: boolean +} + +const CancelSendLinkDrawer = ({ + showCancelLinkDrawer, + setShowCancelLinkDrawer, + amount, + onClick, + isLoading = false, + nested = false, +}: CancelSendLinkDrawerProps) => { + // Catalog path keeps its historical `cancelSendLinkModal` name — the copy is + // unchanged and es-AR has no override, so renaming the key only risks the fallback. + const t = useTranslations('global') + + return ( + { + if (!isOpen && !isLoading) setShowCancelLinkDrawer(false) + }} + > + +
+
+ +
+ + + + {t('cancelSendLinkModal.title')} + + + {t.rich('cancelSendLinkModal.amountReturned', { + amount, + strong: (chunks) => {chunks}, + })} +
+
+ {t('cancelSendLinkModal.noLongerClaimable')} +
+
+ + +
+
+
+ ) +} + +export default CancelSendLinkDrawer diff --git a/src/components/Global/CancelSendLinkModal/index.tsx b/src/components/Global/CancelSendLinkModal/index.tsx deleted file mode 100644 index c07d8e9b77..0000000000 --- a/src/components/Global/CancelSendLinkModal/index.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { useTranslations } from 'next-intl' -import ActionModal from '../ActionModal' - -interface CancelSendLinkModalProps { - showCancelLinkModal: boolean - setshowCancelLinkModal: (showCancelLinkModal: boolean) => void - amount: string - onClick: () => void | Promise - isLoading?: boolean -} - -const CancelSendLinkModal = ({ - showCancelLinkModal, - setshowCancelLinkModal, - amount, - onClick, - isLoading = false, -}: CancelSendLinkModalProps) => { - const t = useTranslations('global') - - const handleClick = (e?: React.MouseEvent) => { - // Stop event propagation to prevent Dialog from closing - e?.preventDefault() - e?.stopPropagation() - - // Call the actual onClick handler - onClick() - } - - return ( - { - if (!isLoading) { - setshowCancelLinkModal(false) - } - }} - icon="link-slash" - iconContainerClassName="bg-purple-1" - iconProps={{ className: 'text-black' }} - title={t('cancelSendLinkModal.title')} - modalClassName="!z-[9999] pointer-events-auto" - description={ - <> - {t.rich('cancelSendLinkModal.amountReturned', { - amount, - strong: (chunks) => {chunks}, - })} -
-
- {t('cancelSendLinkModal.noLongerClaimable')} - - } - preventClose={isLoading} - modalPanelClassName="max-w-sm mx-8 !z-[9999] pointer-events-auto" - contentContainerClassName="relative pointer-events-auto" - classOverlay="!bg-black/40 !z-[9998]" - ctas={[ - { - text: t('cancelSendLinkModal.cancelCta'), - shadowSize: '4', - className: 'md:py-2', - onClick: handleClick, - loading: isLoading, - disabled: isLoading, - }, - ]} - /> - ) -} - -export default CancelSendLinkModal diff --git a/src/components/Global/Drawer/index.tsx b/src/components/Global/Drawer/index.tsx index 082308d936..82e514b625 100644 --- a/src/components/Global/Drawer/index.tsx +++ b/src/components/Global/Drawer/index.tsx @@ -4,8 +4,18 @@ import * as React from 'react' import { twMerge } from 'tailwind-merge' import { Drawer as DrawerPrimitive } from 'vaul' -const Drawer = ({ shouldScaleBackground = true, ...props }: React.ComponentProps) => { - return +type DrawerProps = React.ComponentProps & { + /** + * Set on a drawer opened from inside another drawer. Vaul's NestedRoot stacks + * the two and scales the parent instead of the page; a plain Root nested in a + * Root double-applies the background scale and fights over the scroll lock. + */ + nested?: boolean +} + +const Drawer = ({ shouldScaleBackground = true, nested = false, ...props }: DrawerProps) => { + const Root = nested ? DrawerPrimitive.NestedRoot : DrawerPrimitive.Root + return } Drawer.displayName = 'Drawer' diff --git a/src/components/Global/WalletNavigation/index.tsx b/src/components/Global/WalletNavigation/index.tsx index 07e56fba64..df8d0dcca0 100644 --- a/src/components/Global/WalletNavigation/index.tsx +++ b/src/components/Global/WalletNavigation/index.tsx @@ -5,6 +5,7 @@ import { Icon, type IconName, Icon as NavIcon } from '@/components/Global/Icons/ import IndicatorDot from '@/components/Global/IndicatorDot' import underMaintenanceConfig from '@/config/underMaintenance.config' import { useModalsContext } from '@/context/ModalsContext' +import { isSameRoute } from '@/constants/routes' import { useSupportUnread } from '@/hooks/useSupportUnread' import { useUserStore } from '@/redux/hooks' import classNames from 'classnames' @@ -51,11 +52,11 @@ const NavSection: React.FC = ({ paths, pathName }) => { className={classNames( 'flex items-center gap-3 text-white hover:cursor-pointer hover:text-white/80', { - 'text-primary-1': pathName === href, + 'text-primary-1': isSameRoute(pathName, href), } )} onClick={() => { - if (pathName === href) { + if (isSameRoute(pathName, href)) { router.refresh() } }} @@ -89,7 +90,7 @@ const MobileNav: React.FC = ({ pathName }) => { translate="no" className={classNames( 'notranslate flex flex-col items-center justify-center object-contain hover:cursor-pointer', - { 'text-primary-1': pathName === '/home' } + { 'text-primary-1': isSameRoute(pathName, '/home') } )} > @@ -111,7 +112,7 @@ const MobileNav: React.FC = ({ pathName }) => { translate="no" className={classNames( 'notranslate flex flex-col items-center justify-center object-contain hover:cursor-pointer', - { 'text-primary-1': pathName === '/support' } + { 'text-primary-1': isSameRoute(pathName, '/support') } )} > 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 b83c03c5d5..9ab529edc4 100644 --- a/src/components/Send/link/views/Success.link.send.view.tsx +++ b/src/components/Send/link/views/Success.link.send.view.tsx @@ -1,7 +1,7 @@ 'use client' import { Button } from '@/components/0_Bruddle/Button' -import CancelSendLinkModal from '@/components/Global/CancelSendLinkModal' +import CancelSendLinkDrawer from '@/components/Global/CancelSendLinkDrawer' import { Icon } from '@/components/Global/Icons/Icon' import NavHeader from '@/components/Global/NavHeader' import QRCodeWrapper from '@/components/Global/QRCodeWrapper' @@ -32,7 +32,7 @@ const LinkSendSuccessView = () => { const { cancelLinkAndClaim, pollForClaimConfirmation } = useClaimLink() const toast = useToast() const [isLoading, setIsLoading] = useState(false) - const [showCancelLinkModal, setshowCancelLinkModal] = useState(false) + const [showCancelLinkDrawer, setShowCancelLinkDrawer] = useState(false) const [cancelStatus, setCancelStatus] = useState<'idle' | 'cancelling' | 'cancelled'>('idle') const cancelLinkText = @@ -85,7 +85,7 @@ const LinkSendSuccessView = () => { {t('link.shareLink')}
- {/* Cancel Link Modal */} + {/* Cancel Link Drawer */} {link && ( - { @@ -143,7 +143,7 @@ const LinkSendSuccessView = () => { await queryClient.invalidateQueries({ queryKey: [TRANSACTIONS] }) setIsLoading(false) - setshowCancelLinkModal(false) + setShowCancelLinkDrawer(false) setCancelStatus('cancelled') toast.success(t('link.cancelSuccess')) @@ -159,7 +159,7 @@ const LinkSendSuccessView = () => { // Still navigate even if invalidation fails setIsLoading(false) - setshowCancelLinkModal(false) + setShowCancelLinkDrawer(false) setCancelStatus('cancelled') toast.success(t('link.cancelSuccessRefresh')) await new Promise((resolve) => setTimeout(resolve, 1500)) diff --git a/src/components/TransactionDetails/TransactionDetailsDrawer.tsx b/src/components/TransactionDetails/TransactionDetailsDrawer.tsx index ffe483311e..33ef07b80e 100644 --- a/src/components/TransactionDetails/TransactionDetailsDrawer.tsx +++ b/src/components/TransactionDetails/TransactionDetailsDrawer.tsx @@ -47,7 +47,10 @@ export const TransactionDetailsDrawer: React.FC = } }} > - + {/* No z-index shuffle for the cancel confirmation any more: it is a vaul + NestedRoot now, so vaul stacks it above this drawer and scales this one + back on its own. */} + {t('drawerTitle')} (null) const [isTokenDataLoading, setIsTokenDataLoading] = useState(true) const { setIsSupportModalOpen } = useModalsContext() @@ -145,8 +145,8 @@ export const TransactionDetailsReceipt = ({ // Sync modal state to parent if callback is provided useEffect(() => { - setIsModalOpen?.(showCancelLinkModal) - }, [showCancelLinkModal, setIsModalOpen]) + setIsModalOpen?.(showCancelLinkDrawer) + }, [showCancelLinkDrawer, setIsModalOpen]) // All derived row-visibility / status / share-receipt state lives in the // hook so this component stays focused on JSX + callbacks. @@ -438,7 +438,14 @@ export const TransactionDetailsReceipt = ({ {/* details card (date, fee, memo) and more */} -
+ {/* `[&>*:last-child]:border-b-0` — the last row sits directly on the + card's own black border, so its dashed rule reads as a divider to + nothing. `shouldHideBorder` only reaches rows this component renders + itself; the deposit-instruction sub-components below expand into rows + of their own, and rows can also drop out on conditions the visibility + config doesn't model (a token row still awaiting its icon fetch). The + container settles it for whatever actually renders last. */} +
{rowVisibilityConfig.createdAt && ( setShowCancelLinkModal(true)} + onClick={() => setShowCancelLinkDrawer(true)} loading={isLoading} variant={'primary-soft'} className="flex w-full items-center gap-1" @@ -910,12 +917,15 @@ export const TransactionDetailsReceipt = ({ )} - {/* Cancel Link Modal */} + {/* Cancel Link Drawer */} {setIsLoading && onClose && ( - { @@ -956,7 +966,7 @@ export const TransactionDetailsReceipt = ({ await queryClient.invalidateQueries({ queryKey: [TRANSACTIONS] }) setIsLoading(false) - setShowCancelLinkModal(false) + setShowCancelLinkDrawer(false) setCancelLinkState('cancelled') toast.success(t('toast.linkCancelled')) @@ -972,7 +982,7 @@ export const TransactionDetailsReceipt = ({ // Still close drawer even if invalidation fails setIsLoading(false) - setShowCancelLinkModal(false) + setShowCancelLinkDrawer(false) setCancelLinkState('cancelled') toast.success(t('toast.linkCancelledRefresh')) await new Promise((resolve) => setTimeout(resolve, 1500)) diff --git a/src/components/TransactionDetails/__tests__/receipt-trailing-divider.test.ts b/src/components/TransactionDetails/__tests__/receipt-trailing-divider.test.ts new file mode 100644 index 0000000000..c4d98aaee8 --- /dev/null +++ b/src/components/TransactionDetails/__tests__/receipt-trailing-divider.test.ts @@ -0,0 +1,68 @@ +/** + * The details card underlines every row with a dashed rule and drops it on the + * last one, so the rule doesn't double up with the card's own black border. + * + * `shouldHideBorder` can only reach rows the receipt renders itself. These cases + * end on a row the receipt delegates to a sub-component that renders its own + * rows — which is why the row container also carries + * `[&>*:last-child]:border-b-0`. If a future change makes the delegating rows + * unreachable as "last", this test is the thing that says so. + */ +import { readFileSync } from 'fs' +import { join } from 'path' +import { renderHook } from '@testing-library/react' +import { mapTransactionDataForDrawer } from '../transactionTransformer' +import { useReceiptViewModel } from '../useReceiptViewModel' +import { transactionDetailsRowKeys } from '../transaction-details.utils' +import type { HistoryEntry } from '@/utils/history.utils' + +jest.mock('@/assets', () => ({})) +jest.mock('@/assets/payment-apps', () => ({ MERCADO_PAGO: '', PIX: '' })) + +// Rows the receipt hands to a sub-component (MantecaDepositInfo, +// BridgeDepositInstructions) that expands into rows of its own. Those rows never +// see `hideBottomBorder`, so the container rule is what clears the last one. +// `cardPayment` is absent on purpose: CardPaymentRows takes an `isLastRow` prop. +const DELEGATED_ROWS = ['mantecaDepositInfo', 'depositInstructions'] + +type Case = { name: string; entry: HistoryEntry } + +const cases = JSON.parse(readFileSync(join(__dirname, 'fixtures', 'render-baseline.json'), 'utf8')) as Case[] + +const lastVisibleRow = (entry: HistoryEntry): string | undefined => { + const { transactionDetails } = mapTransactionDataForDrawer(entry) + const { result } = renderHook(() => useReceiptViewModel(transactionDetails, { isPublic: false })) + const visible = transactionDetailsRowKeys.filter((key) => result.current.rowVisibilityConfig[key]) + return visible[visible.length - 1] +} + +describe('receipt details card — trailing dashed rule', () => { + const byName = (name: string) => { + const found = cases.find((c) => c.name === name) + if (!found) throw new Error(`fixture ${name} missing from render-baseline.json`) + return found.entry + } + + it('the bridge pending deposit ends on a delegated row', () => { + expect(lastVisibleRow(byName('onramp-bridge-awaiting_funds-recipient'))).toBe('depositInstructions') + }) + + it('no other fixture ends on a delegated row', () => { + const delegated = cases + .map((c) => ({ name: c.name, last: lastVisibleRow(c.entry) })) + .filter(({ last }) => !!last && DELEGATED_ROWS.includes(last)) + .map(({ name }) => name) + + expect(delegated).toEqual(['onramp-bridge-awaiting_funds-recipient']) + }) + + // The second way the flag misses: a row the config calls visible carries an + // extra runtime gate in the JSX and doesn't reach the DOM, so the row above it + // keeps its rule and ends up last. These two are the live examples. + it('records the rows that can be config-visible but absent from the DOM', () => { + const doubleGated = ['tokenAndNetwork', 'exchangeRate'] + const atRisk = cases.map((c) => lastVisibleRow(c.entry)).filter((last) => !!last && doubleGated.includes(last)) + + expect(atRisk.length).toBeGreaterThan(0) + }) +}) diff --git a/src/constants/__tests__/routes.test.ts b/src/constants/__tests__/routes.test.ts index b690e6ea76..ca530e10b8 100644 --- a/src/constants/__tests__/routes.test.ts +++ b/src/constants/__tests__/routes.test.ts @@ -1,6 +1,6 @@ import fs from 'fs' import path from 'path' -import { DEDICATED_ROUTES, couldBeRecipient, isLocaleSegment, isReservedRoute } from '../routes' +import { DEDICATED_ROUTES, couldBeRecipient, isLocaleSegment, isReservedRoute, isSameRoute } from '../routes' // Guards against the "/card/foo → invalid recipient" class of bug: every // folder that resolves to a real Next.js route under src/app/ must be @@ -133,3 +133,31 @@ describe('isReservedRoute', () => { expect(isReservedRoute('/hugo0')).toBe(false) }) }) + +// The native build sets trailingSlash: true, so usePathname() yields '/home/' +// while nav hrefs are written '/home'. A bare === lost every active state there. +describe('isSameRoute', () => { + it('matches an exact path', () => { + expect(isSameRoute('/home', '/home')).toBe(true) + expect(isSameRoute('/support', '/support')).toBe(true) + }) + + it('matches across a trailing slash on either side', () => { + expect(isSameRoute('/home/', '/home')).toBe(true) + expect(isSameRoute('/home', '/home/')).toBe(true) + expect(isSameRoute('/home/', '/home/')).toBe(true) + }) + + it('does not match a different route', () => { + expect(isSameRoute('/history', '/home')).toBe(false) + expect(isSameRoute('/home/settings', '/home')).toBe(false) + expect(isSameRoute('/add-money', '/withdraw')).toBe(false) + }) + + it('keeps root distinct and tolerates a null pathname', () => { + expect(isSameRoute('/', '/')).toBe(true) + expect(isSameRoute('/', '/home')).toBe(false) + expect(isSameRoute(null, '/home')).toBe(false) + expect(isSameRoute(undefined, '/home')).toBe(false) + }) +}) diff --git a/src/constants/routes.ts b/src/constants/routes.ts index 572c375258..690401656b 100644 --- a/src/constants/routes.ts +++ b/src/constants/routes.ts @@ -216,3 +216,16 @@ export function isPublicRoute(path: string, isDev = false): boolean { } return false } + +/** + * Whether `pathName` is the route `href` points at, for nav active states. + * + * The native build sets `trailingSlash: true` (next.config.native.js), so + * `usePathname()` there returns `/home/` while nav hrefs are written `/home`. + * A bare `===` silently loses every active state in the app. + */ +export function isSameRoute(pathName: string | null | undefined, href: string): boolean { + const strip = (path: string) => (path.length > 1 ? path.replace(/\/+$/, '') : path) + if (!pathName) return false + return strip(pathName) === strip(href) +} From fa4b486f2b833fc0376f99142ad84888f14bba36 Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Tue, 18 Aug 2026 16:44:15 +0100 Subject: [PATCH 15/93] fix(native): paint the status-bar strip in the app background, not black MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The top strip was forced black in bd9a1b377 so it would stop flipping between black and beige above the pink beta feedback ribbon. That ribbon is now hidden on iOS (e56254ef0), and the underlying inconsistency was really a sizing/source problem, fixed in 9b3384e11 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. --- src/app/(mobile-ui)/layout.tsx | 9 ++++----- src/hooks/useNativePlugins.ts | 8 ++++---- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/src/app/(mobile-ui)/layout.tsx b/src/app/(mobile-ui)/layout.tsx index 2feb1ba953..0addd4624c 100644 --- a/src/app/(mobile-ui)/layout.tsx +++ b/src/app/(mobile-ui)/layout.tsx @@ -180,12 +180,11 @@ const Layout = ({ children }: { children: React.ReactNode }) => { return (
- {/* Status-bar safe zone. On Android 15+ edge-to-edge the webview draws - under the status bar, where bg-background would otherwise show beige. - Fill the inset strip (above the feedback ribbon) with black so the top - always reads black. Height is the natively measured inset on Android + {/* Status-bar safe zone. Paints the inset strip in the app background so + the top matches the page even where fixed children would otherwise draw + under the status bar. Height is the natively measured inset on Android 15+ and env() elsewhere, so still a no-op on web (inset = 0). */} -
+
{/* Wrapper div for desktop layout */}
{/* Sidebar - Fixed on desktop */} diff --git a/src/hooks/useNativePlugins.ts b/src/hooks/useNativePlugins.ts index 6e6164713a..7b4d61ceb7 100644 --- a/src/hooks/useNativePlugins.ts +++ b/src/hooks/useNativePlugins.ts @@ -18,11 +18,11 @@ export function useNativePlugins() { try { const { StatusBar, Style } = await import('@capacitor/status-bar') await StatusBar.setOverlaysWebView({ overlay: false }) - // Black status-bar strip with light icons, everywhere. On Android 15+ + // Status-bar strip in the app background with dark icons. On Android 15+ // edge-to-edge these are no-ops and the CSS safe zone in the layout - // paints the black behind the status bar instead. - await StatusBar.setStyle({ style: Style.Dark }) - await StatusBar.setBackgroundColor({ color: '#000000' }) + // paints the strip behind the status bar instead. + await StatusBar.setStyle({ style: Style.Light }) + await StatusBar.setBackgroundColor({ color: '#FAF4F0' }) // background } catch (e) { console.warn('failed to init status bar:', e) } From b846119d0d7c52b8dddf0ac50706d81fe0a289ec Mon Sep 17 00:00:00 2001 From: Aleksandar Balinda Date: Fri, 21 Aug 2026 12:11:28 +0200 Subject: [PATCH 16/93] feat(badges): PEANUT_SHAPER asset + share line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- public/badges/peanut_shaper.png | Bin 0 -> 154943 bytes src/components/Badges/badge.utils.ts | 1 + src/types/badge-assets.json | 1 + 3 files changed, 2 insertions(+) create mode 100644 public/badges/peanut_shaper.png diff --git a/public/badges/peanut_shaper.png b/public/badges/peanut_shaper.png new file mode 100644 index 0000000000000000000000000000000000000000..493257625c205b0355916f07941c57e0e5290f9e GIT binary patch literal 154943 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%0A+9)AARa)1luKx+Y@&q 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 tooQk9O;F*W@mmxv*oG zuDkAJzv%pai$7bkOAc&t&=9I^I&zM#I^`BdPSXHr8a+vjfbPm(Un%SJO<|M&gy&s5 zi+!t%T>;2Nhp>^(lHv|v{cJGrXt#yfYk(%CU*GJ($a9IePg8Y zwI0x;EX2l~k5h;xxn*Irz}SH z1H)Jex}7q=tqv8C_r!zjX)v)jrl#9eoa@b;GW9WpZy8w=ICu{@@H;rL+Xm@=2aN&f zZsNe)pMQF+?yM8I@C$JFR(^=nUw3nf3)sj^}D z=!un$c~vs`r;xNd2OIZe`(~SJMkN9mT&7cuADkheOC_mg;0y~DK7CN&PU*x#8RZtl z%M46rW4E)?h^2;l&=5|@Ytgc$WHxMsjiHzZ7xuI@T#D9VH$U&WoATr4PnsVI*iF8^ zz58V~Y0#uY>e;EEv}o4a)+N$nVck-v%!MzS^Nb)2*vtNV!2bn@2l49Lx35f{HdUri zohB<*t(3KER!d$Uc@~Rag1XWit^LCfKNN=z9j#{#q^Daij%`=X>g?xh`O$3Co*7a= zx{xn>ZG1y<$-*ViTyy62mcbPwg zFw6tcKLSU5E@{-GvZ~UJ>ZMfBi9j{CF=uPItG5%8i3+XM0}zm%XdbY3P+t zYw3l4i1R{|EM>RbPyWH#J7inb=Gm#Z4hrcM_H>-Xx2}h+P4J?gWWQ` z8la-ZFQbYV@aYp-DSp;>gjJHKkxIUF)#9J~_vzDo&){G{#ooq&Z8bvnwsHpL*-j3O zdHelm7py?n-u9veklE*QklKX==8nv^}M*P4*bXW0M%3Xa_l~+o^Ji z8D7vjLi?YWwvW-XfYy@bsrPU`K0SQ;ITcqG%7pRbo@>|gkUg&D@6)?v?Oo?P97@~Q zVkPm)_AHR^ zPgtsH6T}0wj`O6hol`yq{ECUmr)LZ*&APfp*5va;I%fT80$n8R(c9sl$zz9}49vwI zdF;!2LWNGrE(Kg&P|CZ2e7heU!-i`(u9-H*f^T6}Fu!QNj>W?NpzIHgWR*QfZDG@X z*9VoIlWVKU$~_Yobu>;LL3elWfhbwuUpxZg!>k*9o%FKBG2GRVZ!7}g7Dt9lh3|h# z3F1O$SplKe?Jn5Qg0fq-bKupk{oK&Fi)c!fILa9Eud;1z>>F*!LIT9qFej3bo1Y;? zAzbe}E8!ySBjS2PMjTc}dG_ks--u86{2OBu?u@qZh5pf|qe0CtyPhFX$cM5y`R6;x z9VO?1L(diM4T)NjOgKR$cy1YKqtunM!cx?1HG}_JO_nzUDZTOh@0LK4@Dz29KgmE% zgv`i_>S|RixOa<`i=+wlv(hs&5PGG^LgdZDYnCrQ`KJh=K%XgJywS*zmrF0)N-Xz( zGsKBL&Z?I=_fUx=_p)1_%%Q%O3w;I{BmoxCL;3*b?T{CR``ivQecDedMi$ye#Di_~ zn}1}|SyTbnqb}#!AKN_$F-*+wpr3XTN-G`y=VP}!$L{Ts(zR-&%aL0&;#AYEx@ZQh z;}D^0`TE5jRL@Q-0Do!RtyQxT-4ilu*J^Vw=?dlOY8SfZZ@}~C(H6l_Jo5)0D;cml zX&^aPNbH3p>&C3*--+|zh-k6GWhUJs|7fkQDS+(Dy?w^YU~j5hnHIw287b0Xpk33$ zgllB%BE06v+N!WH1sRV?XG>{2ze2CT2Roy7UYQ45$G5x2FPJR<9uyj^wy2ls-gX2P ztk*}2AIg1H4%&^slUia>&m%7Rq`#+^q_}Ovd3w)`>aOD8et@?*tuqRv9jW20gV!h( z?I>h<61+loN6W@t9P4N7*iOu$p98EMiTDSTXItGg+^2)x0{>-I^RB@7jKC-OC6|zl9m;pZ~o~4dsbW)&|iFQ3Z>D`OI z3FMfywem1N#R9q?Qnv=$6g-#sWuZk;@MPO?x+Ur={`;%yT`|#=U@-s`haTCQR}yty zXuGG21rK5CBQB!=eE3^D^lF12y`p0Btcy3wIQX_pg*eP=_n-|yU|(|J>yXX^!F!+%eWer>)_sEZu99GBvz-v>_ARA$Lm)*#3F z7{*PcgjTOOMggn&WDj8Pj;ro{-vi7UnY;j^2Jgf3QW-Ea2#mQ^_<_~uTdG!`Q< z4DEMx*X|MwB-;BRUsmN{uW7*=cm+&P_E$a1F1lEKpW&oxfrG`?l?`)F-~x;&@XlN; zw6m!}i?$;D*~r>-d&N_t9La_7&BsU&sFYd@+fE|$4;W4=(rWjt-J?-06xFGY_1Q~g zi3gCb%CA>S6EI@*Rys6KP5>5YVv&T^FZ`5raF1Y|u~r=@q#j9=)#qdv6!TgtdQZ%4 z{DaPmcZ^64qw?10C%HIOMdZb^i_&l3YfqQq=_{Ju%d<-b3gDfN4)^}c40e$cyUwsz z89+2-OE;_G{4}7r*z27ZszLS^=)$gO2RK_%c(JQ_)>(qz3$Htaq+EPMM9wOnl8nS+ zW*Z)g!6SYvg>VQ=c*Rt$Iq~M?(0Amh!JqA7yjpfS#!>qOv?yBR4uaS347E1oosi6< za-QA|@ozk->hPcXdSi5;)e`${^5LG$Ui~yY_W@0#HdfuvG;u-?A7yj)=pyLD5pd!eP}VVX=P_A#zg_yZ)&U*|^zaBg_RL z%Y^M~4z2z->0GekC+zUTV{D%v_?sD6PIuAZ3M1JF4W*CcOl=@i+j|?HslrQM1KxwV_{+GY}J94f6Um> z+-{J6P^$VSviBA_ZRQveyQ5M#Fd46w#iisqcWk)=R`K#8h-8y(wmIA=c8!ZPJU1`h zCn%CsaMYNL684^Hl$FX?XS6Fzo#d(zkzm4}J+Yyj7c7x^ZHoF&oX{xCCUu zw_7D#gA~m4@=oKDewn4X2hvV{zoB(QgMCXak_o!UL}qOb{c+YYmT?WyurDYJB@!0L zb%%_>!)D+yW%S|qI}5dNd;4v6Ai8?!W*C zfjwZqZP?tGdHdIOy^LIWyzvD0rhTVhyQ0V8DW^Tbr)OH1r#VD8idf1kmWG+Wuph0R z`FvNcWs9k{+W(lu9-i?7t;Pzdzbg8@Hl;0@ns;K1-Kg}NzNyxO3sLo}@8@IBTJ67E zvRM@S&S=#W(Ufz60PqP5&OzrP!mb9{Kr&2~JK6nMnv!_!*LL;Zpdl8c2Y$jwxOrRL z(X2gecf<#p-4U^z7QsO4IgQ=CD5ClVrZwMWO;iXMoe>`?Jnfxj(Rt6!D@;%er@y|< zNDAv>Wx(O~eB-?9#-JT;jL|;XJ{`6@xXG0nrp@~5!yD_2x6h@ABxYmNB7Eit%Ja;s z?H+7G^opS90f?k6)qc44JD76T{z0#i>H}X}#De=Gg-DjW2umaYpftj}ce<@_px=os z0wX-;tIGer(h?4ALe#%a5+Yj6wIq<|3q+TDNpX|tMiRHEj^A8IY-ZUWif6;rPl|hx zZ{O+v=3MU=;Iu2&)Rl}-&TxCO{sgmv)NC=EQ45$$H&O-tIPL#cSNJ{j@Hg%)E(i1q zE9CCD9QkradLD5>e(2(DY&>Q{({Wxr)1te1WI^cby(G^jLJt^xJ36;)fbX+Xy=R~mGA zIi);ijhkGb6Z|gA%u|%t`@Hp8*$(!2kM_Ij+BE?R<;FGdxxh^}l7_7>Hg9lVUhc{G zpSoE#IFuyd5!PX02~BD>ol6k`l1eCCfV2kF5Vw1Q(E9zbQU8hjhj(ThxTMR(bQ%<$ zB%?eqM<-XOAm1w*Y-c=*7ZV?{5_ypy$Kb{FUBvb5)tj_HQ|0l_M+4)TOA(c2pG@`j z4Fo^h$hSO<=&Y1=!I}Cm2PqoP0n4b{|LkifU;foD-jSH@I6$jqR#xZ40^AyAO_>*> z_+{US<~4n_+c9wWX@#$YyfB(s)EAax>xwmVp2!8tEB?K+F6l%DLd zhib00p03Ft&i(Dr2s{x9Ra8~u*UrgCq8MZ^mcrpcROgB#rYQVS38_=mmCs)1^y6hz zX;QwlMI_*31}LJjsFA2YleCA^eUkrjj{Z;-tbTIl)}uhutU(Yh}O|rAt*{kT>@`trK$fX;U%~ zZOOOJ553grX>{nzgCd*3LmRZOSb4Ru8Y6Oitx?G6oNwD68g{lDv*akC?htnU3)$m3 z)W~-)#~5Jvd6Kwg)An9_0gq2`u*ClRp~&G1@nZ!OW{^RCiqkYJ|9hvSIy1qiS#Q)qw+OXDH^()VBDVbN3T161Ic*(J&>8XzS)1t+n5BSC~&iPTL zN@^zvkO{1w6QJ(_ez}m6EYoh}DL069ZBTAk2L2j5c*J`P^U`Ut#j8#gooGs0vOnSB zTi3;I9|@zmw+Lf3t?m@>p9kyHOqNhK{Vb zeDJ9qDS3UKj7u+nd~f%dKOeSRF`k^ieBrpFV~#gDdlJs`@96nr5MOAxm5xtBO4*H4 z47H-8K9tXMd^k&ZY}7(6_e=bh7VD=dKitJ-j+!A421?SceUhhewuJPW(~ef>qLI78 zv~=qpGt)d!qEJy=Y;P)cCFCCVFQedvir>Q#`B+;x5Llrg8^!BgIlG?P+FU>4DBZrA z6c;ymN$d#QS;))~Rl}jzNQTg4F+2B)DWsO6(x|L1TMFxzh>p{PRzxIztNK!ZBHY}}q-I2$2xK`b21u|jYq%G-XB;Ei=!+AaE(`)bDD12_E-W@UT9k`YneGrfr^ zcGit9UMO4b#k=ao$=6KD7BC}cKMOr$n)zxDK-ls_=SrzY1ySPEc!3JA33M_O9A&kV wjxWDD{rBkj{~-C#2LE5pe_=Md6W4{3s$w);{+fb`k!y)$n04vosO8@`> literal 0 HcmV?d00001 diff --git a/src/components/Badges/badge.utils.ts b/src/components/Badges/badge.utils.ts index b5e570897b..5b8739e844 100644 --- a/src/components/Badges/badge.utils.ts +++ b/src/components/Badges/badge.utils.ts @@ -46,6 +46,7 @@ export function getBadgeDisplayName(code?: string, name?: string | null): string */ const BADGE_SHARE_LINES: Readonly> = { BETA_TESTER: "I've been in the Peanut lab since the early experiments. Officially a beta tester 🧪", + PEANUT_SHAPER: 'I talked. They took notes. Peanut got better 🎙️', DEVCONNECT_BA_2025: 'Buenos Aires ✅ Peanut badge ✅ A perfect trip', PRODUCT_HUNT: 'I upvoted Peanut on Product Hunt before it was cool. Hope dealer, certified 🚀', OG_2025_10_12: 'I was here before it was cool. Certified Peanut OG 🥜', diff --git a/src/types/badge-assets.json b/src/types/badge-assets.json index e415116302..4696a123fd 100644 --- a/src/types/badge-assets.json +++ b/src/types/badge-assets.json @@ -38,6 +38,7 @@ "NOT_SO_SHHHH": "/badges/not_so_shhhh.svg", "OFFRAMP_USER": "/badges/offramp_user.png", "OG_2025_10_12": "/badges/og_v1.svg", + "PEANUT_SHAPER": "/badges/peanut_shaper.png", "PRODUCT_HUNT": "/badges/product_hunt.svg", "PSYOPS_DIVISION": "/badges/psyops_division.svg", "SECOND_INVITE": "/badges/second_invite.svg", From 59e87368eb1bffaa7076e3652f5b32c84f001e73 Mon Sep 17 00:00:00 2001 From: Aleksandar Balinda Date: Fri, 21 Aug 2026 14:10:22 +0200 Subject: [PATCH 17/93] feat: mirror the user's app locale to the BE for localized emails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- src/app/ClientProviders.tsx | 2 + src/context/authContext.tsx | 5 ++- src/i18n/app/LocaleSync.tsx | 37 ++++++++++++++++ src/i18n/app/__tests__/locale-sync.test.ts | 49 ++++++++++++++++++++++ src/i18n/app/locale-sync.ts | 28 +++++++++++++ 5 files changed, 119 insertions(+), 2 deletions(-) create mode 100644 src/i18n/app/LocaleSync.tsx create mode 100644 src/i18n/app/__tests__/locale-sync.test.ts create mode 100644 src/i18n/app/locale-sync.ts diff --git a/src/app/ClientProviders.tsx b/src/app/ClientProviders.tsx index 42cfde245e..557eb5f40c 100644 --- a/src/app/ClientProviders.tsx +++ b/src/app/ClientProviders.tsx @@ -15,6 +15,7 @@ import { AppLockGate } from '@/components/Global/AppLock' import { ScreenOrientationLocker } from '@/components/Global/ScreenOrientationLocker' import { TranslationSafeWrapper } from '@/components/Global/TranslationSafeWrapper' import { AppIntlProvider } from '@/i18n/app/AppIntlProvider' +import { LocaleSync } from '@/i18n/app/LocaleSync' import { PeanutProvider } from '@/config/peanut.config' import { ContextProvider } from '@/context/contextProvider' import { FooterVisibilityProvider } from '@/context/footerVisibility' @@ -55,6 +56,7 @@ export function ClientProviders({ children }: { children: React.ReactNode }) { + diff --git a/src/context/authContext.tsx b/src/context/authContext.tsx index f31dcae406..98b70f152c 100644 --- a/src/context/authContext.tsx +++ b/src/context/authContext.tsx @@ -123,8 +123,9 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { // catalog rail ids for joins against the rails table. enabledRails: enabledRails.map((rail) => `${rail.rail.provider.code}:${rail.rail.method.code}`), enabledRailIds: enabledRails.map((rail) => rail.rail.id), - // Client-only (locale never reaches the BE) — covers the first - // session, where the startup locale resolves before identify. + // Client-set (the BE mirror lives in users.locale via LocaleSync) + // — covers the first session, where the startup locale resolves + // before identify. ...(appLocale ? { app_locale: appLocale } : {}), }) // Sentry: every error captured from here on inherits user context diff --git a/src/i18n/app/LocaleSync.tsx b/src/i18n/app/LocaleSync.tsx new file mode 100644 index 0000000000..b6ab156c30 --- /dev/null +++ b/src/i18n/app/LocaleSync.tsx @@ -0,0 +1,37 @@ +'use client' + +import { useEffect } from 'react' +import { useAuth } from '@/context/authContext' +import { useAppLocale } from './AppIntlProvider' +import { currentAppLocale, localeReady } from './locale-store' +import { syncLocaleToBackend } from './locale-sync' + +/** + * Mirrors the user's last known language choice to the BE. Renders nothing. + * Must mount below both AppIntlProvider and AuthProvider. + * + * Keyed on the provider locale so a manual switch in Settings syncs + * immediately, but the synced value comes from localeReady()/currentAppLocale() + * — the provider briefly shows the English SSR default before the startup + * locale applies, and that transient value must never be persisted as a + * "choice". + */ +export function LocaleSync() { + const { userId } = useAuth() + const { locale } = useAppLocale() + + useEffect(() => { + if (!userId) return + let cancelled = false + void localeReady().then((resolved) => { + // a manual setLocale (already applied and persisted) wins over the + // startup resolution + if (!cancelled) syncLocaleToBackend(userId, currentAppLocale() ?? resolved) + }) + return () => { + cancelled = true + } + }, [userId, locale]) + + return null +} diff --git a/src/i18n/app/__tests__/locale-sync.test.ts b/src/i18n/app/__tests__/locale-sync.test.ts new file mode 100644 index 0000000000..5666ed28f3 --- /dev/null +++ b/src/i18n/app/__tests__/locale-sync.test.ts @@ -0,0 +1,49 @@ +import { syncLocaleToBackend } from '../locale-sync' +import { updateUserById } from '@/app/actions/users' + +jest.mock('@/app/actions/users', () => ({ + updateUserById: jest.fn(), +})) + +const mockUpdate = updateUserById as jest.MockedFunction + +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)) + +describe('syncLocaleToBackend', () => { + beforeEach(() => { + localStorage.clear() + mockUpdate.mockReset() + mockUpdate.mockResolvedValue({ data: {} as never }) + }) + + it('sends the locale once and dedupes repeats', async () => { + syncLocaleToBackend('u1', 'pt-BR') + await flush() + expect(mockUpdate).toHaveBeenCalledWith({ userId: 'u1', locale: 'pt-BR' }) + + syncLocaleToBackend('u1', 'pt-BR') + await flush() + expect(mockUpdate).toHaveBeenCalledTimes(1) + }) + + it('re-syncs when the locale or user changes', async () => { + syncLocaleToBackend('u1', 'pt-BR') + await flush() + syncLocaleToBackend('u1', 'es-AR') + await flush() + syncLocaleToBackend('u2', 'es-AR') + await flush() + expect(mockUpdate).toHaveBeenCalledTimes(3) + expect(mockUpdate).toHaveBeenLastCalledWith({ userId: 'u2', locale: 'es-AR' }) + }) + + it('retries after a failed write (no synced marker stored)', async () => { + mockUpdate.mockResolvedValueOnce({ error: 'nope' }) + syncLocaleToBackend('u1', 'pt-BR') + await flush() + + syncLocaleToBackend('u1', 'pt-BR') + await flush() + expect(mockUpdate).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/i18n/app/locale-sync.ts b/src/i18n/app/locale-sync.ts new file mode 100644 index 0000000000..745cff6b7d --- /dev/null +++ b/src/i18n/app/locale-sync.ts @@ -0,0 +1,28 @@ +import { updateUserById } from '@/app/actions/users' +import type { AppLocale } from './config' + +const SYNCED_KEY = 'app-locale-synced' + +/** + * Persists the user's resolved app locale to the BE (`users.locale`, via + * POST /update-user) so notification emails can render in their language. + * Best-effort and deduped: one request per (user, locale) change; a failed + * write retries on the next startup because the synced marker is only stored + * on success. + */ +export function syncLocaleToBackend(userId: string, locale: AppLocale): void { + const synced = `${userId}:${locale}` + try { + if (localStorage.getItem(SYNCED_KEY) === synced) return + } catch { + // storage unavailable → sync every startup; the write is idempotent + } + void updateUserById({ userId, locale }).then(({ error }) => { + if (error) return + try { + localStorage.setItem(SYNCED_KEY, synced) + } catch { + // marker lost → re-sync next startup, still idempotent + } + }) +} From 356e1dcc4e760e9377e353c715bfb7923b66ac6a Mon Sep 17 00:00:00 2001 From: Aleksandar Balinda Date: Fri, 21 Aug 2026 14:11:23 +0200 Subject: [PATCH 18/93] fix: catch a rejected updateUserById so locale sync stays best-effort --- src/i18n/app/locale-sync.ts | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/i18n/app/locale-sync.ts b/src/i18n/app/locale-sync.ts index 745cff6b7d..366476b978 100644 --- a/src/i18n/app/locale-sync.ts +++ b/src/i18n/app/locale-sync.ts @@ -17,12 +17,16 @@ export function syncLocaleToBackend(userId: string, locale: AppLocale): void { } catch { // storage unavailable → sync every startup; the write is idempotent } - void updateUserById({ userId, locale }).then(({ error }) => { - if (error) return - try { - localStorage.setItem(SYNCED_KEY, synced) - } catch { - // marker lost → re-sync next startup, still idempotent - } - }) + void updateUserById({ userId, locale }) + .then(({ error }) => { + if (error) return + try { + localStorage.setItem(SYNCED_KEY, synced) + } catch { + // marker lost → re-sync next startup, still idempotent + } + }) + // updateUserById resolves with { error } today, but best-effort must + // stay best-effort even if it ever starts rejecting + .catch(() => {}) } From 462a2dcc2c499dd9b2a58acd3aa6075a95d49e1e Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Sat, 22 Aug 2026 11:52:16 +0100 Subject: [PATCH 19/93] perf(landing): keep the wallet provider tree off the marketing site MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- instrumentation-client.ts | 12 +- package.json | 1 - sentry.client.config.ts | 77 +- src/app/AppGlobals.tsx | 53 + src/app/ClientProviders.tsx | 37 +- src/app/__tests__/ClientProviders.test.tsx | 4 + src/app/layout.tsx | 29 +- .../illustrations/landing-countries.svg | 935 +----------------- src/components/Global/AddressLink/index.tsx | 2 +- src/components/LandingPage/noFees.tsx | 20 +- .../TransactionDetails/TransactionCard.tsx | 2 +- src/config/justaname.config.tsx | 28 - src/config/peanut.config.tsx | 24 +- src/config/queryClient.ts | 23 + src/config/wagmi.config.tsx | 33 +- src/context/appFlowProviders.tsx | 55 ++ src/context/contextProvider.tsx | 57 +- .../__tests__/usePrimaryNameServer.test.tsx | 30 +- src/hooks/usePrimaryNameServer.ts | 52 +- src/hooks/useRecipientDisplay.ts | 2 +- src/utils/__mocks__/justaname.ts | 22 - src/utils/__tests__/marketing-routes.test.ts | 40 + src/utils/defer-analytics.ts | 27 + src/utils/ens-name.utils.ts | 18 + src/utils/ens-onchain.utils.ts | 37 + src/utils/ens.utils.ts | 15 - src/utils/marketing-routes.ts | 29 + 27 files changed, 467 insertions(+), 1197 deletions(-) create mode 100644 src/app/AppGlobals.tsx delete mode 100644 src/config/justaname.config.tsx create mode 100644 src/config/queryClient.ts create mode 100644 src/context/appFlowProviders.tsx delete mode 100644 src/utils/__mocks__/justaname.ts create mode 100644 src/utils/__tests__/marketing-routes.test.ts create mode 100644 src/utils/defer-analytics.ts create mode 100644 src/utils/ens-name.utils.ts create mode 100644 src/utils/ens-onchain.utils.ts create mode 100644 src/utils/marketing-routes.ts diff --git a/instrumentation-client.ts b/instrumentation-client.ts index 703392d973..309c3ca811 100644 --- a/instrumentation-client.ts +++ b/instrumentation-client.ts @@ -3,6 +3,7 @@ import * as Sentry from '@sentry/nextjs' import { beforeSendHandler } from './sentry.utils' import { inferSentryEnvironment } from '@/utils/sentry-env' import { withoutBrowserTracing } from '@/utils/sentry-integrations' +import { whenIdle } from '@/utils/defer-analytics' // NEXT_PUBLIC_PERF_BARE builds strip all instrumentation to A/B jank against production. const PERF_BARE = process.env.NEXT_PUBLIC_PERF_BARE === 'true' @@ -29,6 +30,13 @@ if (typeof window !== 'undefined' && process.env.NODE_ENV !== 'development' && ! * Session recording is ON everywhere, native included, as a deliberate * trial from 1.0.48. * + * It starts at `whenIdle` rather than at init: rrweb's recorder is a + * separate ~183 KB script whose load and first full-DOM snapshot landed + * in the middle of page load, and on the landing page that is the single + * largest blocking cost after the framework itself. Recording still + * covers every session — it begins a beat later, so the opening moment + * of a replay is not captured. + * * It was disabled on native in 1.0.45 on the theory that rrweb's * per-mutation DOM serialization was the jank users reported. That was * never isolated: 1.0.45 also made pull-to-refresh listeners passive @@ -44,9 +52,11 @@ if (typeof window !== 'undefined' && process.env.NODE_ENV !== 'development' && ! * (default 5 minutes of full-DOM re-serialization) is the first knob to * reach for, before switching recording off again. */ - disable_session_recording: false, + disable_session_recording: true, }) + whenIdle(() => posthog.startSessionRecording()) + // expose the instance like the official snippet does — console access for // QA (feature-flag overrides, e.g. pwa-sunset preview testing) and support // debugging; the npm bundle doesn't attach it by itself diff --git a/package.json b/package.json index de162bbfea..9af11f77ba 100644 --- a/package.json +++ b/package.json @@ -202,7 +202,6 @@ "\\.(svg|png|jpg|jpeg|gif|webp)$": "/src/utils/__mocks__/static-image.ts", "^@/config/wagmi\\.config$": "/src/utils/__mocks__/wagmi-config.ts", "^wagmi/chains$": "/src/utils/__mocks__/wagmi.ts", - "^@justaname\\.id/react$": "/src/utils/__mocks__/justaname.ts", "^next/cache$": "/src/utils/__mocks__/next-cache.ts", "^@zerodev/sdk(.*)$": "/src/utils/__mocks__/zerodev-sdk.ts", "^@simplewebauthn/browser$": "/src/utils/__mocks__/simplewebauthn-browser.ts", diff --git a/sentry.client.config.ts b/sentry.client.config.ts index 0c0f8d7d6c..8b722be7b1 100644 --- a/sentry.client.config.ts +++ b/sentry.client.config.ts @@ -13,31 +13,62 @@ import posthog from 'posthog-js' import { beforeSendHandler } from './sentry.utils' import { inferSentryEnvironment } from '@/utils/sentry-env' +import { whenIdle } from '@/utils/defer-analytics' // NEXT_PUBLIC_PERF_BARE builds strip all instrumentation to A/B jank against production. if (process.env.NODE_ENV !== 'development' && process.env.NEXT_PUBLIC_PERF_BARE !== 'true') { - Sentry.init({ - dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, - environment: inferSentryEnvironment(), - enabled: true, - tracesSampleRate: 0.1, - debug: false, - - beforeSend: beforeSendHandler, - - integrations: [ - Sentry.captureConsoleIntegration({ - levels: ['error', 'warn'], - }), - // Cross-link Sentry ↔ PostHog: every Sentry error becomes a `$exception` - // event in PostHog with a Sentry deeplink, and the Sentry event gets a - // PostHog tag pointing back at the user's profile + session replay. - // posthog.init() runs in instrumentation-client.ts; the integration uses - // the singleton lazily, so init order doesn't matter. - posthog.sentryIntegration({ - organization: 'peanut-c34d84c05', - projectId: 4505827431415808, - }), - ], + /* + * `Sentry.init` is deferred to the first idle moment. Setting up the SDK — + * installing the default integrations, patching fetch/XHR and history for + * BrowserTracing, wrapping console — runs in every session regardless of + * `tracesSampleRate` (sampling only gates what is SENT), and doing it during + * page load is a measurable share of the landing page's blocking time. + * + * Coverage is unchanged rather than traded away: the two listeners below + * hold anything thrown before the SDK exists, and it is replayed into Sentry + * the moment init completes. `whenIdle` also fires on `pagehide`, so a + * session that ends early still reports. + */ + const buffered: Array = [] + const bufferEvent = (event: ErrorEvent | PromiseRejectionEvent) => buffered.push(event) + + window.addEventListener('error', bufferEvent) + window.addEventListener('unhandledrejection', bufferEvent) + + whenIdle(() => { + window.removeEventListener('error', bufferEvent) + window.removeEventListener('unhandledrejection', bufferEvent) + + Sentry.init({ + dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, + environment: inferSentryEnvironment(), + enabled: true, + tracesSampleRate: 0.1, + debug: false, + + beforeSend: beforeSendHandler, + + integrations: [ + Sentry.captureConsoleIntegration({ + levels: ['error', 'warn'], + }), + // Cross-link Sentry ↔ PostHog: every Sentry error becomes a `$exception` + // event in PostHog with a Sentry deeplink, and the Sentry event gets a + // PostHog tag pointing back at the user's profile + session replay. + // posthog.init() runs in instrumentation-client.ts; the integration uses + // the singleton lazily, so init order doesn't matter. + posthog.sentryIntegration({ + organization: 'peanut-c34d84c05', + projectId: 4505827431415808, + }), + ], + }) + + for (const event of buffered) { + Sentry.captureException( + 'reason' in event ? event.reason : (event.error ?? new Error(event.message || 'Unknown error')) + ) + } + buffered.length = 0 }) } diff --git a/src/app/AppGlobals.tsx b/src/app/AppGlobals.tsx new file mode 100644 index 0000000000..d8d541494c --- /dev/null +++ b/src/app/AppGlobals.tsx @@ -0,0 +1,53 @@ +'use client' + +import RainCooldownIntroModal from '@/components/Global/RainCooldown/IntroModal' +import StaleCardApprovalReEnableModal from '@/components/Global/StaleCardApproval/ReEnableModal' +import StaleDeploymentReload from '@/components/Global/StaleDeploymentReload' +import BadgeEarnToast from '@/components/Badges/BadgeEarnToast' +import { AppLockGate } from '@/components/Global/AppLock' +import { PeanutDebug } from '@/context/PeanutDebug' +import { useRouter } from 'next/navigation' +import { useEffect } from 'react' + +/** + * App-only global surfaces. Split out of `ClientProviders` because these depend + * on the wallet provider tree — `IntroModal` reads the rain-cooldown context and + * `PeanutDebug` reaches `useZeroDev` — which the marketing routes do not mount. + */ +export function AppGlobals({ children }: { children: React.ReactNode }) { + const router = useRouter() + + // Warms the route the app's camera button lands on. Lives here rather than + // as a in the root layout, which spent the bandwidth + // on marketing visitors who will never reach it. + useEffect(() => { + if (process.env.NODE_ENV === 'development') return // 9s+ compile in dev + router.prefetch('/qr-pay') + }, [router]) + + return ( + <> + + {/* 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 + 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). + Global so it surfaces wherever the user lands after earning. */} + + {/* Mounted inside the providers (not called in ClientProviders' + component body like useOtaUpdates) because it reads the query + client, redux and loading-state context to know when a reload + is safe. */} + + {/* Wraps rather than sits beside the page: while the native app is + locked, nothing protected renders. */} + {children} + + ) +} diff --git a/src/app/ClientProviders.tsx b/src/app/ClientProviders.tsx index 42cfde245e..c682313178 100644 --- a/src/app/ClientProviders.tsx +++ b/src/app/ClientProviders.tsx @@ -7,11 +7,6 @@ * the root layout (server component) renders this single client boundary. */ import { ConsoleGreeting } from '@/components/Global/ConsoleGreeting' -import RainCooldownIntroModal from '@/components/Global/RainCooldown/IntroModal' -import StaleCardApprovalReEnableModal from '@/components/Global/StaleCardApproval/ReEnableModal' -import StaleDeploymentReload from '@/components/Global/StaleDeploymentReload' -import BadgeEarnToast from '@/components/Badges/BadgeEarnToast' -import { AppLockGate } from '@/components/Global/AppLock' import { ScreenOrientationLocker } from '@/components/Global/ScreenOrientationLocker' import { TranslationSafeWrapper } from '@/components/Global/TranslationSafeWrapper' import { AppIntlProvider } from '@/i18n/app/AppIntlProvider' @@ -23,10 +18,11 @@ import { useNativeAppLinks } from '@/hooks/useNativeAppLinks' import { useOtaUpdates } from '@/hooks/useOtaUpdates' import { useSplashGate } from '@/hooks/useSplashGate' import { useZeroLegacyAndroidSafeAreaInsets } from '@/hooks/useZeroLegacyAndroidSafeAreaInsets' +import { isMarketingRoute } from '@/utils/marketing-routes' import { NuqsAdapter } from 'nuqs/adapters/next/app' import dynamic from 'next/dynamic' +import { usePathname } from 'next/navigation' import { Suspense } from 'react' -import { PeanutDebug } from '@/context/PeanutDebug' // Harness bootstrap ships only in harness builds. In prod bundles the dynamic // import is in dead code behind `if (false)` and webpack drops the chunk. @@ -36,6 +32,8 @@ const HarnessBootstrap = HARNESS_ENABLED }) : null +const AppGlobals = dynamic(() => import('./AppGlobals').then((m) => m.AppGlobals)) + export function ClientProviders({ children }: { children: React.ReactNode }) { // initialize capgo ota updates (calls notifyAppReady on mount, no-op on web) useOtaUpdates() @@ -45,6 +43,11 @@ export function ClientProviders({ children }: { children: React.ReactNode }) { useNativeAppLinks() useZeroLegacyAndroidSafeAreaInsets() + // The marketing site renders without the wallet provider tree, so the + // globals that depend on it are not mounted there either. `isMarketingRoute` + // fails safe: an unrecognised path gets the full app tree. + const marketing = isMarketingRoute(usePathname()) + return ( @@ -57,32 +60,12 @@ export function ClientProviders({ children }: { children: React.ReactNode }) { - - {/* 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 - 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). - Global so it surfaces wherever the user lands after earning. */} - - {/* Mounted inside the providers (not called in this - component's body like useOtaUpdates) because it - reads the query client, redux and loading-state - context to know when a reload is safe. */} - {HarnessBootstrap && ( )} - {/* Wraps rather than sits beside the page: while the - native app is locked, nothing protected renders. */} - {children} + {marketing ? children : {children}} diff --git a/src/app/__tests__/ClientProviders.test.tsx b/src/app/__tests__/ClientProviders.test.tsx index 358815736a..980cf73101 100644 --- a/src/app/__tests__/ClientProviders.test.tsx +++ b/src/app/__tests__/ClientProviders.test.tsx @@ -23,6 +23,10 @@ jest.mock('@/config/peanut.config', () => ({ return children }, })) +// The component is invoked as a plain function rather than rendered, so the +// router hook it now calls has no context. An app route is what keeps the full +// provider tree in the chain being asserted. +jest.mock('next/navigation', () => ({ usePathname: () => '/home' })) jest.mock('nuqs/adapters/next/app', () => ({ NuqsAdapter: function NuqsAdapter({ children }: { children: React.ReactNode }) { return children diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 6199418ad9..e9e78c6eb7 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,6 @@ import { ClientProviders } from './ClientProviders' import { type Viewport } from 'next' -import { Londrina_Solid, Roboto_Flex, Sniglet } from 'next/font/google' +import { Roboto_Flex, Sniglet } from 'next/font/google' import localFont from 'next/font/local' import Script from 'next/script' import '../styles/globals.css' @@ -95,18 +95,15 @@ const roboto = Roboto_Flex({ axes: ['wdth'], }) -const londrina = Londrina_Solid({ - weight: ['400', '900'], - subsets: ['latin'], - display: 'swap', - variable: '--font-londrina', -}) - +// preload: false on the decorative faces — next/font preloads every declared +// family at High priority, and these three render below the fold (2-3 call +// sites each) while competing with the hero image for bandwidth. const sniglet = Sniglet({ weight: ['400', '800'], subsets: ['latin'], display: 'swap', variable: '--font-sniglet', + preload: false, }) // The .woff2 files are latin + latin-ext subsets of the .ttf sources (built @@ -118,12 +115,14 @@ const knerdOutline = localFont({ src: '../assets/fonts/knerd-outline.woff2', variable: '--font-knerd-outline', display: 'swap', + preload: false, }) const knerdFilled = localFont({ src: '../assets/fonts/knerd-filled.woff2', variable: '--font-knerd-filled', display: 'swap', + preload: false, }) const robotoFlexBold = localFont({ @@ -160,9 +159,6 @@ export default function RootLayout({ children }: { children: React.ReactNode }) - {/* Prefetch /qr-pay route - disabled in dev to avoid 9s+ compile time */} - {process.env.NODE_ENV !== 'development' && } - {/* Chunk-load failure recovery: MUST be a raw inline script — error boundaries are lazy chunks themselves and fail to load in the exact conditions that need them, and even next/script beforeInteractive only queues into self.__next_s @@ -244,11 +240,16 @@ export default function RootLayout({ children }: { children: React.ReactNode }) process.env.NEXT_PUBLIC_CAPACITOR_BUILD !== 'true' && process.env.NEXT_PUBLIC_PERF_BARE !== 'true' && ( <> + {/* lazyOnload, not afterInteractive: Next emits a + for afterInteractive scripts, which + put 186 KB of gtag.js at High priority ahead of the LCP + image. Loading it after `load` keeps the pageview and + every downstream event, just off the critical path. */}