From 60f839fcce80f64366cc8b30d0570455734364d3 Mon Sep 17 00:00:00 2001 From: innolope-dev Date: Thu, 10 Sep 2026 18:32:22 +0100 Subject: [PATCH 01/19] feat: persist signup attribution journey context --- instrumentation-client.ts | 8 +- .../Setup/Views/SignTestTransaction.tsx | 10 + src/hooks/useZeroDev.ts | 15 +- src/utils/__tests__/deferred-link.test.ts | 33 +++ .../__tests__/signup-attribution.test.ts | 88 ++++++++ src/utils/deferred-link.ts | 34 ++- src/utils/signup-attribution.ts | 210 ++++++++++++++++++ 7 files changed, 394 insertions(+), 4 deletions(-) create mode 100644 src/utils/__tests__/signup-attribution.test.ts create mode 100644 src/utils/signup-attribution.ts diff --git a/instrumentation-client.ts b/instrumentation-client.ts index 753937c271..5721aaa145 100644 --- a/instrumentation-client.ts +++ b/instrumentation-client.ts @@ -10,6 +10,7 @@ import { startWebVitalsShim } from '@/utils/web-vitals-shim' import { noteAppReviewFriction } from '@/utils/app-review-friction' import { isNativeFetchRejectionExceptionEvent } from '@/utils/native-fetch-rejection' import { installPaymentNetworkGoogleAnalyticsGuard, isPaymentNetworkExplorerPath } from '@/utils/private-routes' +import { captureSignupAttribution, signupAttributionPosthogProperties } from '@/utils/signup-attribution' // Same conditions as the GA bootstrap in app/layout.tsx: with no GA to disable // there is nothing to guard, and PERF_BARE builds exist to carry no instrumentation. @@ -33,6 +34,10 @@ if ( ) { const posthogHost = process.env.NEXT_PUBLIC_POSTHOG_HOST || 'https://eu.i.posthog.com' const isNativeBuild = process.env.NEXT_PUBLIC_CAPACITOR_BUILD === 'true' + // Web journeys begin at the first pageview. Native journeys are created + // at registration unless a deferred store handoff restores this context + // first; the native app cannot observe the pre-install browser page. + const signupAttribution = isNativeBuild ? null : captureSignupAttribution() posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY!, { // Web posts through the `/relay` Next.js rewrite — the path is intentionally @@ -48,7 +53,8 @@ if ( // persisted — so a late register left the first open after an OTA // carrying the PREVIOUS bundle's release. `loaded` runs before that // first capture, which is the whole point of the denominator. - loaded: (ph) => ph.register({ app_release: APP_RELEASE }), + loaded: (ph) => + ph.register({ app_release: APP_RELEASE, ...signupAttributionPosthogProperties(signupAttribution) }), capture_pageleave: true, // The payment explorer contains team-only identity and relationship data. // Drop every event on client navigation; direct loads skip init above. diff --git a/src/components/Setup/Views/SignTestTransaction.tsx b/src/components/Setup/Views/SignTestTransaction.tsx index 7758a17137..531a9a37f5 100644 --- a/src/components/Setup/Views/SignTestTransaction.tsx +++ b/src/components/Setup/Views/SignTestTransaction.tsx @@ -18,6 +18,7 @@ import posthog from 'posthog-js' import { storeDeclaredResidence, storeSecondResidence } from '@/utils/declared-residence.storage' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' import { getFromCookie } from '@/utils/general.utils' +import { clearSignupAttribution, readSignupAttribution } from '@/utils/signup-attribution' import { twMerge } from '@/utils/tw' import { useTranslations } from 'next-intl' @@ -168,10 +169,19 @@ const SignTestTransaction = () => { // account setup complete - addAccount() already fetched and verified user data console.log('[SignTestTransaction] Account setup complete, showing the account-ready screen') const inviteCode = getFromCookie('inviteCode') + const signupAttribution = readSignupAttribution() posthog.capture(ANALYTICS_EVENTS.SIGNUP_COMPLETED, { acquisition_source: inviteCode ? 'referred' : 'organic', invite_code: inviteCode || undefined, + ...(signupAttribution + ? { + signup_journey_id: signupAttribution.journeyId, + signup_platform: signupAttribution.platform, + signup_attribution_capture_method: signupAttribution.captureMethod, + } + : {}), }) + clearSignupAttribution() // Persist the residence answer from the residence step, now that // the account exists. Fire-and-forget: prequalification data, diff --git a/src/hooks/useZeroDev.ts b/src/hooks/useZeroDev.ts index 3dfbde5ebb..d313030cc3 100644 --- a/src/hooks/useZeroDev.ts +++ b/src/hooks/useZeroDev.ts @@ -49,6 +49,7 @@ import { isCapacitor, getNativeRpId } from '@/utils/capacitor' import { isDemoMode } from '@/utils/demo' import { rescueUserOpReceipt } from '@/utils/userop-rescue.utils' import { clearInvite, extendInviteForRetry, readInviteCode, readInviteType } from '@/utils/invite-stash' +import { buildSignupAttributionHeader } from '@/utils/signup-attribution' // types type UserOpEncodedParams = { @@ -112,6 +113,15 @@ export const useZeroDev = () => { zeroDevFlowActions.setIsRegistering(true) try { const rpId = isCapacitor() ? getNativeRpId() : window.location.hostname.replace(/^www\./, '') + // Native store hand-off restoration is intentionally started in the + // app-link listener without blocking app boot. Join the same + // in-flight promise here so a fast signup tap cannot outrun the + // Android referrer read or iOS paste hand-off. + if (isCapacitor()) { + const { restoreDeferredContext } = await import('@/utils/deferred-link') + await restoreDeferredContext() + } + const signupAttributionHeader = buildSignupAttributionHeader() // @capgo/capacitor-passkey shim patches navigator.credentials on native, // so toWebAuthnKey works on all platforms (web, android, ios). @@ -126,7 +136,10 @@ export const useZeroDev = () => { // Consent-ledger echo (tos-v1 phase 2): the ZeroDev SDK owns the // register/verify request body, so the terms+privacy versions the // signup screen displayed ride in a header the backend ledgers. - passkeyServerHeaders: { 'x-accepted-legal': JSON.stringify(signupConsentDocuments()) }, + passkeyServerHeaders: { + 'x-accepted-legal': JSON.stringify(signupConsentDocuments()), + ...(signupAttributionHeader ? { 'x-signup-attribution': signupAttributionHeader } : {}), + }, rpID: rpId, }) ) diff --git a/src/utils/__tests__/deferred-link.test.ts b/src/utils/__tests__/deferred-link.test.ts index 575aa12ae4..6fa187f463 100644 --- a/src/utils/__tests__/deferred-link.test.ts +++ b/src/utils/__tests__/deferred-link.test.ts @@ -51,10 +51,12 @@ jest.mock('@capacitor/preferences', () => ({ })) const posthogCapture = jest.fn() +const posthogRegister = jest.fn() jest.mock('posthog-js', () => ({ __esModule: true, default: { capture: (...args: unknown[]) => posthogCapture(...args), + register: (...args: unknown[]) => posthogRegister(...args), }, })) @@ -390,6 +392,37 @@ describe('restore telemetry', () => { expect(JSON.stringify(posthogCapture.mock.calls)).not.toContain('/home') }) + it('restores attribution and registers only the durable journey identity', async () => { + mockIsAndroidNative.mockReturnValue(true) + getReferrer.mockResolvedValue({ + referrer: `pnutdl=1&attribution=${encodeURIComponent( + JSON.stringify({ + schemaVersion: '1', + journeyId: '33333333-3333-4333-8333-333333333333', + platform: 'android', + analyticsState: 'enabled', + captureMethod: 'browser', + firstTouch: { + occurredAt: '2026-09-10T09:00:00.000Z', + utmSource: 'creator', + utmCampaign: 'summer', + path: '/blog/how-it-works', + }, + }) + )}&dest=%2Fhome`, + }) + + await restoreDeferredContext() + + expect(posthogRegister).toHaveBeenCalledWith({ + signup_journey_id: '33333333-3333-4333-8333-333333333333', + signup_platform: 'android', + signup_attribution_capture_method: 'deferred_link', + }) + expect(JSON.stringify(posthogCapture.mock.calls)).not.toContain('creator') + expect(JSON.stringify(posthogCapture.mock.calls)).not.toContain('/home') + }) + it('separates an organic install from a declined iOS paste prompt', async () => { // organic iOS install: nothing url-like on the clipboard, no prompt raised mockIsIOSNative.mockReturnValue(true) diff --git a/src/utils/__tests__/signup-attribution.test.ts b/src/utils/__tests__/signup-attribution.test.ts new file mode 100644 index 0000000000..5b2a3943de --- /dev/null +++ b/src/utils/__tests__/signup-attribution.test.ts @@ -0,0 +1,88 @@ +import { + buildSignupAttributionHeader, + captureSignupAttribution, + parseSignupAttribution, + readSignupAttribution, + restoreSignupAttribution, + signupAttributionPosthogProperties, +} from '../signup-attribution' + +const clearAttributionCookie = () => { + document.cookie = 'signupAttribution=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/' +} + +beforeEach(() => { + process.env.NEXT_PUBLIC_CAPACITOR_BUILD = 'false' + clearAttributionCookie() + window.history.replaceState({}, '', '/') + Object.defineProperty(document, 'referrer', { configurable: true, value: '' }) +}) + +describe('signup attribution context', () => { + it('keeps first touch stable while updating the last source touch', () => { + window.history.replaceState( + {}, + '', + '/blog/creator-guide?utm_source=creator&utm_medium=social&utm_campaign=summer' + ) + Object.defineProperty(document, 'referrer', { + configurable: true, + value: 'https://example.com/article', + }) + + const first = captureSignupAttribution() + expect(first).toMatchObject({ + platform: 'web', + captureMethod: 'browser', + firstTouch: { + utmSource: 'creator', + utmMedium: 'social', + utmCampaign: 'summer', + referrerHost: 'example.com', + path: '/blog/creator-guide', + }, + firstContentTouch: { path: '/blog/creator-guide' }, + }) + + window.history.replaceState({}, '', '/signup?utm_source=paid&utm_medium=cpc&utm_campaign=retargeting') + const second = captureSignupAttribution() + + expect(second?.journeyId).toBe(first?.journeyId) + expect(second?.firstTouch).toEqual(first?.firstTouch) + expect(second?.firstContentTouch).toEqual(first?.firstContentTouch) + expect(second?.lastTouch).toMatchObject({ + utmSource: 'paid', + utmMedium: 'cpc', + utmCampaign: 'retargeting', + path: '/signup', + }) + }) + + it('round-trips the context through the registration header and preserves its journey id', () => { + window.history.replaceState({}, '', '/?utm_source=newsletter&utm_medium=email') + const captured = captureSignupAttribution() + expect(captured).not.toBeNull() + + const parsed = parseSignupAttribution(buildSignupAttributionHeader()) + expect(parsed).toEqual(readSignupAttribution()) + expect(parsed?.journeyId).toBe(captured?.journeyId) + expect(signupAttributionPosthogProperties(parsed)).toEqual({ + signup_journey_id: captured?.journeyId, + signup_platform: 'web', + signup_attribution_capture_method: 'browser', + }) + }) + + it('marks deferred-link attribution and retains it for native registration', () => { + window.history.replaceState({}, '', '/?utm_source=creator&utm_campaign=summer') + const captured = captureSignupAttribution() + expect(captured).not.toBeNull() + + const restored = restoreSignupAttribution(captured!) + expect(restored).toMatchObject({ + journeyId: captured?.journeyId, + captureMethod: 'deferred_link', + }) + expect(readSignupAttribution()).toEqual(restored) + }) +}) diff --git a/src/utils/deferred-link.ts b/src/utils/deferred-link.ts index 2417e6ca4d..618cc0f08c 100644 --- a/src/utils/deferred-link.ts +++ b/src/utils/deferred-link.ts @@ -22,6 +22,14 @@ import { parsePendingBadgeCampaigns, queuePendingBadgeCampaigns, } from '@/components/Invites/badge-campaign-context' +import { + parseSignupAttribution, + readSignupAttribution, + restoreSignupAttribution, + serializeSignupAttribution, + signupAttributionPosthogProperties, + type SignupAttributionContext, +} from './signup-attribution' // marker param distinguishing our payload from Play's organic referrer // (utm_source=google-play&utm_medium=organic) @@ -51,6 +59,8 @@ export interface DeferredPayload { badgeCampaigns?: string[] /** Legacy single-campaign payload accepted during app upgrade. */ campaign?: string + /** Durable marketing-attribution context from the web journey. */ + attribution?: SignupAttributionContext dest?: string } @@ -106,6 +116,9 @@ export function buildDeferredPayload(dest?: string, invite?: string): string { params.append(BADGE_CAMPAIGN_QUERY_PARAM, badgeCampaign) } + const attribution = serializeSignupAttribution() + if (attribution) params.set('attribution', attribution) + // a page whose url carries the claim secret in the fragment (#p=) must not // ride as a default dest: the fragment never rides (by design), so the // restored claim page would render unclaimable. the working path is the @@ -158,10 +171,12 @@ export function parseDeferredPayload(raw: string): DeferredPayload | null { if (params.get(MARKER) !== '1') return null const pick = (key: string) => params.get(key) || undefined const badgeCampaigns = badgeCampaignIdentitiesFromDeferredSearchParams(params) + const attribution = parseSignupAttribution(params.get('attribution')) return { lang: pick('lang'), invite: pick('invite'), badgeCampaigns: badgeCampaigns.length > 0 ? badgeCampaigns : undefined, + attribution: attribution ?? undefined, dest: pick('dest'), } } @@ -307,12 +322,25 @@ async function doRestore(): Promise { } const restored = applyDeferredPayload(payload) - captureRestore(channel, DEFERRED_LINK_OUTCOMES.RESTORED, { + const restoreFields: Record = { has_dest: !!restored.dest, has_locale: !!restored.locale, has_invite: !!payload.invite, has_campaign: !!(payload.badgeCampaigns?.length || payload.campaign), - }) + } + // Keep the legacy event shape for old payloads, while making attribution + // presence observable for the new hand-off contract. + if (payload.attribution) restoreFields.has_attribution = true + captureRestore(channel, DEFERRED_LINK_OUTCOMES.RESTORED, restoreFields) + + // Register only after the privacy-preserving restore event above. The + // journey id is useful on subsequent native events, but must not turn the + // restore telemetry into a payload dump. + if (payload.attribution && typeof posthog.register === 'function') { + try { + posthog.register(signupAttributionPosthogProperties(readSignupAttribution())) + } catch {} + } // privacy: clear the consumed hand-off off the clipboard. after the flag — // an interrupted clear can't cause a re-read. single space: some platforms @@ -333,6 +361,8 @@ async function doRestore(): Promise { * the /dev/deferred simulator so there is exactly one apply path. */ export function applyDeferredPayload(payload: DeferredPayload): RestoredContext { + if (payload.attribution) restoreSignupAttribution(payload.attribution) + // inviteCode: SESSION cookie, exactly matching the web invite flow // (InvitesPage). the cookie routes /setup past Landing — the only screen // with Log In — so a durable cookie would lock an existing user who diff --git a/src/utils/signup-attribution.ts b/src/utils/signup-attribution.ts new file mode 100644 index 0000000000..89597815a2 --- /dev/null +++ b/src/utils/signup-attribution.ts @@ -0,0 +1,210 @@ +import { isMarketingRoute } from './marketing-routes' +import { getFromCookie, saveToCookie } from './cookie-url.utils' + +export const SIGNUP_ATTRIBUTION_COOKIE = 'signupAttribution' +export const SIGNUP_ATTRIBUTION_SCHEMA_VERSION = '1' +const SIGNUP_ATTRIBUTION_EXPIRY_DAYS = 90 +const MAX_VALUE_LENGTH = 128 +const MAX_PATH_LENGTH = 512 + +export type SignupAttributionPlatform = 'web' | 'ios' | 'android' | 'unknown' +export type SignupAttributionCaptureMethod = 'browser' | 'deferred_link' + +export interface SignupAttributionTouch { + occurredAt: string + utmSource?: string + utmMedium?: string + utmCampaign?: string + utmContent?: string + referrerHost?: string + path?: string +} + +export interface SignupAttributionContext { + schemaVersion: typeof SIGNUP_ATTRIBUTION_SCHEMA_VERSION + journeyId: string + platform: SignupAttributionPlatform + analyticsState: 'enabled' + captureMethod: SignupAttributionCaptureMethod + firstTouch: SignupAttributionTouch + firstContentTouch?: SignupAttributionTouch + lastTouch?: SignupAttributionTouch +} + +function cleanValue(value: string | null | undefined, maxLength = MAX_VALUE_LENGTH): string | undefined { + if (!value) return undefined + const cleaned = value.replace(/[\u0000-\u001f\u007f]/g, '').trim() + return cleaned.length > 0 && cleaned.length <= maxLength ? cleaned : undefined +} + +function cleanPath(path: string): string | undefined { + const clean = cleanValue(path, MAX_PATH_LENGTH)?.split(/[?#]/, 1)[0] + return clean?.startsWith('/') && !clean.startsWith('//') ? clean : undefined +} + +function newJourneyId(): string { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') return crypto.randomUUID() + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (character) => { + const random = Math.random() * 16 + const value = character === 'x' ? random : (random & 0x3) | 0x8 + return Math.floor(value).toString(16) + }) +} + +function platform(): SignupAttributionPlatform { + if (typeof window === 'undefined') return 'unknown' + return process.env.NEXT_PUBLIC_CAPACITOR_BUILD !== 'true' + ? 'web' + : /iphone|ipad|ipod/i.test(navigator.userAgent) + ? 'ios' + : 'android' +} + +function isContentPath(pathname: string): boolean { + const parts = pathname.split('/').filter(Boolean) + if (parts.length === 0) return false + if (isMarketingRoute(pathname)) return parts.length > 1 + return [ + 'blog', + 'content', + 'help', + 'faq', + 'how-it-works', + 'pay-with', + 'send-money-to', + 'receive-money-from', + ].includes(parts[0].toLowerCase()) +} + +function currentTouch(): SignupAttributionTouch | null { + if (typeof window === 'undefined') return null + + const url = new URL(window.location.href) + const referrer = document.referrer + let referrerHost: string | undefined + if (referrer) { + try { + const parsedReferrer = new URL(referrer) + if (parsedReferrer.origin !== url.origin) referrerHost = cleanValue(parsedReferrer.hostname, 255) + } catch {} + } + + return { + occurredAt: new Date().toISOString(), + utmSource: cleanValue(url.searchParams.get('utm_source')), + utmMedium: cleanValue(url.searchParams.get('utm_medium')), + utmCampaign: cleanValue(url.searchParams.get('utm_campaign')), + utmContent: cleanValue(url.searchParams.get('utm_content')), + referrerHost, + path: cleanPath(url.pathname), + } +} + +function isTouch(value: unknown): value is SignupAttributionTouch { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false + const touch = value as Partial + return ( + typeof touch.occurredAt === 'string' && + new Date(touch.occurredAt).getTime() === new Date(touch.occurredAt).getTime() + ) +} + +function isContext(value: unknown): value is SignupAttributionContext { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false + const context = value as Partial + return ( + context.schemaVersion === SIGNUP_ATTRIBUTION_SCHEMA_VERSION && + typeof context.journeyId === 'string' && + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(context.journeyId) && + (context.platform === 'web' || + context.platform === 'ios' || + context.platform === 'android' || + context.platform === 'unknown') && + context.analyticsState === 'enabled' && + (context.captureMethod === 'browser' || context.captureMethod === 'deferred_link') && + isTouch(context.firstTouch) && + (context.firstContentTouch === undefined || isTouch(context.firstContentTouch)) && + (context.lastTouch === undefined || isTouch(context.lastTouch)) + ) +} + +export function readSignupAttribution(): SignupAttributionContext | null { + const stored = getFromCookie(SIGNUP_ATTRIBUTION_COOKIE) + return isContext(stored) ? stored : null +} + +export function captureSignupAttribution(): SignupAttributionContext | null { + const touch = currentTouch() + if (!touch) return null + + const existing = readSignupAttribution() + const context: SignupAttributionContext = { + schemaVersion: SIGNUP_ATTRIBUTION_SCHEMA_VERSION, + journeyId: existing?.journeyId ?? newJourneyId(), + platform: existing?.platform ?? platform(), + analyticsState: 'enabled', + captureMethod: existing?.captureMethod ?? 'browser', + firstTouch: existing?.firstTouch ?? touch, + ...(existing?.firstContentTouch + ? { firstContentTouch: existing.firstContentTouch } + : touch.path && isContentPath(touch.path) + ? { firstContentTouch: touch } + : {}), + ...(touch.utmSource || touch.utmMedium || touch.utmCampaign || touch.utmContent || touch.referrerHost + ? { lastTouch: touch } + : existing?.lastTouch + ? { lastTouch: existing.lastTouch } + : {}), + } + + saveToCookie(SIGNUP_ATTRIBUTION_COOKIE, context, SIGNUP_ATTRIBUTION_EXPIRY_DAYS) + return context +} + +export function restoreSignupAttribution(context: SignupAttributionContext): SignupAttributionContext { + const existing = readSignupAttribution() + const restored: SignupAttributionContext = { + ...context, + captureMethod: 'deferred_link', + firstContentTouch: context.firstContentTouch ?? existing?.firstContentTouch, + lastTouch: context.lastTouch ?? existing?.lastTouch, + } + saveToCookie(SIGNUP_ATTRIBUTION_COOKIE, restored, SIGNUP_ATTRIBUTION_EXPIRY_DAYS) + return restored +} + +export function clearSignupAttribution(): void { + saveToCookie(SIGNUP_ATTRIBUTION_COOKIE, '') +} + +export function serializeSignupAttribution( + context: SignupAttributionContext | null = readSignupAttribution() +): string | null { + return context ? JSON.stringify(context) : null +} + +export function parseSignupAttribution(value: string | null | undefined): SignupAttributionContext | null { + if (!value) return null + try { + const parsed: unknown = JSON.parse(value) + return isContext(parsed) ? parsed : null + } catch { + return null + } +} + +export function buildSignupAttributionHeader(): string | undefined { + const context = readSignupAttribution() ?? captureSignupAttribution() + return serializeSignupAttribution(context) ?? undefined +} + +export function signupAttributionPosthogProperties( + context: SignupAttributionContext | null = readSignupAttribution() +): Record { + if (!context) return {} + return { + signup_journey_id: context.journeyId, + signup_platform: context.platform, + signup_attribution_capture_method: context.captureMethod, + } +} From 72e6f685ffebe77b5634f8a4ea16996097c8c13d Mon Sep 17 00:00:00 2001 From: innolope-dev Date: Thu, 10 Sep 2026 20:17:51 +0100 Subject: [PATCH 02/19] fix: address signup attribution review feedback --- src/app/ClientProviders.tsx | 16 +- src/app/__tests__/ClientProviders.test.tsx | 16 +- .../Setup/Views/SignTestTransaction.tsx | 3 +- src/context/authContext.tsx | 12 ++ src/hooks/useZeroDev.ts | 17 ++- src/services/signup-attribution.ts | 42 +++++ src/utils/__tests__/deferred-link.test.ts | 27 ++++ .../__tests__/signup-attribution.test.ts | 24 +++ src/utils/cookie-url.utils.ts | 7 +- src/utils/deferred-link.ts | 131 +++++++++++++++- src/utils/signup-attribution.ts | 144 ++++++++++++++++-- 11 files changed, 413 insertions(+), 26 deletions(-) create mode 100644 src/services/signup-attribution.ts diff --git a/src/app/ClientProviders.tsx b/src/app/ClientProviders.tsx index aaa197558d..9bb77d8d7c 100644 --- a/src/app/ClientProviders.tsx +++ b/src/app/ClientProviders.tsx @@ -23,10 +23,11 @@ import { useSplashGate } from '@/hooks/useSplashGate' import { useZeroLegacyAndroidSafeAreaInsets } from '@/hooks/useZeroLegacyAndroidSafeAreaInsets' import { applyLegacyAndroidSafeAreaZeroFromUserAgent, isCapacitor, isWebViewCssSupported } from '@/utils/capacitor' import { isMarketingRoute } from '@/utils/marketing-routes' +import { captureSignupAttribution } from '@/utils/signup-attribution' import { NuqsAdapter } from 'nuqs/adapters/next/app' import dynamic from 'next/dynamic' import { usePathname } from 'next/navigation' -import { Suspense } from 'react' +import { Suspense, useEffect } from 'react' // 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. @@ -68,7 +69,9 @@ export function ClientProviders({ children }: { children: React.ReactNode }) { // 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()) + const pathname = usePathname() + const marketing = isMarketingRoute(pathname) + const IntlProvider = marketing ? MarketingIntlProvider : AppIntlProvider if (UNSUPPORTED_WEBVIEW) { @@ -91,6 +94,7 @@ export function ClientProviders({ children }: { children: React.ReactNode }) { only through the dynamically-imported AppStateProviders chunk, and a chunk that loads slowly or fails would take readiness down with it. */ + {/* Must sit ABOVE ContextProvider: TokenContextProvider → useWallet @@ -119,3 +123,11 @@ export function ClientProviders({ children }: { children: React.ReactNode }) { ) } + +/** Capture every App Router entry, including client navigations. */ +export function SignupAttributionNavigationCapture({ pathname }: { pathname: string | null }) { + useEffect(() => { + if (!isCapacitor()) captureSignupAttribution() + }, [pathname]) + return null +} diff --git a/src/app/__tests__/ClientProviders.test.tsx b/src/app/__tests__/ClientProviders.test.tsx index 9b7572c8b6..23eea1f27b 100644 --- a/src/app/__tests__/ClientProviders.test.tsx +++ b/src/app/__tests__/ClientProviders.test.tsx @@ -9,11 +9,16 @@ * contract is checked without mocking the wallet/kernel/Capacitor stack. */ import React from 'react' -import { ClientProviders } from '../ClientProviders' +import { render, waitFor } from '@testing-library/react' +import { ClientProviders, SignupAttributionNavigationCapture } from '../ClientProviders' +import { captureSignupAttribution } from '@/utils/signup-attribution' + +const mockCaptureSignupAttribution = jest.mocked(captureSignupAttribution) jest.mock('@/hooks/useSplashGate', () => ({ useSplashGate: jest.fn() })) jest.mock('@/hooks/useNativeAppLinks', () => ({ useNativeAppLinks: jest.fn() })) jest.mock('@/hooks/useZeroLegacyAndroidSafeAreaInsets', () => ({ useZeroLegacyAndroidSafeAreaInsets: jest.fn() })) +jest.mock('@/utils/signup-attribution', () => ({ captureSignupAttribution: jest.fn() })) // Both sit ABOVE the two providers under test, so stubbing them can't mask the // contract. PeanutProvider pulls the wagmi config (http() at module scope) and // nuqs ships ESM jest won't transform — neither survives jsdom import. @@ -90,4 +95,13 @@ describe('ClientProviders provider order', () => { expect(context).toBeGreaterThanOrEqual(0) expect(intl).toBeLessThan(context) }) + + it('captures attribution when the navigation entry component mounts', async () => { + mockCaptureSignupAttribution.mockClear() + Object.defineProperty(window, 'Capacitor', { configurable: true, value: undefined }) + + render() + + await waitFor(() => expect(mockCaptureSignupAttribution).toHaveBeenCalledTimes(1)) + }) }) diff --git a/src/components/Setup/Views/SignTestTransaction.tsx b/src/components/Setup/Views/SignTestTransaction.tsx index 531a9a37f5..adfdca4d2f 100644 --- a/src/components/Setup/Views/SignTestTransaction.tsx +++ b/src/components/Setup/Views/SignTestTransaction.tsx @@ -18,7 +18,7 @@ import posthog from 'posthog-js' import { storeDeclaredResidence, storeSecondResidence } from '@/utils/declared-residence.storage' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' import { getFromCookie } from '@/utils/general.utils' -import { clearSignupAttribution, readSignupAttribution } from '@/utils/signup-attribution' +import { readSignupAttribution } from '@/utils/signup-attribution' import { twMerge } from '@/utils/tw' import { useTranslations } from 'next-intl' @@ -181,7 +181,6 @@ const SignTestTransaction = () => { } : {}), }) - clearSignupAttribution() // Persist the residence answer from the residence step, now that // the account exists. Fire-and-forget: prequalification data, diff --git a/src/context/authContext.tsx b/src/context/authContext.tsx index cb86e0f6f9..e25ada798f 100644 --- a/src/context/authContext.tsx +++ b/src/context/authContext.tsx @@ -31,6 +31,8 @@ import { clearStepUpToken } from '@/services/step-up' import { claimAndSettlePendingBadgeCampaigns, isConfirmedBadgeCampaignClaim } from '@/services/badge-campaigns' import { clearPendingBadgeCampaigns, getPendingBadgeCampaigns } from '@/components/Invites/badge-campaign-context' import { clearInvite } from '@/utils/invite-stash' +import { attachSignupAttribution } from '@/services/signup-attribution' +import { clearSignupAttribution } from '@/utils/signup-attribution' interface AuthContextType { user: IUserProfile | null @@ -142,6 +144,12 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { username: user.user.username ?? undefined, email: user.user.email ?? undefined, }) + // Covers a native restart or a transient API outage after signup. + // The service is idempotent and clears local evidence only after + // the authenticated endpoint acknowledges it. + void attachSignupAttribution().catch((error) => + captureException(error, { level: 'warning', tags: { error_type: 'signup_attribution_retry_failed' } }) + ) } else { // Logout / unauthenticated: clear Sentry user so subsequent // anonymous-session errors don't get misattributed. @@ -301,6 +309,10 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { // unable to log back in until the process dies (session cookie). clearInvite() + // Source context is account-linkable; an explicit account switch must + // never let the next user inherit the previous user's journey. + clearSignupAttribution() + // A cached step-up proof outliving the session would let the next user // of this device skip verification on card and withdrawal screens. clearStepUpToken() diff --git a/src/hooks/useZeroDev.ts b/src/hooks/useZeroDev.ts index d313030cc3..a0f042c5c0 100644 --- a/src/hooks/useZeroDev.ts +++ b/src/hooks/useZeroDev.ts @@ -49,7 +49,8 @@ import { isCapacitor, getNativeRpId } from '@/utils/capacitor' import { isDemoMode } from '@/utils/demo' import { rescueUserOpReceipt } from '@/utils/userop-rescue.utils' import { clearInvite, extendInviteForRetry, readInviteCode, readInviteType } from '@/utils/invite-stash' -import { buildSignupAttributionHeader } from '@/utils/signup-attribution' +import { attachSignupAttribution } from '@/services/signup-attribution' +import { markSignupAttributionPending } from '@/utils/signup-attribution' // types type UserOpEncodedParams = { @@ -121,8 +122,6 @@ export const useZeroDev = () => { const { restoreDeferredContext } = await import('@/utils/deferred-link') await restoreDeferredContext() } - const signupAttributionHeader = buildSignupAttributionHeader() - // @capgo/capacitor-passkey shim patches navigator.credentials on native, // so toWebAuthnKey works on all platforms (web, android, ios). // Same TASK-21782 guard as login: native shim gate + 60s bound — @@ -138,7 +137,6 @@ export const useZeroDev = () => { // signup screen displayed ride in a header the backend ledgers. passkeyServerHeaders: { 'x-accepted-legal': JSON.stringify(signupConsentDocuments()), - ...(signupAttributionHeader ? { 'x-signup-attribution': signupAttributionHeader } : {}), }, rpID: rpId, }) @@ -147,11 +145,22 @@ export const useZeroDev = () => { // Keep the new key recoverable even if the API session cannot load yet. saveToCookie(WEB_AUTHN_COOKIE_KEY, webAuthnKey, 90) + // The ceremony has created the account. This marker lets a native + // restart retry the authenticated attribution attach without + // allowing ordinary returning-user logins to claim the journey. + markSignupAttributionPending() // Bind the ceremony key to the fresh API session, never the render's previous user. // Native cookies may disappear on restart; persist before any RPC-dependent build. const registeredUser = await hydrateLoginSession() updateUserPreferences(registeredUser.user.userId, { webAuthnKey }) + try { + await attachSignupAttribution() + } catch (error) { + // Attribution is best-effort for signup UX. The durable device + // copy remains for the authenticated retry on the next start. + captureException(error, { level: 'warning', tags: { error_type: 'signup_attribution_attach_failed' } }) + } const inviteCodeFromCookie = getFromCookie('inviteCode') diff --git a/src/services/signup-attribution.ts b/src/services/signup-attribution.ts new file mode 100644 index 0000000000..813ef2ae2e --- /dev/null +++ b/src/services/signup-attribution.ts @@ -0,0 +1,42 @@ +import { apiFetch } from '@/utils/api-fetch' +import { + clearSignupAttribution, + hasPendingSignupAttribution, + readSignupAttributionAsync, + serializeSignupAttribution, +} from '@/utils/signup-attribution' + +let attachInFlight: Promise | null = null + +/** + * Deliver source evidence only after the API session exists. The server scopes + * the write to that session's user and makes the finalization idempotent. + * Keeping the context until a 2xx acknowledgement makes this safe to retry + * on the next authenticated app start. + */ +export function attachSignupAttribution(): Promise { + if (!attachInFlight) { + attachInFlight = doAttachSignupAttribution().finally(() => { + attachInFlight = null + }) + } + return attachInFlight +} + +async function doAttachSignupAttribution(): Promise { + if (!(await hasPendingSignupAttribution())) return false + const context = await readSignupAttributionAsync() + if (!context) return false + + const response = await apiFetch('/users/me/signup-attribution', { + method: 'POST', + body: JSON.stringify({ attribution: serializeSignupAttribution(context) }), + redactTelemetry: true, + }) + if (!response.ok) throw new Error(`signup attribution attach failed: ${response.status}`) + + // The API has acknowledged the evidence, including terminal outcomes such + // as expiry or analytics opt-out. Only now may the device copy be removed. + clearSignupAttribution() + return true +} diff --git a/src/utils/__tests__/deferred-link.test.ts b/src/utils/__tests__/deferred-link.test.ts index 6fa187f463..d28745a466 100644 --- a/src/utils/__tests__/deferred-link.test.ts +++ b/src/utils/__tests__/deferred-link.test.ts @@ -12,6 +12,7 @@ import { import { isAndroidNative, isIOSNative } from '../capacitor' import { clipboardHasStrings, clipboardHasProbableWebUrl } from '../clipboard-detect' import { saveToCookie } from '../cookie-url.utils' +import { SIGNUP_ATTRIBUTION_COOKIE } from '../signup-attribution' const getReferrer = jest.fn() @@ -85,6 +86,32 @@ beforeEach(() => { }) describe('buildDeferredPayload / parseDeferredPayload round-trip', () => { + it('uses a compact attribution token within Play referrer limits', () => { + saveToCookie(SIGNUP_ATTRIBUTION_COOKIE, { + schemaVersion: '1', + journeyId: '33333333-3333-4333-8333-333333333333', + platform: 'android', + analyticsState: 'enabled', + captureMethod: 'browser', + firstTouch: { + occurredAt: new Date().toISOString(), + utmSource: 'creator', + utmMedium: 'social', + utmCampaign: 'localized-landing-page', + path: '/es-419/blog/creator-guide', + }, + }) + + const payload = buildDeferredPayload('/home') + expect(payload).toContain('at=') + expect(payload).not.toContain('attribution=') + expect(encodeURIComponent(payload).length).toBeLessThanOrEqual(512) + expect(parseDeferredPayload(payload)?.attribution).toMatchObject({ + journeyId: '33333333-3333-4333-8333-333333333333', + firstTouch: { utmSource: 'creator', utmCampaign: 'localized-landing-page' }, + }) + }) + it('round-trips a full payload including an encoded dest with query', () => { window.history.replaceState({}, '', '/es-419/some-page') saveToCookie('inviteCode', 'abc123') diff --git a/src/utils/__tests__/signup-attribution.test.ts b/src/utils/__tests__/signup-attribution.test.ts index 5b2a3943de..6913d286dd 100644 --- a/src/utils/__tests__/signup-attribution.test.ts +++ b/src/utils/__tests__/signup-attribution.test.ts @@ -1,6 +1,9 @@ import { buildSignupAttributionHeader, captureSignupAttribution, + clearPendingSignupAttribution, + hasPendingSignupAttribution, + markSignupAttributionPending, parseSignupAttribution, readSignupAttribution, restoreSignupAttribution, @@ -13,6 +16,7 @@ const clearAttributionCookie = () => { beforeEach(() => { process.env.NEXT_PUBLIC_CAPACITOR_BUILD = 'false' + clearPendingSignupAttribution() clearAttributionCookie() window.history.replaceState({}, '', '/') Object.defineProperty(document, 'referrer', { configurable: true, value: '' }) @@ -85,4 +89,24 @@ describe('signup attribution context', () => { }) expect(readSignupAttribution()).toEqual(restored) }) + + it('rejects identifier-shaped campaign values at capture time', () => { + window.history.replaceState( + {}, + '', + '/?utm_source=550e8400-e29b-41d4-a716-446655440000&utm_campaign=wallet%40example.com' + ) + const captured = captureSignupAttribution() + expect(captured?.firstTouch).toMatchObject({ path: '/' }) + expect(captured?.firstTouch.utmSource).toBeUndefined() + expect(captured?.firstTouch.utmCampaign).toBeUndefined() + }) + + it('limits authenticated finalization retries to a completed signup marker', async () => { + expect(await hasPendingSignupAttribution()).toBe(false) + markSignupAttributionPending() + expect(await hasPendingSignupAttribution()).toBe(true) + clearPendingSignupAttribution() + expect(await hasPendingSignupAttribution()).toBe(false) + }) }) diff --git a/src/utils/cookie-url.utils.ts b/src/utils/cookie-url.utils.ts index 960e9be240..95d4bcabba 100644 --- a/src/utils/cookie-url.utils.ts +++ b/src/utils/cookie-url.utils.ts @@ -47,7 +47,10 @@ export const saveToCookie = (key: string, data: unknown, expiryDays?: number) => cookieString += `; path=/; SameSite=Lax${isSecure ? '; Secure' : ''}` document.cookie = cookieString - console.log(`Saved ${key} to cookie:`, data) + // Cookie values can contain invite/source context or other account + // metadata. Keep diagnostics useful without copying the value into + // browser logs, replay breadcrumbs, or support screenshots. + console.log(`Saved ${key} to cookie`) } catch (error) { Sentry.captureException(error) console.error('Error saving to cookie:', error) @@ -73,7 +76,7 @@ export const getFromCookie = (key: string) => { const decodedValue = decodeURIComponent(cookieValue) const parsedData = jsonParse(decodedValue) - console.log(`Retrieved ${key} from cookie:`, parsedData) + console.log(`Retrieved ${key} from cookie`) return parsedData } catch (error) { Sentry.captureException(error) diff --git a/src/utils/deferred-link.ts b/src/utils/deferred-link.ts index 618cc0f08c..53b7bb8324 100644 --- a/src/utils/deferred-link.ts +++ b/src/utils/deferred-link.ts @@ -24,9 +24,9 @@ import { } from '@/components/Invites/badge-campaign-context' import { parseSignupAttribution, + persistSignupAttribution, readSignupAttribution, restoreSignupAttribution, - serializeSignupAttribution, signupAttributionPosthogProperties, type SignupAttributionContext, } from './signup-attribution' @@ -34,6 +34,8 @@ import { // marker param distinguishing our payload from Play's organic referrer // (utm_source=google-play&utm_medium=organic) const MARKER = 'pnutdl' +const ATTRIBUTION_PARAM = 'at' +export const MAX_PLAY_REFERRER_LENGTH = 512 export const CONSUMED_KEY = 'deferredLinkConsumed' // the key the in-app i18n reads (src/i18n/app/locale-store.ts — Preferences @@ -64,6 +66,103 @@ export interface DeferredPayload { dest?: string } +type CompactTouch = { + t: number + s?: string + m?: string + c?: string + n?: string + r?: string + p?: string +} + +type CompactAttribution = { + v: 1 + j: string + p: SignupAttributionContext['platform'] + f: CompactTouch + c?: CompactTouch + l?: CompactTouch +} + +function compactTouch(touch: SignupAttributionContext['firstTouch']): CompactTouch { + return { + t: Date.parse(touch.occurredAt), + ...(touch.utmSource ? { s: touch.utmSource } : {}), + ...(touch.utmMedium ? { m: touch.utmMedium } : {}), + ...(touch.utmCampaign ? { c: touch.utmCampaign } : {}), + ...(touch.utmContent ? { n: touch.utmContent } : {}), + ...(touch.referrerHost ? { r: touch.referrerHost } : {}), + ...(touch.path ? { p: touch.path } : {}), + } +} + +function expandTouch(touch: CompactTouch): SignupAttributionContext['firstTouch'] | null { + if (!Number.isFinite(touch.t)) return null + return { + occurredAt: new Date(touch.t).toISOString(), + ...(touch.s ? { utmSource: touch.s } : {}), + ...(touch.m ? { utmMedium: touch.m } : {}), + ...(touch.c ? { utmCampaign: touch.c } : {}), + ...(touch.n ? { utmContent: touch.n } : {}), + ...(touch.r ? { referrerHost: touch.r } : {}), + ...(touch.p ? { path: touch.p } : {}), + } +} + +function base64UrlEncode(value: string): string { + return btoa(value).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '') +} + +function base64UrlDecode(value: string): string { + const padded = value.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - (value.length % 4)) % 4) + return atob(padded) +} + +/** Compact ASCII-only handoff token; Play's encoded referrer is capped at 512 chars. */ +function serializeAttributionHandoff(context: SignupAttributionContext): string { + const first = compactTouch(context.firstTouch) + const compact: CompactAttribution = { + v: 1, + j: context.journeyId, + p: context.platform, + f: first, + ...(context.firstContentTouch && + JSON.stringify(context.firstContentTouch) !== JSON.stringify(context.firstTouch) + ? { c: compactTouch(context.firstContentTouch) } + : {}), + ...(context.lastTouch && JSON.stringify(context.lastTouch) !== JSON.stringify(context.firstTouch) + ? { l: compactTouch(context.lastTouch) } + : {}), + } + return base64UrlEncode(JSON.stringify(compact)) +} + +function parseAttributionHandoff(value: string | null): SignupAttributionContext | null { + if (!value) return null + try { + const compact = JSON.parse(base64UrlDecode(value)) as Partial + if (compact.v !== 1 || typeof compact.j !== 'string' || !compact.f) return null + const firstTouch = expandTouch(compact.f) + if (!firstTouch) return null + const firstContentTouch = compact.c ? expandTouch(compact.c) : null + const lastTouch = compact.l ? expandTouch(compact.l) : null + const context: SignupAttributionContext = { + schemaVersion: '1', + journeyId: compact.j, + platform: compact.p ?? 'unknown', + analyticsState: 'enabled', + captureMethod: 'deferred_link', + firstTouch, + ...(firstContentTouch ? { firstContentTouch } : {}), + ...(lastTouch ? { lastTouch } : {}), + } + return parseSignupAttribution(JSON.stringify(context)) + } catch { + return null + } +} + // app-local android plugin (InstallReferrerPlugin.java); absent on iOS/web and // on older binaries running OTA'd JS, which the capability turns into null. const InstallReferrer = nativeCapability<{ getReferrer(options?: undefined): Promise<{ referrer: string | null }> }>( @@ -116,8 +215,8 @@ export function buildDeferredPayload(dest?: string, invite?: string): string { params.append(BADGE_CAMPAIGN_QUERY_PARAM, badgeCampaign) } - const attribution = serializeSignupAttribution() - if (attribution) params.set('attribution', attribution) + const attribution = readSignupAttribution() + if (attribution) params.set(ATTRIBUTION_PARAM, serializeAttributionHandoff(attribution)) // a page whose url carries the claim secret in the fragment (#p=) must not // ride as a default dest: the fragment never rides (by design), so the @@ -127,12 +226,27 @@ export function buildDeferredPayload(dest?: string, invite?: string): string { const destination = dest ?? stripLocalePrefix(window.location.pathname) + window.location.search if (destination && destination !== '/' && !secretOnPage) params.set('dest', destination) - return params.toString() + if (encodeURIComponent(params.toString()).length > MAX_PLAY_REFERRER_LENGTH) { + // Destination and badge identities are useful but optional. Never drop + // source attribution; if this still exceeds Play's limit, the caller + // uses the bare store URL instead of a broken handoff. + params.delete('dest') + params.delete('badge_campaign') + params.delete('badgeCampaign') + } + + const result = params.toString() + if (encodeURIComponent(result).length > MAX_PLAY_REFERRER_LENGTH) { + throw new Error('deferred attribution exceeds Play referrer limit') + } + return result } /** play store listing url with the payload riding the install referrer. */ export function playStoreUrlWithReferrer(payload: string): string { - return `${PLAY_STORE_URL}&referrer=${encodeURIComponent(payload)}` + const encoded = encodeURIComponent(payload) + if (encoded.length > MAX_PLAY_REFERRER_LENGTH) throw new Error('deferred attribution exceeds Play referrer limit') + return `${PLAY_STORE_URL}&referrer=${encoded}` } /** @@ -171,7 +285,8 @@ export function parseDeferredPayload(raw: string): DeferredPayload | null { if (params.get(MARKER) !== '1') return null const pick = (key: string) => params.get(key) || undefined const badgeCampaigns = badgeCampaignIdentitiesFromDeferredSearchParams(params) - const attribution = parseSignupAttribution(params.get('attribution')) + const attribution = + parseAttributionHandoff(params.get(ATTRIBUTION_PARAM)) ?? parseSignupAttribution(params.get('attribution')) return { lang: pick('lang'), invite: pick('invite'), @@ -322,6 +437,10 @@ async function doRestore(): Promise { } const restored = applyDeferredPayload(payload) + if (payload.attribution) { + const persisted = readSignupAttribution() + if (persisted) await persistSignupAttribution(persisted) + } const restoreFields: Record = { has_dest: !!restored.dest, has_locale: !!restored.locale, diff --git a/src/utils/signup-attribution.ts b/src/utils/signup-attribution.ts index 89597815a2..8f66475178 100644 --- a/src/utils/signup-attribution.ts +++ b/src/utils/signup-attribution.ts @@ -1,11 +1,21 @@ import { isMarketingRoute } from './marketing-routes' import { getFromCookie, saveToCookie } from './cookie-url.utils' +import posthog from 'posthog-js' export const SIGNUP_ATTRIBUTION_COOKIE = 'signupAttribution' export const SIGNUP_ATTRIBUTION_SCHEMA_VERSION = '1' const SIGNUP_ATTRIBUTION_EXPIRY_DAYS = 90 const MAX_VALUE_LENGTH = 128 const MAX_PATH_LENGTH = 512 +const NATIVE_STORAGE_KEY = 'signup-attribution' +const PENDING_SIGNUP_KEY = 'signup-attribution-pending' +const MAX_TOUCH_AGE_MS = 180 * 24 * 60 * 60 * 1000 +const MAX_TOUCH_FUTURE_MS = 24 * 60 * 60 * 1000 +const MARKETING_VALUE_PATTERN = /^[A-Za-z0-9][-A-Za-z0-9 ._~+]*$/ +const UUID_VALUE_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i +const WALLET_VALUE_PATTERN = /^0x[0-9a-f]{40}$/i +const PUBLIC_PATH_PATTERN = + /^\/(?:$|(?:[a-z]{2}(?:-[a-z0-9]{2,8})?\/)?(?:blog|content|help|faq|how-it-works|pay-with|send-money-to|receive-money-from)(?:\/[a-z0-9][a-z0-9/_-]*)?|(?:signup|setup|home))$/i export type SignupAttributionPlatform = 'web' | 'ios' | 'android' | 'unknown' export type SignupAttributionCaptureMethod = 'browser' | 'deferred_link' @@ -37,9 +47,24 @@ function cleanValue(value: string | null | undefined, maxLength = MAX_VALUE_LENG return cleaned.length > 0 && cleaned.length <= maxLength ? cleaned : undefined } +function cleanMarketingValue(value: string | null | undefined): string | undefined { + const cleaned = cleanValue(value) + if ( + !cleaned || + !MARKETING_VALUE_PATTERN.test(cleaned) || + cleaned.includes('@') || + cleaned.includes('://') || + cleaned.toLowerCase().startsWith('www.') || + UUID_VALUE_PATTERN.test(cleaned) || + WALLET_VALUE_PATTERN.test(cleaned) + ) + return undefined + return cleaned +} + function cleanPath(path: string): string | undefined { const clean = cleanValue(path, MAX_PATH_LENGTH)?.split(/[?#]/, 1)[0] - return clean?.startsWith('/') && !clean.startsWith('//') ? clean : undefined + return clean && PUBLIC_PATH_PATTERN.test(clean) && !clean.includes('..') && !clean.includes('%') ? clean : undefined } function newJourneyId(): string { @@ -91,10 +116,10 @@ function currentTouch(): SignupAttributionTouch | null { return { occurredAt: new Date().toISOString(), - utmSource: cleanValue(url.searchParams.get('utm_source')), - utmMedium: cleanValue(url.searchParams.get('utm_medium')), - utmCampaign: cleanValue(url.searchParams.get('utm_campaign')), - utmContent: cleanValue(url.searchParams.get('utm_content')), + utmSource: cleanMarketingValue(url.searchParams.get('utm_source')), + utmMedium: cleanMarketingValue(url.searchParams.get('utm_medium')), + utmCampaign: cleanMarketingValue(url.searchParams.get('utm_campaign')), + utmContent: cleanMarketingValue(url.searchParams.get('utm_content')), referrerHost, path: cleanPath(url.pathname), } @@ -103,9 +128,23 @@ function currentTouch(): SignupAttributionTouch | null { function isTouch(value: unknown): value is SignupAttributionTouch { if (!value || typeof value !== 'object' || Array.isArray(value)) return false const touch = value as Partial - return ( - typeof touch.occurredAt === 'string' && - new Date(touch.occurredAt).getTime() === new Date(touch.occurredAt).getTime() + const occurredAt = new Date(touch.occurredAt ?? '').getTime() + const now = Date.now() + if ( + !Number.isFinite(occurredAt) || + occurredAt < now - MAX_TOUCH_AGE_MS || + occurredAt > now + MAX_TOUCH_FUTURE_MS || + (touch.utmSource !== undefined && !cleanMarketingValue(touch.utmSource)) || + (touch.utmMedium !== undefined && !cleanMarketingValue(touch.utmMedium)) || + (touch.utmCampaign !== undefined && !cleanMarketingValue(touch.utmCampaign)) || + (touch.utmContent !== undefined && !cleanMarketingValue(touch.utmContent)) || + (touch.referrerHost !== undefined && + !/^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?(?::\d{1,5})?$/i.test(touch.referrerHost)) || + (touch.path !== undefined && !cleanPath(touch.path)) + ) + return false + return Object.keys(touch).every((key) => + ['occurredAt', 'utmSource', 'utmMedium', 'utmCampaign', 'utmContent', 'referrerHost', 'path'].includes(key) ) } @@ -129,11 +168,16 @@ function isContext(value: unknown): value is SignupAttributionContext { } export function readSignupAttribution(): SignupAttributionContext | null { + if (!analyticsCollectionAvailable()) { + clearSignupAttribution() + return null + } const stored = getFromCookie(SIGNUP_ATTRIBUTION_COOKIE) return isContext(stored) ? stored : null } export function captureSignupAttribution(): SignupAttributionContext | null { + if (!analyticsCollectionAvailable()) return null const touch = currentTouch() if (!touch) return null @@ -162,6 +206,7 @@ export function captureSignupAttribution(): SignupAttributionContext | null { } export function restoreSignupAttribution(context: SignupAttributionContext): SignupAttributionContext { + if (!analyticsCollectionAvailable()) return context const existing = readSignupAttribution() const restored: SignupAttributionContext = { ...context, @@ -170,11 +215,92 @@ export function restoreSignupAttribution(context: SignupAttributionContext): Sig lastTouch: context.lastTouch ?? existing?.lastTouch, } saveToCookie(SIGNUP_ATTRIBUTION_COOKIE, restored, SIGNUP_ATTRIBUTION_EXPIRY_DAYS) + void persistNativeSignupAttribution(restored) return restored } export function clearSignupAttribution(): void { - saveToCookie(SIGNUP_ATTRIBUTION_COOKIE, '') + saveToCookie(SIGNUP_ATTRIBUTION_COOKIE, '', -1) + clearPendingSignupAttribution() + void import('@capacitor/preferences') + .then(({ Preferences }) => Preferences.remove({ key: NATIVE_STORAGE_KEY })) + .catch(() => {}) +} + +/** Mark a completed passkey ceremony as eligible for authenticated attribution attach. */ +export function markSignupAttributionPending(): void { + try { + localStorage.setItem(PENDING_SIGNUP_KEY, '1') + } catch {} + if (process.env.NEXT_PUBLIC_CAPACITOR_BUILD === 'true') { + void import('@capacitor/preferences') + .then(({ Preferences }) => Preferences.set({ key: PENDING_SIGNUP_KEY, value: '1' })) + .catch(() => {}) + } +} + +export function clearPendingSignupAttribution(): void { + try { + localStorage.removeItem(PENDING_SIGNUP_KEY) + } catch {} + if (process.env.NEXT_PUBLIC_CAPACITOR_BUILD === 'true') { + void import('@capacitor/preferences') + .then(({ Preferences }) => Preferences.remove({ key: PENDING_SIGNUP_KEY })) + .catch(() => {}) + } +} + +/** Only a client that just completed registration may finalize a journey. */ +export async function hasPendingSignupAttribution(): Promise { + try { + if (localStorage.getItem(PENDING_SIGNUP_KEY) === '1') return true + } catch {} + if (process.env.NEXT_PUBLIC_CAPACITOR_BUILD !== 'true') return false + try { + const { Preferences } = await import('@capacitor/preferences') + return (await Preferences.get({ key: PENDING_SIGNUP_KEY })).value === '1' + } catch { + return false + } +} + +function analyticsCollectionAvailable(): boolean { + // Jest exercises the capture contract without a public build key. + if (process.env.NODE_ENV === 'test') return true + if (!process.env.NEXT_PUBLIC_POSTHOG_KEY) return false + try { + return typeof posthog.has_opted_out_capturing !== 'function' || !posthog.has_opted_out_capturing() + } catch { + return false + } +} + +async function persistNativeSignupAttribution(context: SignupAttributionContext): Promise { + if (process.env.NEXT_PUBLIC_CAPACITOR_BUILD !== 'true') return + try { + const { Preferences } = await import('@capacitor/preferences') + await Preferences.set({ key: NATIVE_STORAGE_KEY, value: JSON.stringify(context) }) + } catch {} +} + +/** Persist the context before a native app can be killed between restore and signup. */ +export async function persistSignupAttribution(context: SignupAttributionContext): Promise { + saveToCookie(SIGNUP_ATTRIBUTION_COOKIE, context, SIGNUP_ATTRIBUTION_EXPIRY_DAYS) + await persistNativeSignupAttribution(context) +} + +/** Cookie first, then native Preferences for WebView restarts. */ +export async function readSignupAttributionAsync(): Promise { + const cookieContext = readSignupAttribution() + if (cookieContext) return cookieContext + if (process.env.NEXT_PUBLIC_CAPACITOR_BUILD !== 'true' || !analyticsCollectionAvailable()) return null + try { + const { Preferences } = await import('@capacitor/preferences') + const stored = await Preferences.get({ key: NATIVE_STORAGE_KEY }) + return parseSignupAttribution(stored.value) + } catch { + return null + } } export function serializeSignupAttribution( From 34230e23a8c0997480a835590f8dc3e575287cd2 Mon Sep 17 00:00:00 2001 From: innolope-dev Date: Thu, 17 Sep 2026 17:20:14 +0100 Subject: [PATCH 03/19] chore: apply CI formatting --- src/components/Setup/Views/SignTestTransaction.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/Setup/Views/SignTestTransaction.tsx b/src/components/Setup/Views/SignTestTransaction.tsx index 2a2a3f56b3..cfb14a5d96 100644 --- a/src/components/Setup/Views/SignTestTransaction.tsx +++ b/src/components/Setup/Views/SignTestTransaction.tsx @@ -314,7 +314,7 @@ const SignTestTransaction = () => {
-

+

{t('steps.sign-test-transaction.description')}

{displayError && {displayError}} @@ -331,7 +331,7 @@ const SignTestTransaction = () => {
{/* In-app explainer instead of a browser redirect — leaving the app mid-signup loses users (full guide inside). */} -

+

setIsPasskeyInfoOpen(true)}>{t('passkey.learnMore')}

@@ -347,7 +347,7 @@ export const PasskeyDocsLink = ({ className }: { className?: string }) => {

{t('passkey.learnMore')} From e490f7c1df7edd109782b18801dc46415ec14813 Mon Sep 17 00:00:00 2001 From: innolope-dev Date: Thu, 17 Sep 2026 18:14:04 +0100 Subject: [PATCH 04/19] Open signup while preserving referral attribution --- .../__tests__/layout-backend-error.test.tsx | 14 +- .../dev/ds/audit/app/audit-app-data.ts | 2 - .../(mobile-ui)/dev/ds/audit/audit-data.ts | 7 +- src/app/(mobile-ui)/layout.tsx | 6 - src/app/(setup)/setup/__tests__/page.test.tsx | 9 + src/app/(setup)/setup/page.tsx | 194 +++----- src/app/m/[slug]/merchants.ts | 6 +- .../Claim/Link/SendLinkActionList.tsx | 15 +- .../__tests__/Initial.view.autoClaim.test.tsx | 15 +- .../Claim/Link/useInitialClaimFlow.ts | 50 +-- .../Global/EarlyUserDrawer/index.tsx | 2 +- .../Global/InviteFriendsDrawer/index.tsx | 5 +- src/components/Invites/InvitesPage.test.tsx | 10 +- src/components/Invites/InvitesPage.tsx | 9 +- .../Invites/JoinWaitlistPage.test.tsx | 144 ------ src/components/Invites/JoinWaitlistPage.tsx | 418 ------------------ src/components/Jobs/Careers.tsx | 3 +- .../Profile/components/PublicProfile.tsx | 30 +- .../__tests__/PublicProfile.test.tsx | 10 + src/components/Setup/Setup.consts.tsx | 10 - src/components/Setup/Views/JoinWaitlist.tsx | 168 ------- src/components/Setup/Views/Landing.tsx | 2 +- .../Setup/__tests__/setup-entry.test.ts | 2 +- src/components/Setup/setup-entry.ts | 11 +- src/constants/analytics.consts.ts | 4 - .../__tests__/authContext-logout.test.tsx | 3 + src/context/authContext.tsx | 51 ++- src/dev/screens/catalogue.test.ts | 3 +- src/dev/screens/catalogue.ts | 1 - src/dev/surfaces/list.ts | 1 - src/dev/surfaces/registry.tsx | 4 - src/features/home/components/HomeModals.tsx | 52 +-- .../setup/__tests__/useSetupFlow.test.tsx | 8 +- src/hooks/useSetupFlow.ts | 2 +- src/hooks/useZeroDev.ts | 111 +---- src/i18n/app/__tests__/messages.test.ts | 3 +- src/i18n/app/messages/en.json | 57 +-- src/i18n/app/messages/es-419.json | 57 +-- src/i18n/app/messages/es-AR.json | 42 +- src/i18n/app/messages/pt-BR.json | 57 +-- .../pending-invite-attribution.test.ts | 124 ++++++ src/services/invites.ts | 18 - src/services/pending-invite-attribution.ts | 96 ++++ src/utils/general.utils.ts | 4 +- src/utils/invite-code.utils.ts | 4 +- 45 files changed, 487 insertions(+), 1357 deletions(-) delete mode 100644 src/components/Invites/JoinWaitlistPage.test.tsx delete mode 100644 src/components/Invites/JoinWaitlistPage.tsx delete mode 100644 src/components/Setup/Views/JoinWaitlist.tsx create mode 100644 src/services/__tests__/pending-invite-attribution.test.ts create mode 100644 src/services/pending-invite-attribution.ts diff --git a/src/app/(mobile-ui)/__tests__/layout-backend-error.test.tsx b/src/app/(mobile-ui)/__tests__/layout-backend-error.test.tsx index e2d791fdb3..8813e1de2a 100644 --- a/src/app/(mobile-ui)/__tests__/layout-backend-error.test.tsx +++ b/src/app/(mobile-ui)/__tests__/layout-backend-error.test.tsx @@ -57,7 +57,6 @@ jest.mock('@/components/Global/SupportDrawer', () => ({ __esModule: true, defaul jest.mock('@/components/Global/SupportDeepLink', () => ({ __esModule: true, default: () =>

})) jest.mock('@/components/Global/QRScannerOverlay', () => ({ __esModule: true, default: () =>
})) jest.mock('@/components/Global/SecurityVerificationOverlay', () => ({ __esModule: true, default: () =>
})) -jest.mock('@/components/Invites/JoinWaitlistPage', () => ({ __esModule: true, default: () =>
})) jest.mock('@/components/Migration/SunsetScreen', () => ({ __esModule: true, default: () =>
})) import Layout from '../layout' @@ -286,4 +285,17 @@ describe('(mobile-ui) layout — no user', () => { expect(screen.getByTestId('app-shell')).toBeInTheDocument() expect(mockRouterReplace).not.toHaveBeenCalled() }) + + it('does not render the retired app waitlist for a legacy false access flag', () => { + mockUseAuth.mockReturnValue( + authState({ + user: { user: { ...CACHED_USER.user, hasAppAccess: false }, accounts: [] }, + }) + ) + + renderLayout() + + expect(screen.getByTestId('app-shell')).toBeInTheDocument() + expect(mockRouterReplace).not.toHaveBeenCalled() + }) }) diff --git a/src/app/(mobile-ui)/dev/ds/audit/app/audit-app-data.ts b/src/app/(mobile-ui)/dev/ds/audit/app/audit-app-data.ts index f06b937ee3..f7fe0aca7d 100644 --- a/src/app/(mobile-ui)/dev/ds/audit/app/audit-app-data.ts +++ b/src/app/(mobile-ui)/dev/ds/audit/app/audit-app-data.ts @@ -171,7 +171,6 @@ export const APP_DIVERGENCE_CATEGORIES: UsageCategory[] = [ 'components/AddWithdraw/DynamicBankAccountForm.tsx', 'components/SearchInput/index.tsx', 'components/Profile/components/ProfileEditField.tsx', - 'components/Invites/JoinWaitlistPage.tsx', 'components/LandingPage/CurrencySelect.tsx', ], }, @@ -188,7 +187,6 @@ export const APP_DIVERGENCE_CATEGORIES: UsageCategory[] = [ 'components/Send/views/SendRouter.view.tsx', 'components/Global/TokenSelector/TokenSelector.tsx', 'components/Common/SavedAccountsView.tsx', - 'components/Setup/Views/JoinWaitlist.tsx', ], }, { diff --git a/src/app/(mobile-ui)/dev/ds/audit/audit-data.ts b/src/app/(mobile-ui)/dev/ds/audit/audit-data.ts index a769c335f0..bdb260b5d7 100644 --- a/src/app/(mobile-ui)/dev/ds/audit/audit-data.ts +++ b/src/app/(mobile-ui)/dev/ds/audit/audit-data.ts @@ -158,7 +158,7 @@ export const AUDIT_ITEMS: AuditItem[] = [ catLabel: 'Colour tokens', layer: 'tokens', role: 'info-blue', - usages: 3, + usages: 2, status: 'variant', source: 'src/styles/globals.css:39', notes: 'Periwinkle/info blue. Same hex as --color-background-icon-bubble-blue and --color-avatar-blue-border. Also re-typed raw #90A8ED 9x.', @@ -3592,8 +3592,8 @@ export const AUDIT_ITEMS: AuditItem[] = [ role: 'Stack-children-on-page using space-y-* instead of gap-* on the outer flex div.', usages: 3, status: 'adhoc', - source: 'src/components/Setup/components/SetupWrapper.tsx:280; src/components/Invites/InvitesPage.tsx:340; src/components/Invites/JoinWaitlistPage.tsx:300', - notes: "Was 49 violations, now 3 — the biggest single cleanup in this category. The 3 survivors are all the same md:space-y-4 desktop tweak on a justify-between shell, not the old 'flex h-full flex-col justify-center space-y-4' recipe (0 left).", + source: 'src/components/Setup/components/SetupWrapper.tsx:280; src/components/Invites/InvitesPage.tsx:340', + notes: "Was 49 violations, now 2 — the biggest single cleanup in this category. The 2 survivors are the same md:space-y-4 desktop tweak on a justify-between shell, not the old 'flex h-full flex-col justify-center space-y-4' recipe (0 left).", }, { name: 'Inline outer-shell recipe: h-full base instead of min-h-inherit', @@ -6459,7 +6459,6 @@ export const AUDIT_CLUSTERS: AuditCluster[] = [ 'src/components/Claim/Link/MantecaFlowManager.tsx (step enum)', 'src/components/Card/CardPinSetupFlow.tsx (step state)', 'src/app/(mobile-ui)/card-recovery/page.tsx (step state)', - 'src/components/Invites/JoinWaitlistPage.tsx (step state)', ], }, { diff --git a/src/app/(mobile-ui)/layout.tsx b/src/app/(mobile-ui)/layout.tsx index 2a7ff530aa..77e6d6b0d9 100644 --- a/src/app/(mobile-ui)/layout.tsx +++ b/src/app/(mobile-ui)/layout.tsx @@ -16,7 +16,6 @@ import QRScannerOverlay from '@/components/Global/QRScannerOverlay' import SecurityVerificationOverlay from '@/components/Global/SecurityVerificationOverlay' import SupportDeepLink from '@/components/Global/SupportDeepLink' import SupportDrawer from '@/components/Global/SupportDrawer' -import JoinWaitlistPage from '@/components/Invites/JoinWaitlistPage' import { useRouter } from 'next/navigation' import { NavHeaderPresenceProvider } from '@/components/Global/Banner/navHeaderPresence' import { ShellBannerFallback } from '@/components/Global/Banner/ShellBannerFallback' @@ -182,11 +181,6 @@ const Layout = ({ children }: { children: React.ReactNode }) => { return } - // Show waitlist page if user doesn't have app access - if (!isFetchingUser && user && !user?.user.hasAppAccess && !isPublicPath) { - return - } - return ( { + mockAuth.user = { user: { username: 'peanutter', hasAppAccess: false } } + + renderWithIntl() + + expect(mockRouter.replace).toHaveBeenCalledWith('/home') + expect(screen.getByRole('status')).toBeInTheDocument() +}) + it.each([true, false])('preserves the resolved entry flow (native=%s)', async (native) => { mockNative = native Object.defineProperty(window, 'PublicKeyCredential', { diff --git a/src/app/(setup)/setup/page.tsx b/src/app/(setup)/setup/page.tsx index 8227d69500..98d8c58f45 100644 --- a/src/app/(setup)/setup/page.tsx +++ b/src/app/(setup)/setup/page.tsx @@ -59,7 +59,7 @@ function SetupPageContent() { const t = useTranslations('setup') const tCommon = useTranslations('common') const { setIsSupportModalOpen } = useModalsContext() - const { steps, resetSetupFlow, setNoBackLockScreenId, setSignupEntryFlow } = useSetupFlowContext() + const { steps, setNoBackLockScreenId, setSignupEntryFlow } = useSetupFlowContext() const { step, currentIndex: currentStepIndex, direction, handleNext, handleBack, setScreenId } = useSetupFlow() const { logoutUser, isLoggingOut, user, isFetchingUser, fetchUser } = useAuth() const router = useRouter() @@ -97,7 +97,6 @@ function SetupPageContent() { [searchParamsString] ) const [sessionChecked, setSessionChecked] = useState(false) - const [existingSessionUsername, setExistingSessionUsername] = useState(null) /* * A completed session is on its way to /home (see the session effect * below), but the soft nav takes a beat and this page keeps rendering and @@ -129,12 +128,7 @@ function SetupPageContent() { const recoveryReason = isLeavingForHome ? null : (initializationError ?? - (!isLoading && - sessionChecked && - !step && - !existingSessionUsername && - !showDeviceNotSupportedModal && - !showBrowserNotSupportedModal + (!isLoading && sessionChecked && !step && !showDeviceNotSupportedModal && !showBrowserNotSupportedModal ? 'missing_step' : null)) @@ -164,21 +158,19 @@ function SetupPageContent() { return () => clearTimeout(timeout) }, [isLoading, sessionChecked, initializationError, isLeavingForHome]) - // only count steps that actually render: not while the entry step is - // being determined, and not behind the existing-session interstitial - // or the unsupported-device/browser modals + // Only count steps that actually render, not while the entry step is being + // determined or behind unsupported-device/browser modals. const stepRendered = !!step && !recoveryReason && !isLoading && sessionChecked && - !existingSessionUsername && !showDeviceNotSupportedModal && !showBrowserNotSupportedModal // Arm the point of no return only for a step the user actually SEES — - // stepRendered excludes entry-resolution loading, the existing-session - // interstitial, and the unsupported modals. A stale terminal URL + // stepRendered excludes entry-resolution loading and unsupported modals. + // A stale terminal URL // (?screen=sign-test-transaction in a fresh session) must stay unlockable // so the entry resolver can replace it (Chip review round 2). useEffect(() => { @@ -225,82 +217,73 @@ function SetupPageContent() { : getPendingBadgeCampaigns() if (user?.user?.username) { - /* - * A COMPLETED session (hasAppAccess) that lands back on /setup — e.g. a - * native cold start that restored this route — goes straight home; the - * interstitial is reserved for the half-finished-signup case it was - * written for (durable credentials, setup never completed). - */ - if (user.user.hasAppAccess) { - const nativeClaimDeepLinkGeneration = getDeepLinkGeneration() - const nativeClaimCampaignsKey = pendingBadgeCampaigns.join('\u0000') - const shouldSettleNativeBadgeCampaigns = - isCapacitor() && - pendingBadgeCampaigns.length > 0 && - (nativeClaimCampaignsKeyRef.current !== nativeClaimCampaignsKey || - nativeClaimGenerationRef.current !== nativeClaimDeepLinkGeneration) - if (shouldSettleNativeBadgeCampaigns) { - const nativeClaimRunId = nativeClaimRunIdRef.current + 1 - nativeClaimRunIdRef.current = nativeClaimRunId - nativeClaimCampaignsKeyRef.current = nativeClaimCampaignsKey - nativeClaimGenerationRef.current = nativeClaimDeepLinkGeneration - const isCurrentNativeClaim = () => - isSetupMountedRef.current && - nativeClaimRunIdRef.current === nativeClaimRunId && - getDeepLinkGeneration() === nativeClaimDeepLinkGeneration && - currentBadgeCampaignsKeyRef.current === urlBadgeCampaigns.join('\u0000') - setIsSettlingNativeBadgeCampaigns(true) - void claimAndSettlePendingBadgeCampaigns(pendingBadgeCampaigns) - .then(async (batch) => { - if (!isCurrentNativeClaim()) return - - const hasConfirmedClaim = batch.claims.some( - ({ outcome }) => outcome === 'awarded' || outcome === 'already_owned' - ) - if (hasConfirmedClaim) { - try { - await fetchUser() - if (!isCurrentNativeClaim()) return - } catch (error) { - Sentry.captureException(error, { - tags: { error_type: 'native_campaign_profile_refresh_failed' }, - }) - } - } - }) - .catch((error) => { - if (!isCurrentNativeClaim()) return - Sentry.captureException(error, { tags: { error_type: 'native_campaign_claim_failed' } }) - }) - .finally(() => { - if (isCurrentNativeClaim()) { - setIsSettlingNativeBadgeCampaigns(false) - router.replace('/home') - } else if (isSetupMountedRef.current && nativeClaimRunIdRef.current === nativeClaimRunId) { - // A newer native link may keep this setup instance - // mounted while Next transitions to its new URL. - // Release the old loader until the latest URL's - // effect starts its replacement settlement. - setIsSettlingNativeBadgeCampaigns(false) + // Signup is open to every authenticated account. A stale legacy + // hasAppAccess=false profile must not resurrect the retired + // waitlist or offer to replace an already-created account. + const nativeClaimDeepLinkGeneration = getDeepLinkGeneration() + const nativeClaimCampaignsKey = pendingBadgeCampaigns.join('\u0000') + const shouldSettleNativeBadgeCampaigns = + isCapacitor() && + pendingBadgeCampaigns.length > 0 && + (nativeClaimCampaignsKeyRef.current !== nativeClaimCampaignsKey || + nativeClaimGenerationRef.current !== nativeClaimDeepLinkGeneration) + if (shouldSettleNativeBadgeCampaigns) { + const nativeClaimRunId = nativeClaimRunIdRef.current + 1 + nativeClaimRunIdRef.current = nativeClaimRunId + nativeClaimCampaignsKeyRef.current = nativeClaimCampaignsKey + nativeClaimGenerationRef.current = nativeClaimDeepLinkGeneration + const isCurrentNativeClaim = () => + isSetupMountedRef.current && + nativeClaimRunIdRef.current === nativeClaimRunId && + getDeepLinkGeneration() === nativeClaimDeepLinkGeneration && + currentBadgeCampaignsKeyRef.current === urlBadgeCampaigns.join('\u0000') + setIsSettlingNativeBadgeCampaigns(true) + void claimAndSettlePendingBadgeCampaigns(pendingBadgeCampaigns) + .then(async (batch) => { + if (!isCurrentNativeClaim()) return + + const hasConfirmedClaim = batch.claims.some( + ({ outcome }) => outcome === 'awarded' || outcome === 'already_owned' + ) + if (hasConfirmedClaim) { + try { + await fetchUser() + if (!isCurrentNativeClaim()) return + } catch (error) { + Sentry.captureException(error, { + tags: { error_type: 'native_campaign_profile_refresh_failed' }, + }) } - }) - return - } - if (isNewSetupDeepLink) { - setIsSettlingNativeBadgeCampaigns(false) - router.replace('/home') - return - } - if (!isInitialSessionCheck) return - posthog.capture(ANALYTICS_EVENTS.SIGNUP_EXISTING_SESSION_CONTINUED, { auto: true }) - setIsLeavingForHome(true) + } + }) + .catch((error) => { + if (!isCurrentNativeClaim()) return + Sentry.captureException(error, { tags: { error_type: 'native_campaign_claim_failed' } }) + }) + .finally(() => { + if (isCurrentNativeClaim()) { + setIsSettlingNativeBadgeCampaigns(false) + router.replace('/home') + } else if (isSetupMountedRef.current && nativeClaimRunIdRef.current === nativeClaimRunId) { + // A newer native link may keep this setup instance + // mounted while Next transitions to its new URL. + // Release the old loader until the latest URL's + // effect starts its replacement settlement. + setIsSettlingNativeBadgeCampaigns(false) + } + }) + return + } + if (isNewSetupDeepLink) { + setIsSettlingNativeBadgeCampaigns(false) router.replace('/home') return } - setExistingSessionUsername(user.user.username) - posthog.capture(ANALYTICS_EVENTS.SIGNUP_EXISTING_SESSION_PROMPTED, { - has_app_access: !!user.user.hasAppAccess, - }) + if (!isInitialSessionCheck) return + posthog.capture(ANALYTICS_EVENTS.SIGNUP_EXISTING_SESSION_CONTINUED, { auto: true }) + setIsLeavingForHome(true) + router.replace('/home') + return } }, [ sessionChecked, @@ -313,19 +296,6 @@ function SetupPageContent() { searchParamsString, ]) - const handleContinueSession = () => { - posthog.capture(ANALYTICS_EVENTS.SIGNUP_EXISTING_SESSION_CONTINUED) - router.push('/home') - } - - const handleStartFresh = async () => { - posthog.capture(ANALYTICS_EVENTS.SIGNUP_EXISTING_SESSION_LOGGED_OUT) - await logoutUser() - // the setup provider stays mounted through this logout — clear the typed state - resetSetupFlow() - setExistingSessionUsername(null) - } - useEffect(() => { let cancelled = false const isObsolete = () => cancelled || initializationExpired.current @@ -342,8 +312,8 @@ function SetupPageContent() { await new Promise((resolve) => setTimeout(resolve, 100)) // ensure other initializations can complete if (isObsolete()) return - // The entry-step rules (invite code / ?step=signup skipping the invite - // gate, ?step=login, a known device going to Log In) live in + // The entry-step rules (invite code / ?step=signup opening the form + // directly, ?step=login, a known device going to Log In) live in // resolveSetupEntryStep. After authentication, useZeroDev submits the // queued opaque campaign list to the canonical claim service; the step // decision never interprets that cookie. @@ -500,28 +470,6 @@ function SetupPageContent() {
) - if (existingSessionUsername) { - return ( - -
- - -
-
- ) - } - if (showBrowserNotSupportedModal || showDeviceNotSupportedModal) { return } diff --git a/src/app/m/[slug]/merchants.ts b/src/app/m/[slug]/merchants.ts index 314d030392..6a9c067809 100644 --- a/src/app/m/[slug]/merchants.ts +++ b/src/app/m/[slug]/merchants.ts @@ -134,7 +134,7 @@ export const MERCHANTS: Record = { { name: 'Avocado Toast', priceARS: ARS(11500) }, ], }, - install: { sub: '60 seconds. Skip the waitlist. Free coffee.' }, + install: { sub: '60 seconds. Create your wallet. Free coffee.' }, branding: { logoSrc: '/merchants/stain/profile.jpg' }, polaroids: [ { @@ -161,7 +161,7 @@ export const MERCHANTS: Record = { metaDescription: 'Free $10 on your first Mercado Pago payment in Buenos Aires. No DNI, no Argentine bank account needed. For the BA Digital Nomads community.', heading: 'stop hunting\nfor cash.', - sub: 'Free $10 on your first payment. Skip the waitlist.', + sub: 'Free $10 on your first payment. Open to everyone.', body: 'Pay any Mercado Pago QR in Buenos Aires at the best rate available. No Argentine bank account, no cash hunt, no DNI. Fund with USDC or a bank transfer. Built for nomads, by nomads.', dealLabel: 'on us', primaryCta: 'GET PEANUT', @@ -221,7 +221,7 @@ export const MERCHANTS: Record = { }, ], }, - install: { sub: '60 seconds. Skip the waitlist. $10 on us.' }, + install: { sub: '60 seconds. Create your wallet. $10 on us.' }, ambassador: { kicker: '↓ FOR COMMUNITY ORGANISERS', heading: 'Bring peanut to your nomad community.', diff --git a/src/components/Claim/Link/SendLinkActionList.tsx b/src/components/Claim/Link/SendLinkActionList.tsx index 044b3efb23..33a16296cc 100644 --- a/src/components/Claim/Link/SendLinkActionList.tsx +++ b/src/components/Claim/Link/SendLinkActionList.tsx @@ -193,17 +193,15 @@ export default function SendLinkActionList({ } const handleContinueWithPeanut = () => { - // migration window: web signups are closed — hand the guest to the - // app stores instead (QR modal on desktop, store link on mobile). - // the sender's invite code rides the deferred hand-off explicitly — - // no cookie is written until /invite, which a guest never reaches. - // dest defaults to this claim path (the #p= secret never rides). + // The sender's username rides the store/web signup hand-off so the + // authenticated retry can record referral rewards without an input + // screen. The claim secret itself never rides in the hand-off. const rawUsername = claimLinkData?.sender?.username const guestInvite = isInviteLink && rawUsername ? toInviteCode(rawUsername) : undefined if (!isLoggedIn && interceptGuestCta({ invite: guestInvite })) return addParamStep('claim') const redirectUri = encodeURIComponent(window.location.pathname + window.location.search + window.location.hash) - if (isInviteLink && !userHasAppAccess && rawUsername) { + if (isInviteLink && !isLoggedIn && rawUsername) { const inviteCode = toInviteCode(rawUsername) stashInvite(inviteCode, EInviteType.PAYMENT_LINK) router.push(inviteFlowUrl(inviteCode, redirectUri)) @@ -213,7 +211,6 @@ export default function SendLinkActionList({ } const username = claimLinkData?.sender?.username - const userHasAppAccess = user?.user?.hasAppAccess ?? false const devconnectMethod = DEVCONNECT_CLAIM_METHODS.find((m) => m.id === 'devconnect')! /* @@ -272,7 +269,7 @@ export default function SendLinkActionList({ )} - {SHOW_INVITE_MODAL_FOR_DEVCONNECT && isInviteLink && !userHasAppAccess && username && ( + {SHOW_INVITE_MODAL_FOR_DEVCONNECT && isInviteLink && !isLoggedIn && username && (
{t('actions.starAlt')}

{t('actions.invitedBy', { username })}

@@ -299,7 +296,7 @@ export default function SendLinkActionList({ return ( { - if (isInviteLink && !userHasAppAccess && method.id !== 'devconnect') { + if (isInviteLink && !isLoggedIn && method.id !== 'devconnect') { setSelectedMethod(method) setShowInviteModal(true) } else { diff --git a/src/components/Claim/Link/__tests__/Initial.view.autoClaim.test.tsx b/src/components/Claim/Link/__tests__/Initial.view.autoClaim.test.tsx index 531d100a75..68a9907dfb 100644 --- a/src/components/Claim/Link/__tests__/Initial.view.autoClaim.test.tsx +++ b/src/components/Claim/Link/__tests__/Initial.view.autoClaim.test.tsx @@ -157,8 +157,9 @@ jest.mock('@/services/sendLinks', () => ({ sendLinksApi: { associateClaim: jest.fn().mockResolvedValue(undefined) }, })) +const mockAcceptInvite = jest.fn() jest.mock('@/services/invites', () => ({ - invitesApi: { acceptInvite: jest.fn() }, + invitesApi: { acceptInvite: (...args: unknown[]) => mockAcceptInvite(...args) }, })) jest.mock('@/services/rhino-sda', () => ({ @@ -285,6 +286,18 @@ describe('InitialClaimLinkView post-auth auto-claim trigger', () => { await waitFor(() => expect(baseProps.onCustom).toHaveBeenCalledWith('SUCCESS')) }) + test('a stale legacy no-access flag does not gate an authenticated claim', async () => { + currentUser = { + ...stableUser, + user: { ...stableUser.user, hasAppAccess: false }, + } + + renderView('?step=claim') + + await waitFor(() => expect(mockClaimLink).toHaveBeenCalledTimes(1)) + expect(mockAcceptInvite).not.toHaveBeenCalled() + }) + test('?step=regional-claim&method=pix restores the regional method and opens the regional flow', async () => { renderView('?step=regional-claim&method=pix') diff --git a/src/components/Claim/Link/useInitialClaimFlow.ts b/src/components/Claim/Link/useInitialClaimFlow.ts index 369d8ffb22..aefc0c77d2 100644 --- a/src/components/Claim/Link/useInitialClaimFlow.ts +++ b/src/components/Claim/Link/useInitialClaimFlow.ts @@ -13,7 +13,7 @@ import { tokenSelectorContext } from '@/context/tokenSelector.context' import { useAuth } from '@/context/authContext' import { useWallet } from '@/hooks/wallet/useWallet' import { sendLinksApi } from '@/services/sendLinks' -import { areEvmAddressesEqual, toInviteCode } from '@/utils/general.utils' +import { areEvmAddressesEqual } from '@/utils/general.utils' import { useRecipientDisplay } from '@/hooks/useRecipientDisplay' import { useFriendlyError } from '@/hooks/useFriendlyError' import { apiFetch } from '@/utils/api-fetch' @@ -37,8 +37,6 @@ import { evmChainIdToRhinoName } from '@/constants/rhino.consts' import { getTokenSymbol, getChainName } from '@/utils/general.utils' import { belowClaimBridgeMinimum } from '@/utils/claim-min-guard' import { useCapabilities } from '@/hooks/useCapabilities' -import { invitesApi } from '@/services/invites' -import { EInviteType } from '@/services/services.types' import { PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN } from '@/constants/zerodev.consts' import { ROUTE_NOT_FOUND_ERROR } from '@/constants/general.consts' import posthog from 'posthog-js' @@ -146,7 +144,7 @@ export const useInitialClaimFlow = (props: IClaimScreenProps, campaignTag: strin const { claimLink, claimLinkXchain, removeParamStep } = useClaimLink() const { isConnected: isPeanutWallet, address, fetchBalance } = useWallet() const router = useRouter() - const { user, fetchUser } = useAuth() + const { user } = useAuth() const queryClient = useQueryClient() const prevRecipientType = useRef(null) const prevUser = useRef(user) @@ -291,49 +289,6 @@ export const useInitialClaimFlow = (props: IClaimScreenProps, campaignTag: strin if (!isPeanutWallet && recipient.address === '') return - // If the user doesn't have app access, accept the invite before claiming the link - if (!user?.user.hasAppAccess) { - try { - const inviterUsername = claimLinkData.sender?.username - if (!inviterUsername) { - setErrorState({ - showError: true, - errorMessage: t('errors.missingInviter'), - }) - setLoadingState('Idle') - return - } - const inviteCode = toInviteCode(inviterUsername) - const result = await invitesApi.acceptInvite( - inviteCode, - EInviteType.PAYMENT_LINK, - campaignTag ?? undefined - ) - if (!result.success || !result.onboardingResolved) { - console.error('Failed to accept invite') - setErrorState({ - showError: true, - errorMessage: tCommon('genericError'), - }) - setLoadingState('Idle') - return - } - - // fetch user so that we have the latest state and user can access the app. - // We dont need to wait for this, can happen in background. - fetchUser() - } catch (error) { - Sentry.captureException(error) - console.error('Failed to accept invite', error) - setErrorState({ - showError: true, - errorMessage: tCommon('genericError'), - }) - setLoadingState('Idle') - return - } - } - try { setLoadingState('Executing transaction') @@ -523,7 +478,6 @@ export const useInitialClaimFlow = (props: IClaimScreenProps, campaignTag: strin isXChain, address, campaignTag, - fetchUser, t, tCommon, toFriendlyError, diff --git a/src/components/Global/EarlyUserDrawer/index.tsx b/src/components/Global/EarlyUserDrawer/index.tsx index fa92cd220c..36decebac2 100644 --- a/src/components/Global/EarlyUserDrawer/index.tsx +++ b/src/components/Global/EarlyUserDrawer/index.tsx @@ -50,7 +50,7 @@ const EarlyUserDrawer = ({ onVisibilityChange }: { onVisibilityChange?: (visible {/* the head owns the M/12 beneath it; everything after it keeps the drawer's L/16 rhythm */}
- + {t('earlyUserModal.title')} diff --git a/src/components/Global/InviteFriendsDrawer/index.tsx b/src/components/Global/InviteFriendsDrawer/index.tsx index 88b6b8da6b..e740cc822f 100644 --- a/src/components/Global/InviteFriendsDrawer/index.tsx +++ b/src/components/Global/InviteFriendsDrawer/index.tsx @@ -18,9 +18,8 @@ interface InviteFriendsDrawerProps { } /** - * Shared modal for inviting friends to Peanut. Reframed to "your username - * IS your invite" — no more raw-code copy line. Friends just need to enter - * the username on the /setup waitlist gate. + * Shared modal for referring friends to Peanut. The username rides in the + * shared link and is resolved after signup; friends never type it manually. * * Used in: CardSuccessScreen, Profile, PointsPage */ diff --git a/src/components/Invites/InvitesPage.test.tsx b/src/components/Invites/InvitesPage.test.tsx index 91a035b2b1..1414922947 100644 --- a/src/components/Invites/InvitesPage.test.tsx +++ b/src/components/Invites/InvitesPage.test.tsx @@ -440,7 +440,7 @@ describe('invite and badge campaign routing boundaries', () => { } render() - fireEvent.click(await screen.findByRole('button', { name: 'Claim your spot' })) + fireEvent.click(await screen.findByRole('button', { name: 'Create your wallet' })) expect(mockStashInvite).toHaveBeenCalledWith('alice', 'PAYMENT_LINK') expect(mockQueuePendingBadgeCampaigns).toHaveBeenCalledWith(['Creator/Summer', 'second']) @@ -463,7 +463,7 @@ describe('invite and badge campaign routing boundaries', () => { } render() - fireEvent.click(await screen.findByRole('button', { name: 'Claim your spot' })) + fireEvent.click(await screen.findByRole('button', { name: 'Create your wallet' })) expect(mockStashInvite).toHaveBeenCalledWith('offramp', 'PAYMENT_LINK') expect(mockQueuePendingBadgeCampaigns).not.toHaveBeenCalled() @@ -515,7 +515,7 @@ describe('invite and badge campaign routing boundaries', () => { expect(await screen.findByText('peanut invited you to Peanut')).toBeInTheDocument() expect(screen.queryByText('Claim your badge')).not.toBeInTheDocument() - fireEvent.click(screen.getByRole('button', { name: 'Claim your spot' })) + fireEvent.click(screen.getByRole('button', { name: 'Create your wallet' })) expect(mockStashInvite).toHaveBeenCalledWith('squirrelinvitesyou', 'PAYMENT_LINK') // utm values stopped being badge identities (TASK-21226); nothing queues @@ -602,7 +602,7 @@ describe('invite and badge campaign routing boundaries', () => { expect(mockLogin).toHaveBeenCalledTimes(1) }) - it('hands a guest Claim your spot off to the stores during the migration window', async () => { + it('hands a guest wallet signup off to the stores during the migration window', async () => { mockAuth.user = null mockSearch = 'code=alice' mockQueryResult.data = { @@ -614,7 +614,7 @@ describe('invite and badge campaign routing boundaries', () => { mockInterceptGuestCta.mockReturnValue(true) render() - fireEvent.click(await screen.findByRole('button', { name: 'Claim your spot' })) + fireEvent.click(await screen.findByRole('button', { name: 'Create your wallet' })) // invite bookkeeping still runs so post-install signup recovers context expect(mockStashInvite).toHaveBeenCalledWith('alice', 'PAYMENT_LINK') diff --git a/src/components/Invites/InvitesPage.tsx b/src/components/Invites/InvitesPage.tsx index 2425c9aec4..89d696ec4c 100644 --- a/src/components/Invites/InvitesPage.tsx +++ b/src/components/Invites/InvitesPage.tsx @@ -123,10 +123,7 @@ function InvitePageContent() { // A logged-in visitor on either claim path will be auto-routed by the // effect below — keep the loading spinner so they don't see the CTA flash. const canClaimBadgeCampaign = hasAcquisitionBadgeCampaigns - if ( - user?.user && - (canClaimBadgeCampaign || (!redirectUri && user.user.hasAppAccess && inviteCodeData?.onboardingResolved)) - ) { + if (user?.user && (canClaimBadgeCampaign || (!redirectUri && inviteCodeData?.onboardingResolved))) { setShouldShowContent(false) return } @@ -142,7 +139,7 @@ function InvitePageContent() { if (inviteCode && (isLoading || !inviteCodeData)) return const hasValidInvite = !!inviteCodeData?.onboardingResolved && !!inviteCodeData.username - const isInviteAutoClaim = !redirectUri && user.user.hasAppAccess && hasValidInvite + const isInviteAutoClaim = !redirectUri && hasValidInvite const canClaimBadgeCampaign = hasAcquisitionBadgeCampaigns if (!isInviteAutoClaim && !canClaimBadgeCampaign) return @@ -299,7 +296,7 @@ function InvitePageContent() {
diff --git a/src/components/Invites/JoinWaitlistPage.test.tsx b/src/components/Invites/JoinWaitlistPage.test.tsx deleted file mode 100644 index d9e573b2dd..0000000000 --- a/src/components/Invites/JoinWaitlistPage.test.tsx +++ /dev/null @@ -1,144 +0,0 @@ -import { fireEvent, screen, waitFor } from '@testing-library/react' -import { renderWithIntl as render } from '@/test-utils/intl' -import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' -import JoinWaitlistPage from './JoinWaitlistPage' - -const mockAcceptInvite = jest.fn() -const mockFetchUser = jest.fn() -const mockClearInvite = jest.fn() -const mockSetStep = jest.fn() -const mockSettleAcceptedInviteAcquisition = jest.fn() -const mockCapture = jest.fn() - -jest.mock('@/context/authContext', () => ({ - useAuth: () => ({ - user: { user: { userId: 'user-1', email: 'member@example.com' } }, - fetchUser: mockFetchUser, - isFetchingUser: false, - logoutUser: jest.fn(), - }), -})) - -jest.mock('@/services/invites', () => ({ - invitesApi: { - acceptInvite: (...args: unknown[]) => mockAcceptInvite(...args), - validateInviteCode: jest.fn(), - getWaitlistQueuePosition: jest.fn(), - }, -})) -jest.mock('@/services/invite-acquisition', () => ({ - settleAcceptedInviteAcquisition: (...args: unknown[]) => mockSettleAcceptedInviteAcquisition(...args), -})) - -jest.mock('next/navigation', () => ({ useRouter: () => ({ push: jest.fn() }) })) -jest.mock('@tanstack/react-query', () => ({ - useQuery: () => ({ data: { success: true, position: 7 }, isLoading: false }), -})) -jest.mock('@/utils/invite-stash', () => ({ - readInviteCode: () => '', - readInviteType: () => 'PAYMENT_LINK', - clearInvite: () => mockClearInvite(), - stashInvite: jest.fn(), -})) -jest.mock('@/hooks/useNotifications', () => ({ - useNotifications: () => ({ - requestPermission: jest.fn(), - afterPermissionAttempt: jest.fn(), - isPermissionGranted: true, - }), -})) -jest.mock('@/app/actions/users', () => ({ updateUserById: jest.fn() })) -jest.mock('nuqs', () => ({ - useQueryState: () => ['jail', mockSetStep], - parseAsStringEnum: () => ({ withDefault: () => ({}) }), -})) -jest.mock('@/utils/general.utils', () => ({ - getFromCookie: () => null, - toInviteCode: (value: string) => value.trim().toLowerCase(), -})) -jest.mock('@/utils/format.utils', () => ({ isValidEmail: () => true })) -jest.mock('posthog-js', () => ({ capture: (...args: unknown[]) => mockCapture(...args) })) -jest.mock('./InvitesPageLayout', () => ({ - __esModule: true, - default: ({ children }: { children: React.ReactNode }) =>
{children}
, -})) -jest.mock('../Global/ValidatedInput', () => ({ - __esModule: true, - default: ({ - onUpdate, - }: { - onUpdate: (value: { value: string; isValid: boolean; isChanging: boolean }) => void - }) => ( - - ), -})) -jest.mock('@/components/0_Bruddle/Button', () => ({ - Button: ({ - children, - onClick, - disabled, - }: { - children: React.ReactNode - onClick?: () => void - disabled?: boolean - }) => ( - - ), -})) -jest.mock('../Global/Loading', () => ({ - __esModule: true, - default: (props: any) => (props.variant === 'mascot' ?
Loading
:
), -})) -jest.mock('@/components/0_Bruddle/BaseInput', () => ({ BaseInput: () => })) - -describe('JoinWaitlistPage invite onboarding boundary', () => { - beforeEach(() => { - jest.clearAllMocks() - sessionStorage.clear() - mockSettleAcceptedInviteAcquisition.mockReturnValue({ destination: '/home', pending: [] }) - }) - - it.each([ - ['awarded', true], - ['already_owned', true], - ['inactive', false], - ['expired', false], - ['unknown', false], - ] as const)( - 'settles a terminal %s campaign-only response and refreshes only confirmed possession', - async (outcome, shouldRefresh) => { - mockAcceptInvite.mockResolvedValue({ - success: true, - attributionResolved: false, - onboardingResolved: false, - legacyAcquisition: { - campaignTag: 'offramp', - fallback: 'normal_app', - destination: 'offramp_migration', - }, - claims: [{ badgeCampaign: 'offramp', badgeCode: 'OFFRAMP_USER', outcome }], - }) - - render() - fireEvent.click(screen.getByRole('button', { name: 'Enter legacy code' })) - fireEvent.click(screen.getByRole('button', { name: 'Next' })) - - await waitFor(() => expect(mockAcceptInvite).toHaveBeenCalledWith('offramp', 'PAYMENT_LINK')) - expect(mockSettleAcceptedInviteAcquisition).toHaveBeenCalledWith( - expect.objectContaining({ campaignTag: 'offramp' }), - [expect.objectContaining({ badgeCampaign: 'offramp', badgeCode: 'OFFRAMP_USER', outcome })] - ) - expect(sessionStorage.getItem('showNoMoreJailModal')).toBeNull() - expect(mockClearInvite).toHaveBeenCalled() - if (shouldRefresh) expect(mockFetchUser).toHaveBeenCalledTimes(1) - else expect(mockFetchUser).not.toHaveBeenCalled() - expect(screen.queryByText('Something went wrong. Please try again or contact support.')).toBeNull() - expect(mockCapture).not.toHaveBeenCalledWith(ANALYTICS_EVENTS.INVITE_ACCEPTED, expect.anything()) - expect(mockCapture).not.toHaveBeenCalledWith(ANALYTICS_EVENTS.INVITE_ACCEPT_FAILED, expect.anything()) - } - ) -}) diff --git a/src/components/Invites/JoinWaitlistPage.tsx b/src/components/Invites/JoinWaitlistPage.tsx deleted file mode 100644 index dd5944d1d5..0000000000 --- a/src/components/Invites/JoinWaitlistPage.tsx +++ /dev/null @@ -1,418 +0,0 @@ -'use client' - -import { useAuth } from '@/context/authContext' -import { FieldError } from '@/components/0_Bruddle/FieldError' -import { Notification } from '@/components/0_Bruddle/Notification' -import { invitesApi } from '@/services/invites' -import { useEffect, useRef, useState } from 'react' -import InvitesPageLayout from './InvitesPageLayout' -import ValidatedInput from '../Global/ValidatedInput' -import { Button } from '@/components/0_Bruddle/Button' -import { LinkButton } from '@/components/0_Bruddle/LinkButton' -import { useRouter } from 'next/navigation' -import { useQuery } from '@tanstack/react-query' -import Loading from '../Global/Loading' -import { useNotifications } from '@/hooks/useNotifications' -import { updateUserById } from '@/app/actions/users' -import { useQueryState, parseAsStringEnum } from 'nuqs' -import { isValidEmail } from '@/utils/format.utils' -import { BaseInput } from '@/components/0_Bruddle/BaseInput' -import posthog from 'posthog-js' -import { useTranslations } from 'next-intl' -import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' -import { getFromCookie, toInviteCode } from '@/utils/general.utils' -import { USERNAME_MIN_LENGTH } from '@/constants/general.consts' -import { settleAcceptedInviteAcquisition } from '@/services/invite-acquisition' -import { isConfirmedBadgeCampaignClaim } from '@/services/badge-campaigns' -import { clearInvite, readInviteCode, readInviteType } from '@/utils/invite-stash' - -type WaitlistStep = 'email' | 'notifications' | 'jail' -type InviteAcceptanceOutcome = 'onboarding_resolved' | 'campaign_only' | 'failed' - -const nextStepAfterEmail = (isPermissionGranted: boolean): WaitlistStep => - isPermissionGranted ? 'jail' : 'notifications' - -const JoinWaitlistPage = () => { - const t = useTranslations('invites') - const tCommon = useTranslations('common') - const tSetup = useTranslations('setup') - const tNotifications = useTranslations('notifications') - const { fetchUser, isFetchingUser, logoutUser, user } = useAuth() - const router = useRouter() - // invite hand-off lives in cookies — TASK-21460 - const inviteType = readInviteType() - const setupInviteCode = readInviteCode() - const { requestPermission, afterPermissionAttempt, isPermissionGranted } = useNotifications() - - // URL-backed step state — survives refresh, enables deep-linking - const [step, setStep] = useQueryState( - 'step', - parseAsStringEnum(['email', 'notifications', 'jail']).withDefault( - (() => { - if (user?.user.email) return nextStepAfterEmail(isPermissionGranted) - return 'email' - })() - ) - ) - - // Step 1: Email state - const [emailValue, setEmailValue] = useState('') - const [emailError, setEmailError] = useState('') - const [isSubmittingEmail, setIsSubmittingEmail] = useState(false) - - // Step 3: Invite code state. A pending code can also survive in the - // inviteCode cookie when the register-time accept failed (useZeroDev keeps - // it there so this page can retry after an app restart). - const inviteCodeFromCookie = getFromCookie('inviteCode') - const pendingInviteCode = - setupInviteCode?.trim() || (typeof inviteCodeFromCookie === 'string' ? inviteCodeFromCookie.trim() : '') - const [inviteCode, setInviteCode] = useState(pendingInviteCode) - const [isValid, setIsValid] = useState(false) - const [isChanging, setIsChanging] = useState(false) - const [isValidating, setIsValidating] = useState(false) - const [isAccepting, setIsAccepting] = useState(false) - const [error, setError] = useState('') - const [isLoggingOut, setIsLoggingOut] = useState(false) - const [isAutoAccepting, setIsAutoAccepting] = useState(!!pendingInviteCode) - - const { data, isLoading: isLoadingWaitlistPosition } = useQuery({ - queryKey: ['waitlist-position', user?.user.userId], - queryFn: () => invitesApi.getWaitlistQueuePosition(), - enabled: !!user?.user.userId && step === 'jail', - }) - - // Track whether the email step has been completed or skipped this session, - // so the step invariant useEffect doesn't race with react-query state updates - const [emailStepDone, setEmailStepDone] = useState(!!user?.user.email) - - // Enforce step invariants: prevent URL bypass and fast-forward completed steps - useEffect(() => { - if (isFetchingUser) return - if (step !== 'email' && !user?.user.email && !emailStepDone) { - setStep('email') - } else if (step === 'email' && (user?.user.email || emailStepDone)) { - setStep(nextStepAfterEmail(isPermissionGranted)) - } else if (step === 'notifications' && isPermissionGranted) { - setStep('jail') - } - }, [user?.user.email, isPermissionGranted, isFetchingUser, step, setStep, emailStepDone]) - - // Sync emailStepDone when user data loads with an existing email - useEffect(() => { - if (user?.user.email) setEmailStepDone(true) - }, [user?.user.email]) - - // Track waitlist step views — measures email-capture conversion + jail-stuck volume. - // Skipped while auto-accepting: the user never sees the step, and counting - // it would pollute the funnel. - useEffect(() => { - if (isAutoAccepting) return - posthog.capture(ANALYTICS_EVENTS.WAITLIST_STEP_VIEWED, { step }) - }, [step, isAutoAccepting]) - - // Step 1: Submit email via server action - const handleEmailSubmit = async () => { - if (!isValidEmail(emailValue) || isSubmittingEmail) return - - if (!user?.user.userId) { - setEmailError(t('accountNotLoaded')) - return - } - - setIsSubmittingEmail(true) - setEmailError('') - - try { - const result = await updateUserById({ userId: user.user.userId, email: emailValue }) - if (result.error) { - setEmailError(result.error) - return - } - - const refreshedUser = await fetchUser() - if (!refreshedUser?.user.email) { - console.error('[JoinWaitlist] Email update succeeded but fetchUser did not return email') - setEmailError(t('emailSavedProfileFailed')) - return - } - - // Mark email step as done BEFORE setStep to prevent the useEffect - // from racing and resetting the step back to 'email' - setEmailStepDone(true) - setStep(nextStepAfterEmail(isPermissionGranted)) - } catch (e) { - console.error('[JoinWaitlist] handleEmailSubmit failed:', e) - setEmailError(t('emailSubmitFailed')) - } finally { - setIsSubmittingEmail(false) - } - } - - const handleSkipEmail = () => { - setEmailStepDone(true) - setStep(nextStepAfterEmail(isPermissionGranted)) - } - - // Step 2: Enable notifications (always advances regardless of outcome) - const handleEnableNotifications = async () => { - try { - await requestPermission() - await afterPermissionAttempt() - } catch { - // permission denied or error — that's fine - } - setStep('jail') - } - - // Step 3: Validate and accept invite code (separate loading states to avoid race) - const validateInviteCode = async (code: string): Promise => { - setIsValidating(true) - try { - const res = await invitesApi.validateInviteCode(code) - const onboardingResolved = res.success && res.onboardingResolved - posthog.capture(ANALYTICS_EVENTS.INVITE_CODE_VALIDATED, { - valid: onboardingResolved, - source: 'waitlist_page', - }) - return onboardingResolved - } catch (e) { - posthog.capture(ANALYTICS_EVENTS.INVITE_CODE_VALIDATED, { valid: false, source: 'waitlist_page' }) - throw e - } finally { - setIsValidating(false) - } - } - - // Shared by the manual Next button and the automatic attempt below. - // Campaign-only compatibility can settle without granting onboarding. - const acceptInviteWithCode = async ( - code: string, - source: 'waitlist_page' | 'waitlist_auto' - ): Promise => { - const res = await invitesApi.acceptInvite(code, inviteType) - if (!res.success) { - posthog.capture(ANALYTICS_EVENTS.INVITE_ACCEPT_FAILED, { - invite_code: code, - error_message: 'API returned unsuccessful', - source, - }) - return 'failed' - } - if (!res.onboardingResolved) { - if (!res.legacyAcquisition) { - posthog.capture(ANALYTICS_EVENTS.INVITE_ACCEPT_FAILED, { - invite_code: code, - error_message: 'Campaign-only response omitted acquisition descriptor', - source, - }) - return 'failed' - } - - // The adapter has done all it can. Settle terminal claims and keep - // only retryable campaign state; never retry the non-invite code. - settleAcceptedInviteAcquisition(res.legacyAcquisition, res.claims) - clearInvite() - // Provenance is audit-only. Refresh every confirmed permanent award - // so any badge-owned access/reward projection is visible immediately; - // unavailable outcomes must not masquerade as a capability change. - if (res.claims.some(isConfirmedBadgeCampaignClaim)) await fetchUser() - return 'campaign_only' - } - posthog.capture(ANALYTICS_EVENTS.INVITE_ACCEPTED, { invite_code: code, source }) - sessionStorage.setItem('showNoMoreJailModal', 'true') - clearInvite() - await fetchUser() - return 'onboarding_resolved' - } - - const handleAcceptInvite = async () => { - setIsAccepting(true) - try { - const outcome = await acceptInviteWithCode(inviteCode, 'waitlist_page') - if (outcome === 'failed') { - setError(tCommon('genericError')) - } - } catch { - posthog.capture(ANALYTICS_EVENTS.INVITE_ACCEPT_FAILED, { - invite_code: inviteCode, - error_message: 'Exception during invite acceptance', - source: 'waitlist_page', - }) - setError(tCommon('genericError')) - } finally { - setIsAccepting(false) - } - } - - /* - * Auto-redeem a pending invite before showing any waitlist UI. The - * register-time accept in useZeroDev is fail-open (signup continues even - * if it fails), so a user who provided an inviter can still land here - * without access — one automatic attempt makes that recovery invisible. - * Falls back to the manual flow silently on failure; runs at most once. - */ - const autoAcceptRanRef = useRef(false) - useEffect(() => { - if (!isAutoAccepting || autoAcceptRanRef.current) return - if (isFetchingUser || !user?.user.userId) return - autoAcceptRanRef.current = true - ;(async () => { - try { - // on success the refetched user has access and the layout - // unmounts this page; the setState below is then a no-op - await acceptInviteWithCode(pendingInviteCode, 'waitlist_auto') - } catch { - posthog.capture(ANALYTICS_EVENTS.INVITE_ACCEPT_FAILED, { - invite_code: pendingInviteCode, - error_message: 'Exception during invite acceptance', - source: 'waitlist_auto', - }) - } - setIsAutoAccepting(false) - })() - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isAutoAccepting, isFetchingUser, user?.user.userId]) - - const handleLogout = async () => { - setIsLoggingOut(true) - try { - await logoutUser() - router.push('/setup') - } finally { - setIsLoggingOut(false) - setError('') - } - } - - useEffect(() => { - if (!isFetchingUser && !user) { - router.push('/setup') - } - }, [isFetchingUser, user, router]) - - const stepPose = step === 'jail' ? 'pointing' : 'waving-hello' - - if (isAutoAccepting) return - - return ( - -
-
- {/* Step 1: Email Collection */} - {step === 'email' && ( -
-

{t('emailTitle')}

-

{t('emailDescription')}

- - {/* input + its field error form one column, 4px apart (form-field board 17788:19179) */} -
- { - setEmailValue(e.target.value) - setEmailError('') - }} - onKeyDown={(e) => { - if (e.key === 'Enter' && isValidEmail(emailValue)) handleEmailSubmit() - }} - /> - {emailError && {emailError}} -
- - - - {emailError && ( - - {tCommon('skipForNow')} - - )} -
- )} - - {/* Step 2: Enable Notifications (skippable) */} - {step === 'notifications' && ( -
-

{t('notificationsTitle')}

-

{t('notificationsDescription')}

- - - - setStep('jail')} className="self-center"> - {tNotifications('notNow')} - -
- )} - - {/* Step 3: Jail Screen */} - {step === 'jail' && isLoadingWaitlistPosition && } - {step === 'jail' && !isLoadingWaitlistPosition && ( -
-

{t('inviteOnlyTitle')}

- -

- {data?.position ? t('inLineWithPosition', { position: data.position }) : t('inLine')} -

-

{t('skipTheLine')}

- - {/* input + its field error form one column, 4px apart (form-field board 17788:19179) */} -
-
- toInviteCode(v).length >= USERNAME_MIN_LENGTH} - onUpdate={({ value, isValid, isChanging }) => { - setIsValid(isValid) - setIsChanging(isChanging) - setInviteCode(value) - }} - isSetupFlow - isInputChanging={isChanging} - className="rounded-sm" - /> - - -
- - {!isValid && !isChanging && !!inviteCode && ( - {tSetup('waitlist.inviterNotFound')} - )} -
- - {error && {error}} - - - {isLoggingOut ? t('pleaseWait') : t('logInDifferentAccount')} - -
- )} -
-
-
- ) -} - -export default JoinWaitlistPage diff --git a/src/components/Jobs/Careers.tsx b/src/components/Jobs/Careers.tsx index 1ca2091380..f417f6e6bd 100644 --- a/src/components/Jobs/Careers.tsx +++ b/src/components/Jobs/Careers.tsx @@ -28,8 +28,7 @@ export function Careers() {

Peanut is a money app for people who cross borders. You send money to anyone, pay into local systems like MercadoPago and PIX, and settle up with friends — instantly, without needing - local ID or a bank account. Bank transfers reach 40+ countries. It's invite-only for - now. + local ID or a bank account. Bank transfers reach 40+ countries. Anyone can create a wallet.

That's the product. The rest of this page is what it's like to work on it, and diff --git a/src/components/Profile/components/PublicProfile.tsx b/src/components/Profile/components/PublicProfile.tsx index 7a1afca268..178d9fc497 100644 --- a/src/components/Profile/components/PublicProfile.tsx +++ b/src/components/Profile/components/PublicProfile.tsx @@ -23,7 +23,6 @@ import { useAuth } from '@/context/authContext' import { useGuestStoreHandoff } from '@/hooks/useGuestStoreHandoff' import { useSafeBack } from '@/hooks/useSafeBack' import { useUserInteractions } from '@/hooks/useUserInteractions' -import ShareButton from '@/components/Global/ShareButton' import { IconBubble } from '@/components/0_Bruddle/IconBubble' import { Drawer, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle } from '@/components/Global/Drawer' import BadgesRow from '@/components/Badges/BadgesRow' @@ -110,8 +109,8 @@ const PublicProfile: React.FC = ({ username, isLoggedIn = fa onboardingResolved: false, username: '', })) - // Credit ONLY the profile owner: the API's typo-fallback resolves a - // waitlisted handle to a DIFFERENT real user (`maria23` → `maria`). + // Credit ONLY the profile owner: the API's legacy-code fallback can + // resolve a mistyped handle to a DIFFERENT real user (`maria23` → `maria`). // Session scope, no expiryDays — a poisoned cookie outlives this page // and locks setup past the only screen with Log In (PR #2346). const resolvedToOwner = !!onboardingResolved && inviterUsername === code @@ -206,7 +205,7 @@ const PublicProfile: React.FC = ({ username, isLoggedIn = fa

diff --git a/src/components/Profile/components/__tests__/PublicProfile.test.tsx b/src/components/Profile/components/__tests__/PublicProfile.test.tsx index 692fb83fc9..93d511da32 100644 --- a/src/components/Profile/components/__tests__/PublicProfile.test.tsx +++ b/src/components/Profile/components/__tests__/PublicProfile.test.tsx @@ -183,6 +183,16 @@ describe('PublicProfile guest door', () => { expect(posthog.capture).not.toHaveBeenCalledWith(ANALYTICS_EVENTS.REFERRAL_CTA_SHOWN, expect.anything()) }) + it('routes a registered user directly even when a cached legacy access flag is false', async () => { + mockAuth = { user: { user: { username: 'hal', hasAppAccess: false } }, isFetchingUser: false } + renderWithIntl() + + fireEvent.click(await screen.findByRole('button', { name: en.navigation.request })) + + expect(mockPush).toHaveBeenCalledWith('/request/satoshi') + expect(screen.queryByTestId('invite-drawer')).not.toBeInTheDocument() + }) + it('holds the impression back until auth has settled', async () => { mockAuth = { user: null, isFetchingUser: true } renderWithIntl() diff --git a/src/components/Setup/Setup.consts.tsx b/src/components/Setup/Setup.consts.tsx index d265e52369..911ce76966 100644 --- a/src/components/Setup/Setup.consts.tsx +++ b/src/components/Setup/Setup.consts.tsx @@ -1,6 +1,5 @@ import type { ISetupStep } from '@/components/Setup/Setup.types' import { SetupPasskey, SignupStep, LandingStep, ResidenceStep, SignTestTransaction } from '@/components/Setup/Views' -import JoinWaitlist from './Views/JoinWaitlist' export const setupSteps: ISetupStep[] = [ { @@ -12,15 +11,6 @@ export const setupSteps: ISetupStep[] = [ showSkipButton: false, contentClassName: 'flex flex-col items-center justify-center gap-6', }, - { - screenId: 'welcome', - layoutType: 'signup', - image: { pose: 'pointing' }, - component: JoinWaitlist, - showBackButton: true, - showSkipButton: false, - contentClassName: 'flex flex-col items-center justify-center gap-6', - }, { screenId: 'signup', layoutType: 'signup', diff --git a/src/components/Setup/Views/JoinWaitlist.tsx b/src/components/Setup/Views/JoinWaitlist.tsx deleted file mode 100644 index 5fb3e22ae0..0000000000 --- a/src/components/Setup/Views/JoinWaitlist.tsx +++ /dev/null @@ -1,168 +0,0 @@ -'use client' - -import { Button } from '@/components/0_Bruddle/Button' -import { Divider } from '@/components/0_Bruddle/Divider' -import { FieldError } from '@/components/0_Bruddle/FieldError' -import { Notification } from '@/components/0_Bruddle/Notification' -import ValidatedInput from '@/components/Global/ValidatedInput' -import { useEffect, useRef, useState } from 'react' -import { useSetupFlow } from '@/hooks/useSetupFlow' -import { stashInvite } from '@/utils/invite-stash' -import { EInviteType } from '@/services/services.types' -import { invitesApi } from '@/services/invites' -import posthog from 'posthog-js' -import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' -import { enableDemoMode, isDemoInviteCode } from '@/utils/demo' -import { useTranslations } from 'next-intl' -import { isCapacitor } from '@/utils/capacitor' -import { useRouter } from 'next/navigation' -import { useQueryClient } from '@tanstack/react-query' -import { USER } from '@/constants/query.consts' -import { DEMO_USER } from '@/constants/demo-data' -import { toInviteCode } from '@/utils/general.utils' -import { USERNAME_MIN_LENGTH } from '@/constants/general.consts' - -const JoinWaitlist = () => { - const t = useTranslations('setup') - const tCommon = useTranslations('common') - const [inviteCode, setInviteCode] = useState('') - const [isValid, setIsValid] = useState(false) - const [isChanging, setIsChanging] = useState(false) - const [isLoading, setisLoading] = useState(false) - const [error, setError] = useState('') - // flow/api failure — not attributable to the input, so it renders as a - // notification banner instead of a field error (design.md error display) - const [flowError, setFlowError] = useState('') - - const { handleNext } = useSetupFlow() - const router = useRouter() - const queryClient = useQueryClient() - - useEffect(() => { - posthog.capture(ANALYTICS_EVENTS.SIGNUP_WAITLIST_VIEWED) - }, []) - - // stale-request guard: only the latest validation may touch the error - // channels — a slow request A resolving after fresh request B must not - // stack its message next to B's. - const validationSeq = useRef(0) - - const validateInviteCode = async (inviteCode: string): Promise => { - // token first — even the demo early-return must retire any in-flight - // validation so a stale resolution cannot write errors for this value - const seq = ++validationSeq.current - const isCurrent = () => seq === validationSeq.current - // Demo mode (native): `demo` is a client-only trigger — never hit the invite - // API. Keeps it from creating accounts / bypassing the waitlist, and lets it - // work even when the (prod) backend doesn't know the code. - if (isCapacitor() && isDemoInviteCode(inviteCode)) { - enableDemoMode() - return true - } - try { - setError('') - setFlowError('') - setisLoading(true) - const res = await invitesApi.validateInviteCode(inviteCode) - const isValid = res.success && res.onboardingResolved - posthog.capture(ANALYTICS_EVENTS.INVITE_CODE_VALIDATED, { - valid: isValid, - source: 'setup', - invite_code: inviteCode, - }) - if (!isValid && isCurrent()) { - setError(t('waitlist.inviterNotFound')) - } - return isValid - } catch { - posthog.capture(ANALYTICS_EVENTS.INVITE_CODE_VALIDATED, { - valid: false, - source: 'setup', - invite_code: inviteCode, - }) - if (isCurrent()) { - setFlowError(tCommon('genericError')) - } - return false - } finally { - if (isCurrent()) { - setisLoading(false) - } - } - } - - return ( -
- {/* input + its field error form one column, 4px apart (form-field board 17788:19179) */} -
- toInviteCode(v).length >= USERNAME_MIN_LENGTH} - onUpdate={({ value, isValid, isChanging }) => { - setIsValid(isValid) - setIsChanging(isChanging) - setInviteCode(value) - if (isChanging) { - // retire any in-flight validation: a cleared or - // shortened value skips the next lookup, so a stale - // resolution must not repopulate the channels - validationSeq.current++ - setError('') - setFlowError('') - setisLoading(false) - } - }} - isSetupFlow - isInputChanging={isChanging} - className="rounded-sm" - /> - {error && {error}} -
- - {flowError && {flowError}} - - - - - - -
- ) -} - -export default JoinWaitlist diff --git a/src/components/Setup/Views/Landing.tsx b/src/components/Setup/Views/Landing.tsx index 7c048bed5a..4bc9415d71 100644 --- a/src/components/Setup/Views/Landing.tsx +++ b/src/components/Setup/Views/Landing.tsx @@ -77,7 +77,7 @@ const LandingStep = () => { ) : ( +
{error && {error}}
- {/* slot space is always reserved so the error mounting doesn't - re-center the vertically-centered step block (F-5). min-h-8 - = two 16px body-xs lines, enough for the longest message */} -
{error && {error}}
+ + + + + {showInviterInput && ( + + toInviteCode(value).length >= USERNAME_MIN_LENGTH} + onUpdate={({ value, isValid, isChanging }) => { + inviterValueRef.current = value + setInviterUsername(value) + setInviterValid(isValid) + setInviterChanging(isChanging) + if (isChanging) { + clearManualInvite() + setInviterError('') + } + }} + isSetupFlow + isInputChanging={inviterChanging} + /> + {inviterError ? ( + {inviterError} + ) : ( +

+ {t('signupStep.inviterHelp')} +

+ )} +
+ )} +
+ +

diff --git a/src/components/Setup/Views/__tests__/SignupInviter.test.tsx b/src/components/Setup/Views/__tests__/SignupInviter.test.tsx new file mode 100644 index 0000000000..2b0e443bb7 --- /dev/null +++ b/src/components/Setup/Views/__tests__/SignupInviter.test.tsx @@ -0,0 +1,103 @@ +/** @jest-environment jsdom */ +import { act, fireEvent, screen, waitFor } from '@testing-library/react' +import { renderWithIntl } from '@/test-utils/intl' +import { setupScreenIds } from '@/components/Setup/Setup.consts' +import { SetupFlowProvider } from '@/features/setup/SetupFlowContext' +import { useState } from 'react' +import SignupStep from '../Signup' + +const mockHandleNext = jest.fn(async (guard?: () => Promise) => guard?.()) +const mockValidateInviter = jest.fn() +const mockApiFetch = jest.fn() +const mockStoredInvite = { code: '', type: 'DIRECT' } + +jest.mock('@/hooks/useSetupFlow', () => ({ useSetupFlow: () => ({ handleNext: mockHandleNext, isLoading: false }) })) +jest.mock('@/hooks/useDebounce', () => ({ useDebounce: (value: string) => value })) +jest.mock('@/utils/api-fetch', () => ({ apiFetch: (...args: unknown[]) => mockApiFetch(...args) })) +jest.mock('@/services/invites', () => ({ + invitesApi: { validateInviteCode: (...args: unknown[]) => mockValidateInviter(...args) }, +})) +jest.mock('@/utils/invite-stash', () => ({ + readInviteCode: () => mockStoredInvite.code, + readInviteType: () => mockStoredInvite.type, + stashInvite: (code: string, type: string) => { + mockStoredInvite.code = code + mockStoredInvite.type = type + }, + clearInvite: () => { + mockStoredInvite.code = '' + }, +})) +jest.mock('posthog-js', () => ({ capture: jest.fn() })) + +const renderSignup = () => + renderWithIntl( + + + + ) + +describe('optional inviter on signup', () => { + beforeEach(() => { + jest.clearAllMocks() + mockStoredInvite.code = '' + mockStoredInvite.type = 'DIRECT' + mockApiFetch.mockResolvedValue({ status: 404 }) + mockValidateInviter.mockResolvedValue({ success: true, attributionResolved: true }) + }) + + it('lets a direct signup continue without an inviter', async () => { + renderSignup() + expect(screen.queryByRole('textbox', { name: "Inviter's Peanut username" })).not.toBeInTheDocument() + fireEvent.change(screen.getByPlaceholderText('Username'), { target: { value: 'newuser' } }) + await waitFor(() => expect(screen.getByRole('button', { name: 'Next' })).toBeEnabled()) + fireEvent.click(screen.getByRole('button', { name: 'Next' })) + await waitFor(() => expect(mockHandleNext).toHaveBeenCalledTimes(1)) + expect(mockStoredInvite.code).toBe('') + }) + + it('reveals, validates, and stores a manual inviter without losing an existing referral on edit', async () => { + mockStoredInvite.code = 'original-referral' + renderSignup() + fireEvent.change(screen.getByPlaceholderText('Username'), { target: { value: 'newuser' } }) + await waitFor(() => expect(screen.getByRole('button', { name: 'Next' })).toBeEnabled()) + + const reveal = screen.getByRole('button', { name: 'Who invited you?' }) + fireEvent.click(reveal) + expect(reveal).toHaveAttribute('aria-expanded', 'true') + const inviter = screen.getByRole('textbox', { name: "Inviter's Peanut username" }) + fireEvent.change(inviter, { target: { value: '@Alice ' } }) + await waitFor(() => expect(mockValidateInviter).toHaveBeenCalledWith('@Alice ')) + await waitFor(() => expect(screen.getByRole('button', { name: 'Next' })).toBeEnabled()) + fireEvent.click(screen.getByRole('button', { name: 'Next' })) + await waitFor(() => expect(mockStoredInvite.code).toBe('alice')) + + fireEvent.change(inviter, { target: { value: 'bob' } }) + expect(mockStoredInvite.code).toBe('original-referral') + }) + + it('keeps the entered inviter when the signup step is left and revisited', async () => { + function FlowHarness() { + const [onSignup, setOnSignup] = useState(true) + return ( + + + {onSignup && } + + ) + } + + renderWithIntl() + fireEvent.click(screen.getByRole('button', { name: 'Who invited you?' })) + fireEvent.change(screen.getByRole('textbox', { name: "Inviter's Peanut username" }), { + target: { value: 'alice' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'Switch step' })) + fireEvent.click(screen.getByRole('button', { name: 'Switch step' })) + + expect(screen.getByRole('textbox', { name: "Inviter's Peanut username" })).toHaveValue('alice') + await act(async () => { + await Promise.resolve() + }) + }) +}) diff --git a/src/constants/analytics.consts.ts b/src/constants/analytics.consts.ts index 09f76a9d1e..ce9db8ecfa 100644 --- a/src/constants/analytics.consts.ts +++ b/src/constants/analytics.consts.ts @@ -24,6 +24,8 @@ export const ANALYTICS_EVENTS = { PASSKEY_LOGIN_RETRY: 'passkey_login_retry', SIGNUP_CREATE_WALLET_CLICKED: 'signup_create_wallet_clicked', SIGNUP_USERNAME_VALIDATED: 'signup_username_validated', + SIGNUP_INVITER_PROMPT_OPENED: 'signup_inviter_prompt_opened', + SIGNUP_INVITER_ADDED: 'signup_inviter_added', SIGNUP_EXISTING_SESSION_CONTINUED: 'signup_existing_session_continued', SIGNUP_PASSKEY_STARTED: 'signup_passkey_started', SIGNUP_PASSKEY_SUCCEEDED: 'signup_passkey_succeeded', diff --git a/src/features/setup/SetupFlowContext.tsx b/src/features/setup/SetupFlowContext.tsx index ad8c312412..0d2251fe3a 100644 --- a/src/features/setup/SetupFlowContext.tsx +++ b/src/features/setup/SetupFlowContext.tsx @@ -3,6 +3,10 @@ import React, { createContext, type ReactNode, useContext, useMemo, useState, useCallback } from 'react' import { type ISetupStep, type ScreenId } from '@/components/Setup/Setup.types' import { type SignupEntryFlow } from '@/features/setup/signup-analytics' +import { EInviteType } from '@/services/services.types' +import { clearInvite, readInviteCode, readInviteType, stashInvite } from '@/utils/invite-stash' + +type ManualInvite = { code: string; previousCode: string; previousType: EInviteType } /** * Setup flow memory that cannot live in the URL: the filtered step list (a @@ -25,6 +29,10 @@ interface SetupFlowContextType { setDirection: (direction: number) => void username: string setUsername: (username: string) => void + inviterUsername: string + setInviterUsername: (username: string) => void + applyManualInvite: (code: string) => void + clearManualInvite: () => void residenceCountry: string setResidenceCountry: (country: string) => void secondResidenceCountry: string @@ -54,21 +62,45 @@ export const SetupFlowProvider: React.FC<{ children: ReactNode; masterScreenIds: const [isLoading, setIsLoading] = useState(false) const [direction, setDirection] = useState(0) const [username, setUsername] = useState('') + const [inviterUsername, setInviterUsername] = useState('') + const [manualInvite, setManualInvite] = useState(null) const [residenceCountry, setResidenceCountry] = useState('') const [secondResidenceCountry, setSecondResidenceCountry] = useState('') const [signupEntryFlow, setSignupEntryFlow] = useState('default') const [noBackLockScreenId, setNoBackLockScreenId] = useState(null) + const clearManualInvite = useCallback(() => { + if (manualInvite && manualInvite.code === readInviteCode()) { + if (manualInvite.previousCode) stashInvite(manualInvite.previousCode, manualInvite.previousType) + else clearInvite() + } + setManualInvite(null) + }, [manualInvite]) + + const applyManualInvite = useCallback( + (code: string) => { + const currentCode = readInviteCode() + if (manualInvite?.code === code && currentCode === code) return + const previousCode = manualInvite?.code === currentCode ? manualInvite.previousCode : currentCode + const previousType = manualInvite?.code === currentCode ? manualInvite.previousType : readInviteType() + stashInvite(code, EInviteType.DIRECT) + setManualInvite({ code, previousCode, previousType }) + }, + [manualInvite] + ) + // "start fresh" on the existing-session interstitial: the provider stays // mounted through the logout, so the typed state clears explicitly const resetSetupFlow = useCallback(() => { setIsLoading(false) setDirection(0) setUsername('') + setInviterUsername('') + clearManualInvite() setResidenceCountry('') setSecondResidenceCountry('') setNoBackLockScreenId(null) - }, []) + }, [clearManualInvite]) const value = useMemo( () => ({ @@ -81,6 +113,10 @@ export const SetupFlowProvider: React.FC<{ children: ReactNode; masterScreenIds: setDirection, username, setUsername, + inviterUsername, + setInviterUsername, + applyManualInvite, + clearManualInvite, residenceCountry, setResidenceCountry, secondResidenceCountry, @@ -97,6 +133,9 @@ export const SetupFlowProvider: React.FC<{ children: ReactNode; masterScreenIds: isLoading, direction, username, + inviterUsername, + applyManualInvite, + clearManualInvite, residenceCountry, secondResidenceCountry, signupEntryFlow, diff --git a/src/i18n/app/messages/en.json b/src/i18n/app/messages/en.json index 8ffa48427e..574aedf128 100644 --- a/src/i18n/app/messages/en.json +++ b/src/i18n/app/messages/en.json @@ -758,6 +758,11 @@ }, "signupStep": { "usernamePlaceholder": "Username", + "whoInvitedYou": "Who invited you?", + "inviterUsernameLabel": "Inviter's Peanut username", + "inviterUsernamePlaceholder": "Their username", + "inviterHelp": "Your inviter may earn rewards when you use Peanut.", + "inviterNotFound": "We couldn't verify that username. Check it and try again.", "errors": { "required": "Username is required", "tooShort": "Username must be at least 4 characters long", diff --git a/src/i18n/app/messages/es-419.json b/src/i18n/app/messages/es-419.json index 614e90fdd7..360986c1b6 100644 --- a/src/i18n/app/messages/es-419.json +++ b/src/i18n/app/messages/es-419.json @@ -758,6 +758,11 @@ }, "signupStep": { "usernamePlaceholder": "Tu usuario", + "whoInvitedYou": "¿Quién te invitó?", + "inviterUsernameLabel": "Usuario de Peanut de quien te invitó", + "inviterUsernamePlaceholder": "Su usuario", + "inviterHelp": "Quien te invitó puede ganar recompensas cuando uses Peanut.", + "inviterNotFound": "No pudimos verificar ese usuario. Revísalo e inténtalo de nuevo.", "errors": { "required": "El nombre de usuario es obligatorio", "tooShort": "El nombre de usuario debe tener al menos 4 caracteres", diff --git a/src/i18n/app/messages/es-AR.json b/src/i18n/app/messages/es-AR.json index 8b76a279a9..a6b25ca03e 100644 --- a/src/i18n/app/messages/es-AR.json +++ b/src/i18n/app/messages/es-AR.json @@ -455,6 +455,7 @@ }, "signupStep": { "usernamePlaceholder": "Tu usuario", + "inviterNotFound": "No pudimos verificar ese usuario. Revisalo e intentá de nuevo.", "errors": { "invalid": "El nombre de usuario no es válido, usá uno diferente", "checkFailed": "No pudimos verificar la disponibilidad del usuario. Intentalo de nuevo.", diff --git a/src/i18n/app/messages/pt-BR.json b/src/i18n/app/messages/pt-BR.json index 5dab56214b..b8f5040ea8 100644 --- a/src/i18n/app/messages/pt-BR.json +++ b/src/i18n/app/messages/pt-BR.json @@ -758,6 +758,11 @@ }, "signupStep": { "usernamePlaceholder": "Seu usuário", + "whoInvitedYou": "Quem convidou você?", + "inviterUsernameLabel": "Usuário do Peanut de quem convidou você", + "inviterUsernamePlaceholder": "Usuário de quem convidou você", + "inviterHelp": "Quem convidou você pode ganhar recompensas quando você usar o Peanut.", + "inviterNotFound": "Não conseguimos verificar esse usuário. Confira e tente de novo.", "errors": { "required": "O nome de usuário é obrigatório", "tooShort": "O nome de usuário deve ter pelo menos 4 caracteres",