diff --git a/instrumentation-client.ts b/instrumentation-client.ts index 277e683f6a..f2ea3a0315 100644 --- a/instrumentation-client.ts +++ b/instrumentation-client.ts @@ -11,6 +11,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. @@ -34,6 +35,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({ includeDocumentReferrer: true }) posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY!, { // Web posts through the `/relay` Next.js rewrite — the path is intentionally @@ -52,7 +57,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/scripts/screens/inventory.mjs b/scripts/screens/inventory.mjs index 6a4d8db6f1..e2f3a11914 100644 --- a/scripts/screens/inventory.mjs +++ b/scripts/screens/inventory.mjs @@ -1,6 +1,11 @@ import { routePatterns, routePatternFor } from './routes.mjs' const ROUTE_EXCLUSIONS = new Map([ + ['/setup', 'Stateful setup router; each repeatable setup view is catalogued as a synthetic component scenario'], + [ + '/setup/finish', + 'Stateful incomplete-account route; its repeatable completion view is catalogued as 05-a-signtesttransaction', + ], ['/qr', 'Routing alias for QR scanner and claim destinations; those destination screens are catalogued separately'], ['/points', 'Routing alias for the Rewards destination, which is catalogued separately'], ['/points/invites', 'Routing alias for the Rewards invites destination, which is catalogued separately'], diff --git a/scripts/screens/inventory.test.mjs b/scripts/screens/inventory.test.mjs index c7f33b82ba..ff0a7c26bc 100644 --- a/scripts/screens/inventory.test.mjs +++ b/scripts/screens/inventory.test.mjs @@ -23,4 +23,12 @@ test('the current catalogue accounts for every app route', () => { const aliases = result.filter((entry) => routingAliases.has(entry.route)) assert.equal(aliases.length, routingAliases.size) assert.ok(aliases.every((entry) => entry.status === 'excluded' && entry.reason.includes('Routing alias'))) + + const setup = result.find((entry) => entry.route === '/setup') + assert.equal(setup?.status, 'excluded') + assert.match(setup?.reason ?? '', /setup view/i) + + const setupFinish = result.find((entry) => entry.route === '/setup/finish') + assert.equal(setupFinish?.status, 'excluded') + assert.match(setupFinish?.reason ?? '', /05-a-signtesttransaction/) }) 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 fa0d823a27..5da3e7dab5 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' @@ -327,4 +326,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 569a529241..415cff202e 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 1b55608ffa..a34f1acae6 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' @@ -221,11 +220,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', { @@ -318,20 +327,23 @@ it('does not attribute a stored session-end page to a new signup', async () => { expect(useSetupStepAnalytics).toHaveBeenLastCalledWith(expect.objectContaining({ signupEntryFlow: 'default' })) }) -it('settles a native badge campaign before redirecting an authenticated user home', async () => { - mockAuth.user = { user: { username: 'alice', hasAppAccess: true } } - mockSearchParams = new URLSearchParams('step=signup&badge_campaign=bug_whisperer') - - renderWithIntl() - - await waitFor(() => expect(mockClaimAndSettlePendingBadgeCampaigns).toHaveBeenCalledWith(['bug_whisperer'])) - expect(mockClaimAndSettlePendingBadgeCampaigns).toHaveBeenCalledTimes(1) - await waitFor(() => expect(mockAuth.fetchUser).toHaveBeenCalledTimes(1)) - expect(mockRouter.replace).toHaveBeenCalledWith('/home') - expect(mockRouter.replace.mock.invocationCallOrder[0]).toBeGreaterThan( - mockClaimAndSettlePendingBadgeCampaigns.mock.invocationCallOrder[0] - ) -}) +it.each([true, false])( + 'settles a native badge campaign before redirecting home (legacy access=%s)', + async (hasAppAccess) => { + mockAuth.user = { user: { username: 'alice', hasAppAccess } } + mockSearchParams = new URLSearchParams('step=signup&badge_campaign=bug_whisperer') + + renderWithIntl() + + await waitFor(() => expect(mockClaimAndSettlePendingBadgeCampaigns).toHaveBeenCalledWith(['bug_whisperer'])) + expect(mockClaimAndSettlePendingBadgeCampaigns).toHaveBeenCalledTimes(1) + await waitFor(() => expect(mockAuth.fetchUser).toHaveBeenCalledTimes(1)) + expect(mockRouter.replace).toHaveBeenCalledWith('/home') + expect(mockRouter.replace.mock.invocationCallOrder[0]).toBeGreaterThan( + mockClaimAndSettlePendingBadgeCampaigns.mock.invocationCallOrder[0] + ) + } +) it('does not redirect home when setup unmounts before the native claim settles', async () => { mockAuth.user = { user: { username: 'alice', hasAppAccess: true } } diff --git a/src/app/(setup)/setup/page.tsx b/src/app/(setup)/setup/page.tsx index 7fce3c7c4b..5d1d8b032f 100644 --- a/src/app/(setup)/setup/page.tsx +++ b/src/app/(setup)/setup/page.tsx @@ -60,7 +60,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() @@ -98,7 +98,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 @@ -130,12 +129,7 @@ function SetupPageContent() { const recoveryReason = isLeavingForHome ? null : (initializationError ?? - (!isLoading && - sessionChecked && - !step && - !existingSessionUsername && - !showDeviceNotSupportedModal && - !showBrowserNotSupportedModal + (!isLoading && sessionChecked && !step && !showDeviceNotSupportedModal && !showBrowserNotSupportedModal ? 'missing_step' : null)) @@ -165,21 +159,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(() => { @@ -226,82 +218,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, @@ -314,19 +297,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 @@ -343,8 +313,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. @@ -506,33 +476,6 @@ function SetupPageContent() {
) - if (existingSessionUsername) { - return ( - -
- - -
-
- ) - } - if (showBrowserNotSupportedModal || showDeviceNotSupportedModal) { return } diff --git a/src/app/ClientProviders.tsx b/src/app/ClientProviders.tsx index 819880f4bb..69d0965d70 100644 --- a/src/app/ClientProviders.tsx +++ b/src/app/ClientProviders.tsx @@ -22,10 +22,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' import { PathnamePageviewTracker } from '@/components/Analytics/PathnamePageviewTracker' import { ScreenTransitionTracker } from '@/components/Analytics/ScreenTransitionTracker' @@ -80,7 +81,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 // One mount, used by both branches below. Nothing in it reads a provider: @@ -114,6 +117,7 @@ export function ClientProviders({ children }: { children: React.ReactNode }) { chunk that loads slowly or fails would take readiness down with it. */ {harness} + @@ -139,3 +143,16 @@ export function ClientProviders({ children }: { children: React.ReactNode }) { ) } + +/** Capture every App Router entry after the document-level bootstrap. */ +export function SignupAttributionNavigationCapture({ pathname }: { pathname: string | null }) { + useEffect(() => { + if (!isCapacitor()) { + // instrumentation-client owns the one document-referrer capture. + // Every React entry is therefore referrer-free, including hydration + // of the initial URL and all later SPA transitions. + captureSignupAttribution() + } + }, [pathname]) + return null +} diff --git a/src/app/__tests__/ClientProviders.test.tsx b/src/app/__tests__/ClientProviders.test.tsx index 9b7572c8b6..d2e6566685 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,18 @@ describe('ClientProviders provider order', () => { expect(context).toBeGreaterThanOrEqual(0) expect(intl).toBeLessThan(context) }) + + it('keeps React navigation entries referrer-free after instrumentation bootstrap', async () => { + mockCaptureSignupAttribution.mockClear() + Object.defineProperty(window, 'Capacitor', { configurable: true, value: undefined }) + + const { rerender } = render() + + await waitFor(() => expect(mockCaptureSignupAttribution).toHaveBeenLastCalledWith()) + + rerender() + + await waitFor(() => expect(mockCaptureSignupAttribution).toHaveBeenLastCalledWith()) + expect(mockCaptureSignupAttribution).toHaveBeenCalledTimes(2) + }) }) 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 fd6c32cb1f..b37e3e3d01 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 22c98d9ae7..b3b7353764 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/Invites/InvitesPage.test.tsx b/src/components/Invites/InvitesPage.test.tsx index 91a035b2b1..4753666739 100644 --- a/src/components/Invites/InvitesPage.test.tsx +++ b/src/components/Invites/InvitesPage.test.tsx @@ -163,6 +163,23 @@ describe('invite and badge campaign routing boundaries', () => { expect(mockClaimBadgeCampaigns).not.toHaveBeenCalled() }) + it('auto-routes an authenticated visitor even when the legacy app-access flag is false', async () => { + mockAuth.user = { user: { userId: 'user-1', username: 'member', hasAppAccess: false } } + mockSearch = 'code=alice' + mockQueryResult.data = { + success: true, + attributionResolved: true, + onboardingResolved: true, + username: 'alice', + } + + render() + + await waitFor(() => expect(mockPush).toHaveBeenCalledWith('/profile/alice')) + expect(screen.queryByRole('button', { name: 'Create your wallet' })).not.toBeInTheDocument() + expect(mockClaimBadgeCampaigns).not.toHaveBeenCalled() + }) + it('uses backend validation metadata for an authenticated code-only Offramp journey', async () => { mockSearch = 'code=offramp' mockQueryResult.data = { @@ -440,7 +457,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 +480,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 +532,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 +619,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 +631,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 b976459be7..2b27967b90 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 7f623c5da8..0000000000 --- a/src/components/Invites/JoinWaitlistPage.test.tsx +++ /dev/null @@ -1,145 +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, - // retired offramp wire fields on purpose: the page reads only the claim outcome (TASK-21226) - 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 8e291bbfed..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 { Callout } from '@/components/0_Bruddle/Callout' -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 949880f052..906bb494ec 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 @@ -43,8 +42,8 @@ export function Careers() {

The product is live

- Invite-only doesn't mean quiet. What you ship this week lands on people who are - moving real money this week, in a currency that isn't the one they earn in. + What you ship this week lands on people who are moving real money this week, in a + currency that isn't the one they earn in.

diff --git a/src/components/Migration/DownloadQR.tsx b/src/components/Migration/DownloadQR.tsx index 56f9fb96af..921986e85a 100644 --- a/src/components/Migration/DownloadQR.tsx +++ b/src/components/Migration/DownloadQR.tsx @@ -19,7 +19,16 @@ export default function DownloadQR({ surface, handoff }: { surface: MigrationSur const t = useTranslations('migration') const [payload, setPayload] = useState() useEffect(() => { - setPayload(handoff ? buildDeferredPayload(handoff.dest, handoff.invite) : undefined) + if (!handoff) { + setPayload(undefined) + return + } + try { + setPayload(buildDeferredPayload(handoff.dest, handoff.invite)) + } catch { + // An unusable handoff must not hide the QR or store links. + setPayload(undefined) + } }, [handoff]) useEffect(() => { diff --git a/src/components/Migration/__tests__/DownloadQR.test.tsx b/src/components/Migration/__tests__/DownloadQR.test.tsx index 16884cba70..c85019bf03 100644 --- a/src/components/Migration/__tests__/DownloadQR.test.tsx +++ b/src/components/Migration/__tests__/DownloadQR.test.tsx @@ -1,9 +1,12 @@ import { render, screen } from '@testing-library/react' import DownloadQR from '../DownloadQR' +import { buildDeferredPayload } from '@/utils/deferred-link' jest.mock('next-intl', () => ({ useTranslations: () => (key: string) => key })) jest.mock('posthog-js', () => ({ capture: jest.fn() })) -jest.mock('@/utils/deferred-link', () => ({ buildDeferredPayload: () => 'pnutdl=1&badgeCampaign=door&dest=%2Fcard' })) +jest.mock('@/utils/deferred-link', () => ({ + buildDeferredPayload: jest.fn(() => 'pnutdl=1&badgeCampaign=door&dest=%2Fcard'), +})) jest.mock('@/components/Global/QRCodeWrapper', () => ({ __esModule: true, default: ({ url }: { url: string }) => ( @@ -33,3 +36,14 @@ it('puts an explicit campaign handoff in both the QR and store fallbacks', () => rerender() expect(screen.getByTestId('qr')).toHaveAttribute('href', `${window.location.origin}/home?app_entry=1`) }) + +it('keeps the bare QR and store links when an explicit handoff cannot be built', () => { + ;(buildDeferredPayload as jest.Mock).mockImplementationOnce(() => { + throw new Error('handoff exceeded the Play referrer limit') + }) + + render() + + expect(screen.getByTestId('qr')).toHaveAttribute('href', `${window.location.origin}/home?app_entry=1`) + expect(screen.getByTestId('stores')).toHaveAttribute('data-payload', '') +}) diff --git a/src/components/Profile/components/PublicProfile.tsx b/src/components/Profile/components/PublicProfile.tsx index bed0c5220b..972467fc87 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 7271467962..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 { Callout } from '@/components/0_Bruddle/Callout' -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 4bdbb189d4..8cde52ec5e 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__/SignTestTransaction.test.tsx b/src/components/Setup/Views/__tests__/SignTestTransaction.test.tsx index 0a7df47510..567c20634a 100644 --- a/src/components/Setup/Views/__tests__/SignTestTransaction.test.tsx +++ b/src/components/Setup/Views/__tests__/SignTestTransaction.test.tsx @@ -14,6 +14,8 @@ const mockRouterPush = jest.fn() const mockRouterReplace = jest.fn() const mockAddAccount = jest.fn() const mockSendUserOp = jest.fn() +const mockReadSignupAttributionAsync = jest.fn() +const mockClearSignupAttribution = jest.fn() let accounts: Array<{ type: AccountType }> = [] @@ -52,6 +54,10 @@ jest.mock('@/features/setup/SetupFlowContext', () => ({ jest.mock('@/app/actions/users', () => ({ updateUserById: jest.fn() })) jest.mock('@/utils/passkeyDebug', () => ({ capturePasskeyDebugInfo: jest.fn() })) jest.mock('@/utils/auth.utils', () => ({ clearAuthState: jest.fn() })) +jest.mock('@/utils/signup-attribution', () => ({ + readSignupAttributionAsync: (...args: unknown[]) => mockReadSignupAttributionAsync(...args), + clearSignupAttribution: (...args: unknown[]) => mockClearSignupAttribution(...args), +})) jest.mock('@sentry/nextjs', () => ({ captureException: jest.fn(), addBreadcrumb: jest.fn() })) jest.mock('posthog-js', () => ({ __esModule: true, @@ -67,6 +73,8 @@ describe('SignTestTransaction — setup completion', () => { localStorage.clear() accounts = [] mockSendUserOp.mockResolvedValue({ userOpHash: '0xhash' }) + mockReadSignupAttributionAsync.mockResolvedValue(null) + mockClearSignupAttribution.mockResolvedValue(undefined) // addAccount refetches the user, so the account appears before the // completion redirect — the pre-existing-account effect must not race it. mockAddAccount.mockImplementation(async () => { @@ -128,6 +136,35 @@ describe('SignTestTransaction — setup completion', () => { expect(mockRouterPush).not.toHaveBeenCalled() }) + it('captures a Preferences-only journey after restart before clearing its native context', async () => { + const order: string[] = [] + mockReadSignupAttributionAsync.mockResolvedValue({ + journeyId: '33333333-3333-4333-8333-333333333333', + platform: 'android', + captureMethod: 'browser', + }) + jest.mocked(posthog.capture).mockImplementation((event) => { + if (event === ANALYTICS_EVENTS.SIGNUP_COMPLETED) order.push('capture') + return undefined + }) + mockClearSignupAttribution.mockImplementation(async () => { + order.push('clear') + }) + + renderWithIntl() + fireEvent.click(screen.getByRole('button', { name: /confirm/i })) + + await waitFor(() => expect(mockRouterReplace).toHaveBeenCalledWith('/home')) + expect(posthog.capture).toHaveBeenCalledWith( + ANALYTICS_EVENTS.SIGNUP_COMPLETED, + expect.objectContaining({ + signup_journey_id: '33333333-3333-4333-8333-333333333333', + signup_platform: 'android', + }) + ) + expect(order).toEqual(['capture', 'clear']) + }) + /* * Signup completion declares the account new, switching on the cross-account * guard. Without a classified 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/components/Setup/__tests__/setup-entry.test.ts b/src/components/Setup/__tests__/setup-entry.test.ts index 6f16200837..9c89cd5448 100644 --- a/src/components/Setup/__tests__/setup-entry.test.ts +++ b/src/components/Setup/__tests__/setup-entry.test.ts @@ -33,7 +33,7 @@ describe('resolveSetupEntryStep', () => { it.each([ ['invite code', { hasInviteCode: true }], ['?step=signup', { stepParam: 'signup' }], - ])('skips the invite gate with %s', (_name, overrides) => { + ])('opens signup directly with %s', (_name, overrides) => { expect(resolveSetupEntryStep({ ...base, ...overrides })).toBe('signup') }) diff --git a/src/components/Setup/setup-entry.ts b/src/components/Setup/setup-entry.ts index 11495e1538..2166b97b49 100644 --- a/src/components/Setup/setup-entry.ts +++ b/src/components/Setup/setup-entry.ts @@ -6,7 +6,7 @@ export type SetupEntryStep = Extract export interface SetupEntryInput { /** An invite code from the store, the cookie or `?code=`. */ hasInviteCode: boolean - /** The legacy `?step=` param: `signup` skips the invite gate, `login` lands on Log In. */ + /** The legacy `?step=` param: `signup` opens the form directly, `login` lands on Log In. */ stepParam: string | null /** Native-migration notice window on web: signups are closed, so nothing may skip the landing gate. */ webSignupClosed: boolean @@ -25,10 +25,11 @@ export interface SetupEntryInput { */ export function resolveSetupEntryStep(input: SetupEntryInput): SetupEntryStep { if (input.knownDevice || input.stepParam === 'login') return 'landing' - // ?step=signup is what every campaign entrypoint sends. Neither it nor an - // invite may skip the landing gate while web signups are closed. - const skipInviteGate = (input.hasInviteCode || input.stepParam === 'signup') && !input.webSignupClosed - return skipInviteGate ? 'signup' : 'landing' + // Invite/campaign entrypoints can open the signup form directly. This is + // navigation only: neither the presence nor absence of an invite controls + // account access now that signup is open. + const openSignupDirectly = (input.hasInviteCode || input.stepParam === 'signup') && !input.webSignupClosed + return openSignupDirectly ? 'signup' : 'landing' } function hasCookie(key: string): boolean { diff --git a/src/constants/analytics.consts.ts b/src/constants/analytics.consts.ts index e708facfef..ce9db8ecfa 100644 --- a/src/constants/analytics.consts.ts +++ b/src/constants/analytics.consts.ts @@ -23,11 +23,10 @@ export const ANALYTICS_EVENTS = { SIGNUP_LOGIN_ERROR: 'signup_login_error', PASSKEY_LOGIN_RETRY: 'passkey_login_retry', SIGNUP_CREATE_WALLET_CLICKED: 'signup_create_wallet_clicked', - SIGNUP_WAITLIST_VIEWED: 'signup_waitlist_viewed', SIGNUP_USERNAME_VALIDATED: 'signup_username_validated', - SIGNUP_EXISTING_SESSION_PROMPTED: 'signup_existing_session_prompted', + SIGNUP_INVITER_PROMPT_OPENED: 'signup_inviter_prompt_opened', + SIGNUP_INVITER_ADDED: 'signup_inviter_added', SIGNUP_EXISTING_SESSION_CONTINUED: 'signup_existing_session_continued', - SIGNUP_EXISTING_SESSION_LOGGED_OUT: 'signup_existing_session_logged_out', SIGNUP_PASSKEY_STARTED: 'signup_passkey_started', SIGNUP_PASSKEY_SUCCEEDED: 'signup_passkey_succeeded', SIGNUP_PASSKEY_FAILED: 'signup_passkey_failed', @@ -155,7 +154,6 @@ export const ANALYTICS_EVENTS = { INVITE_CODE_VALIDATED: 'invite_code_validated', INVITE_ACCEPTED: 'invite_accepted', INVITE_ACCEPT_FAILED: 'invite_accept_failed', - WAITLIST_STEP_VIEWED: 'waitlist_step_viewed', // ── Points / Rewards ── POINTS_PAGE_VIEWED: 'points_page_viewed', diff --git a/src/context/__tests__/authContext-logout.test.tsx b/src/context/__tests__/authContext-logout.test.tsx index 1a0503f1ea..4ea07f4373 100644 --- a/src/context/__tests__/authContext-logout.test.tsx +++ b/src/context/__tests__/authContext-logout.test.tsx @@ -17,6 +17,7 @@ const mockCancelQueries = jest.fn().mockResolvedValue(undefined) const mockRefetch = jest.fn().mockResolvedValue({ data: null }) const mockApiFetch = jest.fn().mockResolvedValue(undefined) const mockToastError = jest.fn() +const mockClearSignupAttribution = jest.fn().mockResolvedValue(undefined) jest.mock('next-intl', () => ({ useTranslations: () => (key: string) => key })) jest.mock('@/components/0_Bruddle/Toast', () => ({ useToast: () => ({ error: mockToastError }) })) @@ -38,7 +39,7 @@ jest.mock('@/utils/api-fetch', () => ({ apiFetch: (...args: unknown[]) => mockAp jest.mock('@/utils/auth-token', () => ({ clearAuthToken: jest.fn().mockResolvedValue(undefined) })) jest.mock('@/utils/login-session', () => ({ recoverLoginSession: jest.fn() })) jest.mock('@/utils/cache.utils', () => ({ purgeCaches: jest.fn().mockResolvedValue(undefined) })) -jest.mock('@/utils/crisp', () => ({ resetCrispProxySessions: jest.fn() })) +jest.mock('@/utils/crisp', () => ({ resetCrispProxySessions: jest.fn().mockResolvedValue(undefined) })) jest.mock('@/utils/capacitor', () => ({ isCapacitor: () => false })) jest.mock('@/utils/sentry-lazy', () => ({ captureException: jest.fn(), setUser: jest.fn() })) jest.mock('@/i18n/app/locale-store', () => ({ @@ -56,6 +57,12 @@ jest.mock('@/components/Invites/badge-campaign-context', () => ({ getPendingBadgeCampaigns: () => [], })) jest.mock('@/utils/invite-stash', () => ({ clearInvite: jest.fn() })) +jest.mock('@/utils/signup-attribution', () => ({ + clearSignupAttribution: () => mockClearSignupAttribution(), +})) +jest.mock('@/services/pending-invite-attribution', () => ({ + settlePendingInviteAttribution: jest.fn().mockResolvedValue({ status: 'none' }), +})) jest.mock('posthog-js', () => ({ __esModule: true, default: { identify: jest.fn(), reset: jest.fn(), register: jest.fn() }, @@ -90,6 +97,7 @@ describe('logoutUser', () => { jest.clearAllMocks() mockCancelQueries.mockResolvedValue(undefined) mockRefetch.mockResolvedValue({ data: null }) + mockClearSignupAttribution.mockResolvedValue(undefined) endIntentionalLogout() clearRedirectUrl() clearSessionHeld() @@ -170,4 +178,34 @@ describe('logoutUser', () => { expect(order).toEqual(['api:/users/logout', 'cache-cleared']) }) + + it('awaits signup-attribution cleanup before leaving the account boundary', async () => { + let finishAttributionCleanup: (() => void) | undefined + mockClearSignupAttribution.mockImplementation( + () => + new Promise((resolve) => { + finishAttributionCleanup = resolve + }) + ) + let navigated = false + stubLocation(() => { + navigated = true + }) + const { result } = renderHook(() => useAuth(), { wrapper }) + + let logout: Promise | undefined + act(() => { + logout = result.current.logoutUser({ skipBackendCall: true }) + }) + + await waitFor(() => expect(mockClearSignupAttribution).toHaveBeenCalledTimes(1)) + expect(navigated).toBe(false) + + finishAttributionCleanup?.() + await act(async () => { + await logout + }) + + expect(navigated).toBe(true) + }) }) diff --git a/src/context/authContext.tsx b/src/context/authContext.tsx index 273b83fbce..784d3d4d85 100644 --- a/src/context/authContext.tsx +++ b/src/context/authContext.tsx @@ -34,7 +34,10 @@ 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' import { completeAccountSetup, type AccountSetupOutcome } from '@/services/account-setup' +import { settlePendingInviteAttribution } from '@/services/pending-invite-attribution' interface AuthContextType { user: IUserProfile | null @@ -146,6 +149,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(user.user.userId).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. @@ -153,35 +162,43 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { } }, [user]) - // Returning-user and app-restart recovery. Invite attribution is handled - // elsewhere; this only resumes opaque campaign identities after auth. The - // claim service de-dupes concurrent registration/page attempts and retains - // only retryable tags. + // Returning-user and app-restart recovery. Referral attribution and opaque + // campaigns are independent durable hand-offs; neither blocks app access. + // Both services de-dupe concurrent registration/page attempts. useEffect(() => { const userId = user?.user.userId if (!userId) return - const badgeCampaigns = getPendingBadgeCampaigns() - if (badgeCampaigns.length === 0) return - let cancelled = false - void claimAndSettlePendingBadgeCampaigns(badgeCampaigns).then(async (batch) => { - if (cancelled) return - if (batch.claims.some(isConfirmedBadgeCampaignClaim)) { - try { - await fetchUser() - } catch (error) { - captureException(error, { tags: { error_type: 'campaign_profile_refresh_failed' } }) - } - } - if (batch.pending.length > 0) { - captureException(new Error('authenticated campaign claim retained for retry'), { - tags: { error_type: 'campaign_claim_retryable' }, - extra: { userId, pendingCampaigns: batch.pending, claims: batch.claims }, - }) + void settlePendingInviteAttribution(userId).then(async (outcome) => { + if (cancelled || outcome.status !== 'attributed') return + try { + await fetchUser() + } catch (error) { + captureException(error, { tags: { error_type: 'invite_profile_refresh_failed' } }) } }) + const badgeCampaigns = getPendingBadgeCampaigns() + if (badgeCampaigns.length > 0) { + void claimAndSettlePendingBadgeCampaigns(badgeCampaigns).then(async (batch) => { + if (cancelled) return + if (batch.claims.some(isConfirmedBadgeCampaignClaim)) { + try { + await fetchUser() + } catch (error) { + captureException(error, { tags: { error_type: 'campaign_profile_refresh_failed' } }) + } + } + if (batch.pending.length > 0) { + captureException(new Error('authenticated campaign claim retained for retry'), { + tags: { error_type: 'campaign_claim_retryable' }, + extra: { userId, pendingCampaigns: batch.pending, claims: batch.claims }, + }) + } + }) + } + return () => { cancelled = true } @@ -280,6 +297,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. + await 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/dev/fixtures/registry.ts b/src/dev/fixtures/registry.ts index 70c36cf0ab..bf350cf980 100644 --- a/src/dev/fixtures/registry.ts +++ b/src/dev/fixtures/registry.ts @@ -464,11 +464,6 @@ const REQUEST_PAY_EUR = { } export const FIXTURES: Record = { - 'setup-pending': { - route: '/setup', - about: 'Resume an unfinished account setup', - responses: { 'GET /users/me': { user: { hasAppAccess: false }, accounts: [] } }, - }, // --------------------------------------------------------------------- // One per screen — the known-good default for each. // --------------------------------------------------------------------- diff --git a/src/dev/screens/catalogue.test.ts b/src/dev/screens/catalogue.test.ts index 20e8992386..a1433df6b0 100644 --- a/src/dev/screens/catalogue.test.ts +++ b/src/dev/screens/catalogue.test.ts @@ -15,6 +15,9 @@ describe('screen catalogue', () => { 'p66-bank-local', 'h01-notifications', 'h02-settings', + 'fixture-setup-pending', + 'p50-setup-session', + 'p51-setup-finish', ].filter((id) => ids.has(id)) ).toEqual([]) expect( @@ -36,9 +39,8 @@ describe('screen catalogue', () => { it('orders setup screens by their product journey and keeps rewards invites in Rewards', () => { const setupIds = SCREENS.filter(({ flow }) => flow === 'Setup and login').map(({ id }) => id) - expect(setupIds.slice(0, 10)).toEqual([ + expect(setupIds.slice(0, 8)).toEqual([ '01-a-landing', - '02-a-joinwaitlist', '06-a-signup', '03-a-residence-select', '07-a-setuppasskey', @@ -46,7 +48,6 @@ describe('screen catalogue', () => { '09-a-passkeyinfomodal', '05-a-signtesttransaction', '20-a-setupnotificationsmodal', - 'p51-setup-finish', ]) expect(SCREENS.find(({ id }) => id === 'fixture-rewards-invites')?.flow).toBe('Rewards') expect(SCREENS.every((screen, index) => index === 0 || SCREENS[index - 1].order < screen.order)).toBe(true) diff --git a/src/dev/screens/catalogue.ts b/src/dev/screens/catalogue.ts index 486c4891b1..fea3ad5ec1 100644 --- a/src/dev/screens/catalogue.ts +++ b/src/dev/screens/catalogue.ts @@ -121,7 +121,6 @@ const flowOrder = [ ] const setupJourney: Record = { '01-a-landing': { journey: 'Account setup', step: 10 }, - '02-a-joinwaitlist': { journey: 'Account setup', step: 20 }, '06-a-signup': { journey: 'Account setup', step: 30 }, '03-a-residence-select': { journey: 'Account setup', step: 40 }, '07-a-setuppasskey': { journey: 'Account setup', step: 50 }, @@ -129,9 +128,6 @@ const setupJourney: Record = { '09-a-passkeyinfomodal': { journey: 'Account setup', step: 52 }, '05-a-signtesttransaction': { journey: 'Account setup', step: 60 }, '20-a-setupnotificationsmodal': { journey: 'Account setup', step: 70 }, - 'p51-setup-finish': { journey: 'Account setup', step: 80 }, - 'fixture-setup-pending': { journey: 'Resume setup', step: 110 }, - 'p50-setup-session': { journey: 'Resume setup', step: 120 }, 'fixture-guest-invite': { journey: 'Invite entry', step: 210 }, 'p68-invite': { journey: 'Invite entry', step: 220 }, '10-a-confirminvitemodal': { journey: 'Invite entry', step: 230 }, diff --git a/src/dev/screens/pages.ts b/src/dev/screens/pages.ts index ac609d59cd..9b70f6e8a4 100644 --- a/src/dev/screens/pages.ts +++ b/src/dev/screens/pages.ts @@ -104,8 +104,6 @@ PAGE_CAPTURES.push( { id: 'p44-card-add-to-wallet', name: 'Add card to wallet', route: '/card/add-to-wallet' }, { id: 'p46-card-signature', name: 'Repair card signature', route: '/fix-card-signature' }, { id: 'p47-pay-request', name: 'Pay a request', route: '/pay-request?id=synthetic-request' }, - { id: 'p50-setup-session', name: 'Setup — existing session', route: '/setup', fixture: 'setup-pending' }, - { id: 'p51-setup-finish', name: 'Setup completion', route: '/setup/finish', fixture: 'setup-pending' }, { id: 'p52-kyc-success', name: 'Verification success', route: '/kyc/success' }, { id: 'p53-maintenance', name: 'Maintenance', route: '/maintenance' }, { id: 'p55-send-recipient', name: 'Send to a recipient', route: '/send/demo' }, diff --git a/src/dev/surfaces/list.ts b/src/dev/surfaces/list.ts index 8a37956477..617e30a6b8 100644 --- a/src/dev/surfaces/list.ts +++ b/src/dev/surfaces/list.ts @@ -25,7 +25,6 @@ export type SurfaceMeta = { export const SURFACE_META: Record = { '01-a-landing': { name: 'Landing', path: 'Setup/Views/Landing.tsx' }, - '02-a-joinwaitlist': { name: 'JoinWaitlist', path: 'Setup/Views/JoinWaitlist.tsx' }, '03-a-residence-select': { name: 'Residence — select', path: 'Setup/Views/Residence.tsx' }, '05-a-signtesttransaction': { name: 'SignTestTransaction — account ready', diff --git a/src/dev/surfaces/registry.tsx b/src/dev/surfaces/registry.tsx index 5fb69fd09b..a7e3e6c658 100644 --- a/src/dev/surfaces/registry.tsx +++ b/src/dev/surfaces/registry.tsx @@ -161,10 +161,6 @@ export const SURFACES: Record = { ...SURFACE_META['01-a-landing'], render: () => , }, - '02-a-joinwaitlist': { - ...SURFACE_META['02-a-joinwaitlist'], - render: () => , - }, '03-a-residence-select': { ...SURFACE_META['03-a-residence-select'], render: () => , diff --git a/src/features/home/components/HomeModals.tsx b/src/features/home/components/HomeModals.tsx index cf716bc3bc..ba138e0934 100644 --- a/src/features/home/components/HomeModals.tsx +++ b/src/features/home/components/HomeModals.tsx @@ -17,7 +17,6 @@ import { formatUnits } from 'viem' // wrapped in error boundaries to gracefully handle chunk load failures const BalanceWarningDrawer = lazy(() => import('@/components/Global/BalanceWarningDrawer')) const SetupNotificationsModal = lazy(() => import('@/components/Notifications/SetupNotificationsModal')) -const NoMoreJailDrawer = lazy(() => import('@/components/Global/NoMoreJailDrawer')) const EarlyUserDrawer = lazy(() => import('@/components/Global/EarlyUserDrawer')) const MigrationDownloadModal = lazy(() => import('@/components/Migration/MigrationDownloadModal')) const ScanToDownloadModal = lazy(() => import('@/components/Migration/ScanToDownloadModal')) @@ -45,13 +44,6 @@ export function HomeModals() { // migration download prompt outranks every other home modal (self-gating, // only during the native-migration notice window) const [showMigrationModal, setShowMigrationModal] = useState(false) - // Both celebration drawers open themselves (session storage / a user - // flag), and a pre-lockup invitee can qualify for both at once — two open - // vaul roots would stack overlays and scroll locks. The jail celebration - // goes first; the early-user drawer mounts only once it is out of the way. - const [jailCelebrationPending, setJailCelebrationPending] = useState( - () => typeof window !== 'undefined' && sessionStorage.getItem('showNoMoreJailModal') === 'true' - ) // the migration prompt outranks the post-signup modal; unmounting the // manager skips its onVisibilityChange(false), so clear the state here or // it stays stuck true and suppresses the balance-warning modal @@ -111,23 +103,13 @@ export function HomeModals() { )} - {/* these modals manage their own state internally */} + {/* this modal manages its own state internally */} {!showBalanceWarningDrawer && !showMigrationModal && ( - <> - - - - - - - {!jailCelebrationPending && ( - - - - - - )} - + + + + + )} 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/features/setup/__tests__/useSetupFlow.test.tsx b/src/features/setup/__tests__/useSetupFlow.test.tsx index 3544c569d7..a66c30d54a 100644 --- a/src/features/setup/__tests__/useSetupFlow.test.tsx +++ b/src/features/setup/__tests__/useSetupFlow.test.tsx @@ -62,7 +62,7 @@ describe('useSetupFlow (URL stepper)', () => { const { result } = renderFlow({ screen: 'signup' }) await seedSteps(result) expect(result.current.flow.step?.screenId).toBe('signup') - expect(result.current.flow.currentIndex).toBe(2) + expect(result.current.flow.currentIndex).toBe(1) }) it('an unknown screen id falls back to the first step instead of a dead screen', async () => { @@ -161,10 +161,10 @@ describe('useSetupFlow (URL stepper)', () => { expect(result.current.flow.currentIndex).toBeGreaterThanOrEqual(0) }) - it('a filtered-out screen in the URL resolves to the default, never to no step', async () => { + it('the retired inviter-input screen resolves to the default, never to no step', async () => { + expect(setupScreenIds).not.toContain('welcome') const { result } = renderFlow({ screen: 'welcome' }) - const filteredSteps = setupSteps.filter((step) => step.screenId !== 'welcome') - await seedSteps(result, filteredSteps) + await seedSteps(result) expect(result.current.flow.step).toBeDefined() expect(result.current.flow.step?.screenId).toBe(SETUP_DEFAULT_SCREEN) }) diff --git a/src/hooks/__tests__/useZeroDev-invite-onboarding.test.tsx b/src/hooks/__tests__/useZeroDev-invite-onboarding.test.tsx index a47025b864..7af14cf23a 100644 --- a/src/hooks/__tests__/useZeroDev-invite-onboarding.test.tsx +++ b/src/hooks/__tests__/useZeroDev-invite-onboarding.test.tsx @@ -16,6 +16,10 @@ const mockIsConfirmedBadgeCampaignClaim = jest.fn() const mockIsUnavailableBadgeCampaignClaim = jest.fn() const mockPersistRegistrationBadgeCampaignDestination = jest.fn() const mockSettleShhhhhCampaignContinuation = jest.fn() +const mockEnsureSignupAttributionForRegistration = jest.fn() +const mockMarkSignupAttributionPending = jest.fn() +const mockAttachSignupAttribution = jest.fn() +const mockSignupAnalyticsState = jest.fn() let mockPendingBadgeCampaigns: string[] = [] jest.mock('@/context/authContext', () => ({ @@ -57,7 +61,9 @@ const mockClearInvite = jest.fn() jest.mock('@/utils/invite-stash', () => ({ readInviteCode: () => 'founderhaus', readInviteType: () => 'PAYMENT_LINK', - clearInvite: (...args: unknown[]) => mockClearInvite(...args), + bindInviteToUser: () => true, + clearInviteIfOwnedBy: (...args: unknown[]) => mockClearInvite(...args), + extendInviteForRetry: jest.fn(), })) jest.mock('@/utils/general.utils', () => ({ updateUserPreferences: jest.fn(), @@ -102,6 +108,14 @@ jest.mock('@sentry/nextjs', () => ({ captureException: (...args: unknown[]) => m jest.mock('posthog-js', () => ({ capture: (...args: unknown[]) => mockCapture(...args) })) jest.mock('@/utils/capacitor', () => ({ isCapacitor: () => false, getNativeRpId: () => 'localhost' })) jest.mock('@/utils/demo', () => ({ isDemoMode: () => false })) +jest.mock('@/utils/signup-attribution', () => ({ + ensureSignupAttributionForRegistration: (...args: unknown[]) => mockEnsureSignupAttributionForRegistration(...args), + markSignupAttributionPending: (...args: unknown[]) => mockMarkSignupAttributionPending(...args), + signupAnalyticsState: (...args: unknown[]) => mockSignupAnalyticsState(...args), +})) +jest.mock('@/services/signup-attribution', () => ({ + attachSignupAttribution: (...args: unknown[]) => mockAttachSignupAttribution(...args), +})) describe('useZeroDev registration invite boundary', () => { beforeEach(() => { @@ -110,6 +124,10 @@ describe('useZeroDev registration invite boundary', () => { mockToWebAuthnKey.mockResolvedValue({ id: 'new-passkey' }) mockSettleAcceptedInviteAcquisition.mockReturnValue({ destination: '/home', pending: [] }) mockSettleShhhhhCampaignContinuation.mockReturnValue(undefined) + mockEnsureSignupAttributionForRegistration.mockResolvedValue({ journeyId: 'signup-journey' }) + mockMarkSignupAttributionPending.mockResolvedValue(undefined) + mockAttachSignupAttribution.mockResolvedValue(undefined) + mockSignupAnalyticsState.mockReturnValue('enabled') mockIsConfirmedBadgeCampaignClaim.mockImplementation( (claim: { outcome?: string }) => claim.outcome === 'awarded' || claim.outcome === 'already_owned' ) @@ -118,6 +136,40 @@ describe('useZeroDev registration invite boundary', () => { ) }) + it('durably prepares attribution before registration and only arms attachment when a context exists', async () => { + mockAcceptInvite.mockResolvedValue({ + success: true, + attributionResolved: true, + onboardingResolved: true, + claims: [], + }) + const { result } = renderHook(() => useZeroDev()) + + await act(async () => result.current.handleRegister('new-user')) + + expect(mockEnsureSignupAttributionForRegistration).toHaveBeenCalledTimes(1) + expect(mockEnsureSignupAttributionForRegistration.mock.invocationCallOrder[0]).toBeLessThan( + mockToWebAuthnKey.mock.invocationCallOrder[0] + ) + expect(mockMarkSignupAttributionPending).toHaveBeenCalledWith('registered-user') + expect(mockAttachSignupAttribution).toHaveBeenCalledWith('registered-user') + expect(mockToWebAuthnKey).toHaveBeenCalledWith( + expect.objectContaining({ + passkeyServerHeaders: expect.objectContaining({ 'x-signup-analytics-state': 'enabled' }), + }) + ) + + jest.clearAllMocks() + mockToWebAuthnKey.mockResolvedValue({ id: 'new-passkey' }) + mockEnsureSignupAttributionForRegistration.mockResolvedValue(null) + mockAttachSignupAttribution.mockResolvedValue(undefined) + + await act(async () => result.current.handleRegister('another-user')) + + expect(mockMarkSignupAttributionPending).not.toHaveBeenCalled() + expect(mockAttachSignupAttribution).toHaveBeenCalledWith('registered-user') + }) + it.each(['awarded', 'inactive'] as const)( 'settles a terminal %s campaign-only response without retaining an invite retry or reporting failure', async (outcome) => { diff --git a/src/hooks/useSetupFlow.ts b/src/hooks/useSetupFlow.ts index 9be9d56d9d..adfd463bc9 100644 --- a/src/hooks/useSetupFlow.ts +++ b/src/hooks/useSetupFlow.ts @@ -8,7 +8,7 @@ import { isNativeBridge } from '@/utils/capacitor' import { useCallback, useMemo } from 'react' /** ?screen=, not ?step=: at /setup entry, ?step=signup is an existing contract - * that skips the invite gate (see determineInitialStep). */ + * that opens the signup form directly (see determineInitialStep). */ export const SETUP_SCREEN_PARAM = 'screen' /* diff --git a/src/hooks/useZeroDev.ts b/src/hooks/useZeroDev.ts index da7cfa8024..ed47298e5c 100644 --- a/src/hooks/useZeroDev.ts +++ b/src/hooks/useZeroDev.ts @@ -6,13 +6,7 @@ import { loadingStateContext } from '@/context/loadingStates.context' import { useAuth } from '@/context/authContext' import { useKernelClient } from '@/context/kernelClient.context' import { useZeroDevFlow, zeroDevFlowActions } from '@/hooks/useZeroDevFlow' -import { - getFromCookie, - removeFromCookie, - saveToCookie, - setRedirectUrl, - updateUserPreferences, -} from '@/utils/general.utils' +import { removeFromCookie, saveToCookie, updateUserPreferences } from '@/utils/general.utils' import { clearAuthState } from '@/utils/auth.utils' import { isStaleKeyError, createStaleSessionError } from '@/utils/walletCredential.utils' import { @@ -34,13 +28,11 @@ import { toWebAuthnKey, WebAuthnMode } from '@zerodev/passkey-validator' import { useCallback, useContext } from 'react' import type { TransactionReceipt, Hex, Hash } from 'viem' import { captureException } from '@sentry/nextjs' -import { invitesApi } from '@/services/invites' import { claimAndSettlePendingBadgeCampaigns, isConfirmedBadgeCampaignClaim, isUnavailableBadgeCampaignClaim, } from '@/services/badge-campaigns' -import { settleAcceptedInviteAcquisition } from '@/services/invite-acquisition' import { getPendingBadgeCampaigns } from '@/components/Invites/badge-campaign-context' import { settleShhhhhCampaignContinuation } from '@/app/shhhhh/shhhhh-acquisition' import { signupConsentDocuments } from '@/services/consent' @@ -49,7 +41,13 @@ import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' import { isCapacitor, getNativeRpId } from '@/utils/capacitor' import { isDemoMode } from '@/utils/demo' import { rescueUserOpReceipt, userOpRevertedError } from '@/utils/userop-rescue.utils' -import { clearInvite, extendInviteForRetry, readInviteCode, readInviteType } from '@/utils/invite-stash' +import { attachSignupAttribution } from '@/services/signup-attribution' +import { + ensureSignupAttributionForRegistration, + markSignupAttributionPending, + signupAnalyticsState, +} from '@/utils/signup-attribution' +import { settlePendingInviteAttribution } from '@/services/pending-invite-attribution' // types type UserOpEncodedParams = { @@ -79,10 +77,6 @@ export const useZeroDev = () => { const { isKernelClientReady, isRegistering, isLoggingIn, isSendingUserOp, address } = useZeroDevFlow() const { setWebAuthnKey, getClientForChain, ensureClientForChain } = useKernelClient() const { setLoadingState } = useContext(loadingStateContext) - // invite hand-off lives in cookies so it survives app navigation — TASK-21460 - const inviteCode = readInviteCode() - const inviteType = readInviteType() - // Future note: could be `${username}.${process.env.NEXT_PUBLIC_JUSTANAME_ENS_DOMAIN || 'peanut.me'}` (have to change BE too) const _getPasskeyName = (username: string) => `${username}.peanut.wallet` @@ -113,7 +107,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 signupAttribution = await ensureSignupAttributionForRegistration() // @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 — @@ -127,7 +129,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()), + 'x-signup-analytics-state': signupAnalyticsState(), + }, rpID: rpId, }) ) @@ -135,104 +140,26 @@ export const useZeroDev = () => { // Keep the new key recoverable even if the API session cannot load yet. saveToCookie(WEB_AUTHN_COOKIE_KEY, webAuthnKey, 90) - // 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 }) - - const inviteCodeFromCookie = getFromCookie('inviteCode') - - // invite code can also be store in cookies, so we need to check both - const userInviteCode = inviteCode || inviteCodeFromCookie - const badgeCampaigns = getPendingBadgeCampaigns() - - if (userInviteCode?.trim().length > 0) { - /* - * Fail-open by design: a broken accept must not block signup. But a - * failure here strands the user in the waitlist, so (1) persist the - * code in the cookie — JoinWaitlistPage auto-retries from it, even - * after an app restart — and (2) report to Sentry, not just PostHog: - * a systematic accept failure looks like a completed signup otherwise. - * The cookie is only cleared on confirmed success. - */ - const keepInviteCodeForRetry = () => extendInviteForRetry(30) - const clearAcceptedInviteCode = () => { - clearInvite() - } - try { - const result = await invitesApi.acceptInvite(userInviteCode, inviteType) - let campaignOnlyProcessed = false - if (result.success) { - if (result.legacyAcquisition) { - const acceptedBadgeCampaigns = [result.legacyAcquisition.campaignTag] - const { destination, pending } = settleAcceptedInviteAcquisition( - result.legacyAcquisition, - result.claims - ) - - // Keep an already-published migration continuation - // through setup only after the matching claim confirms. - if (destination !== '/home') setRedirectUrl(destination) - if (pending.some((tag) => tag.toLowerCase() === acceptedBadgeCampaigns[0].toLowerCase())) { - captureException(new Error('accept-time legacy acquisition retained for retry'), { - tags: { error_type: 'invite_accept_campaign_retryable' }, - extra: { - inviteCode: userInviteCode, - pendingCampaigns: pending, - claims: result.claims, - }, - }) - } - if (!result.onboardingResolved) { - // A NONE adapter is not an invite retry. Its - // terminal claim is done; any retryable state is - // now carried solely by the versioned campaign queue. - campaignOnlyProcessed = true - clearAcceptedInviteCode() - } - } - } - - if (result.success && result.onboardingResolved) { - posthog.capture(ANALYTICS_EVENTS.INVITE_ACCEPTED, { - invite_code: userInviteCode, - invite_type: inviteType, - campaign_tag: badgeCampaigns[0], - campaign_tags: badgeCampaigns, - }) - clearAcceptedInviteCode() - } else if (campaignOnlyProcessed) { - // Deliberately no onboarding-failed analytics/Sentry: - // campaign-only compatibility resolved as designed. - } else { - posthog.capture(ANALYTICS_EVENTS.INVITE_ACCEPT_FAILED, { - invite_code: userInviteCode, - error_message: result.success - ? 'Invite did not resolve onboarding' - : 'API returned unsuccessful', - }) - captureException(new Error('register-time invite onboarding unresolved'), { - tags: { error_type: 'invite_accept_failed' }, - extra: { inviteCode: userInviteCode, result }, - }) - keepInviteCodeForRetry() - console.error('Error accepting invite', result) - } - } catch (e) { - posthog.capture(ANALYTICS_EVENTS.INVITE_ACCEPT_FAILED, { - invite_code: userInviteCode, - error_message: String(e), - }) - captureException(e, { - tags: { error_type: 'invite_accept_failed' }, - extra: { inviteCode: userInviteCode }, - }) - keepInviteCodeForRetry() - console.error('Error accepting invite', e) - } + // Arm delivery only after hydration supplies the authoritative + // registrant. An unbound marker could be consumed by a later login. + if (signupAttribution) await markSignupAttributionPending(registeredUser.user.userId) + try { + await attachSignupAttribution(registeredUser.user.userId) + } 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' } }) } + // Fail-open: a referral never blocks open signup. The shared + // authenticated recovery path keeps the inviter until the server + // confirms its immutable reward edge. + await settlePendingInviteAttribution(registeredUser.user.userId) + // Campaign acquisition is independent from invite attribution. It // runs after authentication whether or not an invite was present, // and per-tag settlement keeps only transport/configuration retries. diff --git a/src/i18n/app/__tests__/messages.test.ts b/src/i18n/app/__tests__/messages.test.ts index 58d8900e22..7304488312 100644 --- a/src/i18n/app/__tests__/messages.test.ts +++ b/src/i18n/app/__tests__/messages.test.ts @@ -32,8 +32,7 @@ const CONTEXT_DIVERGENT: Record = { Failed: 'generic status vs. KYC status agreeing with "verificación" (Fallido / Fallida)', Verified: 'badge/KYC status vs. residence chip agreeing with "residencia" (Verificado / Verificada)', 'Settings → Passwords → Search "Peanut"': 'iOS and Android name the settings app differently', - Username: - "signup asks for your own new handle (Tu usuario) vs. waitlist asks for the inviter's (Nombre de usuario)", + Username: 'signup handles and recipient/profile labels need different regional wording', 'Mexican peso bank transfers': 'sentence fragment vs. standalone label casing', 'US dollar bank transfers': 'sentence fragment vs. standalone label casing', 'Euro bank transfers': 'sentence fragment vs. standalone label casing', diff --git a/src/i18n/app/messages/en.json b/src/i18n/app/messages/en.json index 790aa54558..574aedf128 100644 --- a/src/i18n/app/messages/en.json +++ b/src/i18n/app/messages/en.json @@ -535,14 +535,10 @@ "allSetTitle": "You're all set", "allSetDescription": "Now send or request money to get started.", "joinPeanut": "Join Peanut!", - "inviteOnlyLine1": "Peanut is invite-only.", - "inviteOnlyLine2": "Go beg your friend for an invite link!", - "invitedLine": "Peanut is invite-only — and {username} has an invite for you.", - "joinCta": "Claim your invite", - "begShareText": "Bro… I'm on my knees. Peanut is invite-only and I'm locked outside. Save my life and send me your invite", - "begForInvite": "Beg for an invite", + "invitedLine": "{username} invited you to Peanut.", + "joinCta": "Create your wallet", "activityPrivateNote": "Activity is only visible for you, it is not public.", - "noInviteTitle": "No invite, no Peanut", + "noInviteTitle": "Join Peanut", "requestAction": "Request" }, "iosCopy": { @@ -722,10 +718,6 @@ "title": "Peanut makes dollars easy.", "description": "Create your account in seconds to save, send, or cash out dollars fast, wherever you are in the world." }, - "welcome": { - "title": "Peanut is invite-only", - "description": "Who invited you? Enter their username to continue, or join the waitlist and we'll reach out when you're eligible." - }, "signup": { "title": "How should we call you?", "description": "Choose your username. You will use it to send and receive money." @@ -743,12 +735,6 @@ "description": "Just confirm it's you — one tap and you're in." } }, - "existingSession": { - "title": "You're already signed in", - "description": "This device is signed in as {username}. Continue with that account, or log out to create a new one.", - "continueAs": "Continue as {username}", - "logoutAndStartFresh": "Log out & start fresh" - }, "navigation": { "goBack": "Go back", "skip": "Skip", @@ -766,15 +752,17 @@ "recoverWallet": "Need to recover your Peanut account?" }, "waitlist": { - "inviterUsernamePlaceholder": "Username", - "inviterNotFound": "No Peanut member with that username. Double-check your inviter's exact handle.", - "joinWaitlist": "Join waitlist", "loginCanceled": "Login was canceled. Please try again.", "noPasskey": "No passkey found. Please create a wallet first.", "loginUnexpectedError": "An unexpected error occurred during login." }, "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", @@ -1287,7 +1275,7 @@ "devconnectApp": "Devconnect app", "devconnectLogoAlt": "Devconnect Logo", "starAlt": "star", - "invitedBy": "Invited by {username}, you have early access!", + "invitedBy": "Invited by {username}", "requiresVerification": "Requires verification" }, "minAmount": { @@ -2403,32 +2391,14 @@ "illustrationAlt": "Section illustration", "invalidCodeTitle": "Invalid Invite Code", "invalidCodeMessage": "The invite code you are trying to use is invalid. Please check the URL and try again.", - "skipTitle": "You're skipping the waitlist", - "skipDescription": "Someone at Peanut wants you in. Create your wallet and walk straight past the line — no invite code, no queue.", - "skipCta": "Claim your Skip Pass", "vanityTitle": "Claim your badge", "vanityDescription": "You earned a Peanut badge. To claim it, sign up or log in.", "vanityCta": "Sign up", "inviterTitle": "{username} invited you to Peanut", - "inviterDescription": "Members-only access. Use this invite to open your wallet and start sending and receiving money globally.", - "inviterCta": "Claim your spot", + "inviterDescription": "Create your wallet through this link and start sending and receiving money globally.", + "inviterCta": "Create your wallet", "alreadyHaveAccount": "Already have an account? Log in!", - "logIn": "Log in", - "emailTitle": "Stay in the loop", - "emailDescription": "Enter your email so we can reach you when you get access.", - "emailLabel": "Email address", - "accountNotLoaded": "Account not loaded yet. Please wait a moment and try again.", - "emailSavedProfileFailed": "Email saved, but we had trouble loading your profile. Please try again.", - "emailSubmitFailed": "Something went wrong. Please try again or skip this step.", - "notificationsTitle": "Want instant updates?", - "notificationsDescription": "We'll notify you the moment you get access.", - "inviteOnlyTitle": "Peanut is invite-only", - "inLine": "You're in line", - "inLineWithPosition": "You're #{position} in line", - "skipTheLine": "Skip the line — drop the username of the member who invited you.", - "pleaseWait": "Please wait...", - "logInDifferentAccount": "Log in with a different account", - "emailPlaceholder": "you@example.com" + "logIn": "Log in" }, "badges": { "title": "Badges", @@ -3362,14 +3332,14 @@ "descriptionNoRewards": "Share your link so friends can join you on Peanut." }, "confirmInviteModal": { - "title": "Don't lose your invite!", - "description": "This link lets you skip the Peanut waitlist. Using {method} will skip your invite.", - "joinCta": "Join", + "title": "Create your Peanut wallet?", + "description": "Create a wallet to keep this referral attached to your account. Using {method} will continue without signing up.", + "joinCta": "Create wallet", "continueWithMethod": "Continue with {method}" }, "earlyUserModal": { "title": "Earn from invites", - "inviteOnly": "Peanut is now invite-only and you're in!", + "inviteOnly": "Peanut is open to everyone. Your personal link still tracks referral rewards.", "earnRules": "Friends you invite → you earn a cut of their fees. Their invites → you earn a cut of their cut.", "shareSheetTitle": "Share your invite link", "shareCta": "Share Invite link", diff --git a/src/i18n/app/messages/es-419.json b/src/i18n/app/messages/es-419.json index 6e7077dd82..360986c1b6 100644 --- a/src/i18n/app/messages/es-419.json +++ b/src/i18n/app/messages/es-419.json @@ -535,14 +535,10 @@ "allSetTitle": "Todo listo", "allSetDescription": "Ahora envía o solicita dinero para empezar.", "joinPeanut": "¡Únete a Peanut!", - "inviteOnlyLine1": "Peanut es solo por invitación.", - "inviteOnlyLine2": "¡Ve y ruégale a tu amigo por un enlace de invitación!", - "invitedLine": "Peanut es solo por invitación — y {username} tiene una invitación para ti.", - "joinCta": "Reclama tu invitación", - "begShareText": "Bro… estoy de rodillas. Peanut es solo por invitación y me quedé afuera. Sálvame la vida y mándame tu invitación", - "begForInvite": "Rogar por una invitación", + "invitedLine": "{username} te invitó a Peanut.", + "joinCta": "Crea tu billetera", "activityPrivateNote": "La actividad solo es visible para ti, no es pública.", - "noInviteTitle": "Sin invitación no hay Peanut", + "noInviteTitle": "Únete a Peanut", "requestAction": "Solicitar" }, "iosCopy": { @@ -722,10 +718,6 @@ "title": "Peanut hace que los dólares sean fáciles.", "description": "Crea tu cuenta en segundos para guardar, enviar o retirar dólares rápido, estés donde estés en el mundo." }, - "welcome": { - "title": "Peanut es solo con invitación", - "description": "¿Quién te invitó? Ingresa su nombre de usuario para continuar, o únete a la lista de espera y te avisaremos cuando puedas entrar." - }, "signup": { "title": "¿Cómo te llamamos?", "description": "Elige tu nombre de usuario. Lo usarás para enviar y recibir dinero." @@ -743,12 +735,6 @@ "description": "Solo confirma que eres tú: un toque y listo." } }, - "existingSession": { - "title": "Ya tienes una sesión iniciada", - "description": "Este dispositivo tiene una sesión iniciada como {username}. Continúa con esa cuenta o cierra sesión para crear una nueva.", - "continueAs": "Continuar como {username}", - "logoutAndStartFresh": "Cerrar sesión y empezar de cero" - }, "navigation": { "goBack": "Volver", "skip": "Omitir", @@ -766,15 +752,17 @@ "recoverWallet": "¿Necesitas recuperar tu cuenta de Peanut?" }, "waitlist": { - "inviterUsernamePlaceholder": "Nombre de usuario", - "inviterNotFound": "No hay ningún miembro de Peanut con ese nombre de usuario. Verifica el usuario exacto de quien te invitó.", - "joinWaitlist": "Unirme a la lista de espera", "loginCanceled": "El inicio de sesión fue cancelado. Inténtalo de nuevo.", "noPasskey": "No se encontró ninguna passkey. Primero crea una billetera.", "loginUnexpectedError": "Ocurrió un error inesperado al iniciar sesión." }, "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", @@ -1287,7 +1275,7 @@ "devconnectApp": "app de Devconnect", "devconnectLogoAlt": "Logo de Devconnect", "starAlt": "estrella", - "invitedBy": "¡{username} te invitó, tienes acceso anticipado!", + "invitedBy": "Invitación de {username}", "requiresVerification": "Requiere verificación" }, "minAmount": { @@ -2403,32 +2391,14 @@ "illustrationAlt": "Ilustración de la sección", "invalidCodeTitle": "Código de invitación inválido", "invalidCodeMessage": "El código de invitación que intentas usar no es válido. Revisa la URL e inténtalo de nuevo.", - "skipTitle": "Te estás saltando la lista de espera", - "skipDescription": "Alguien de Peanut te quiere adentro. Crea tu billetera y pasa directo: sin código de invitación y sin fila.", - "skipCta": "Reclama tu pase directo", "vanityTitle": "Reclama tu insignia", "vanityDescription": "Ganaste una insignia de Peanut. Para reclamarla, regístrate o inicia sesión.", "vanityCta": "Regístrate", "inviterTitle": "{username} te invitó a Peanut", - "inviterDescription": "Acceso solo para miembros. Usa esta invitación para abrir tu billetera y empezar a enviar y recibir dinero en todo el mundo.", - "inviterCta": "Reclama tu lugar", + "inviterDescription": "Crea tu billetera desde este enlace y empieza a enviar y recibir dinero en todo el mundo.", + "inviterCta": "Crea tu billetera", "alreadyHaveAccount": "¿Ya tienes una cuenta? ¡Inicia sesión!", - "logIn": "Inicia sesión", - "emailTitle": "Mantente al tanto", - "emailDescription": "Ingresa tu correo para que podamos avisarte cuando tengas acceso.", - "emailLabel": "Correo electrónico", - "accountNotLoaded": "Tu cuenta aún no se cargó. Espera un momento e inténtalo de nuevo.", - "emailSavedProfileFailed": "Guardamos tu correo, pero tuvimos problemas para cargar tu perfil. Inténtalo de nuevo.", - "emailSubmitFailed": "Algo salió mal. Inténtalo de nuevo u omite este paso.", - "notificationsTitle": "¿Quieres novedades al instante?", - "notificationsDescription": "Te avisaremos apenas tengas acceso.", - "inviteOnlyTitle": "Peanut es solo con invitación", - "inLine": "Estás en la fila", - "inLineWithPosition": "Estás #{position} en la fila", - "skipTheLine": "Sáltate la fila: escribe el usuario del miembro que te invitó.", - "pleaseWait": "Espera un momento...", - "logInDifferentAccount": "Iniciar sesión con otra cuenta", - "emailPlaceholder": "tu@ejemplo.com" + "logIn": "Inicia sesión" }, "badges": { "title": "Insignias", @@ -3362,14 +3332,14 @@ "descriptionNoRewards": "Comparte tu enlace para que tus amigos se unan a ti en Peanut." }, "confirmInviteModal": { - "title": "¡No pierdas tu invitación!", - "description": "Este enlace te permite saltarte la lista de espera de Peanut. Si usas {method}, perderás tu invitación.", - "joinCta": "Únete", + "title": "¿Crear tu billetera de Peanut?", + "description": "Crea una billetera para que esta referencia quede vinculada a tu cuenta. Si usas {method}, continuarás sin registrarte.", + "joinCta": "Crear billetera", "continueWithMethod": "Continuar con {method}" }, "earlyUserModal": { "title": "Gana con tus invitaciones", - "inviteOnly": "Peanut ahora es solo por invitación, ¡y tú estás dentro!", + "inviteOnly": "Peanut está abierto para todos. Tu enlace personal sigue registrando tus recompensas por referidos.", "earnRules": "Amigos que invitas → ganas una parte de sus comisiones. Sus invitados → ganas una parte de la parte de ellos.", "shareSheetTitle": "Comparte tu enlace de invitación", "shareCta": "Compartir link de invitación", diff --git a/src/i18n/app/messages/es-AR.json b/src/i18n/app/messages/es-AR.json index 208530f067..a6b25ca03e 100644 --- a/src/i18n/app/messages/es-AR.json +++ b/src/i18n/app/messages/es-AR.json @@ -329,11 +329,10 @@ "joinPeanutAlt": "Unite a Peanut", "allSetDescription": "Ahora enviá o solicitá plata para empezar.", "joinPeanut": "¡Unite a Peanut!", - "inviteOnlyLine2": "¡Andá y rogale a tu amigo por un enlace de invitación!", - "invitedLine": "Peanut es solo por invitación — y {username} tiene una invitación para vos.", - "joinCta": "Reclamá tu invitación", - "begShareText": "Bro… estoy de rodillas. Peanut es solo por invitación y me quedé afuera. Salvame la vida y mandame tu invitación", - "activityPrivateNote": "La actividad solo es visible para vos, no es pública." + "invitedLine": "{username} te invitó a Peanut.", + "joinCta": "Creá tu billetera", + "activityPrivateNote": "La actividad solo es visible para vos, no es pública.", + "noInviteTitle": "Unite a Peanut" }, "update": { "title": "Actualización lista", @@ -431,9 +430,6 @@ "landing": { "description": "Creá tu cuenta en segundos para guardar, enviar o retirar dólares rápido, estés donde estés en el mundo." }, - "welcome": { - "description": "¿Quién te invitó? Ingresá su nombre de usuario para continuar, o unite a la lista de espera y te avisaremos cuando puedas entrar." - }, "signup": { "description": "Elegí tu nombre de usuario. Lo vas a usar para enviar y recibir dinero." }, @@ -448,22 +444,18 @@ "description": "Solo confirmá que sos vos: un toque y listo." } }, - "existingSession": { - "title": "Ya tenés una sesión iniciada", - "description": "Este dispositivo tiene una sesión iniciada como {username}. Continuá con esa cuenta o cerrá sesión para crear una nueva." - }, "loginFailed": "No pudimos iniciar tu sesión. Intentalo de nuevo.", "landing": { "signUp": "Registrate", "recoverWallet": "¿Necesitás recuperar tu cuenta de Peanut?" }, "waitlist": { - "inviterNotFound": "No hay ningún miembro de Peanut con ese nombre de usuario. Verificá el usuario exacto de quien te invitó.", "loginCanceled": "El inicio de sesión fue cancelado. Intentalo de nuevo.", "noPasskey": "No se encontró ninguna passkey. Primero creá una billetera." }, "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.", @@ -709,7 +701,7 @@ "accountRequiredDescription": "Necesitás una cuenta de Peanut para reclamar con este método. Creá una y verificá tu identidad, y tus fondos se depositarán en tu banco." }, "actions": { - "invitedBy": "¡{username} te invitó, tenés acceso anticipado!" + "invitedBy": "Invitación de {username}" }, "minAmount": { "description": "El monto mínimo para {method} es ${amount}. Probá con otro método." @@ -1105,24 +1097,13 @@ }, "invites": { "invalidCodeMessage": "El código de invitación que intentás usar no es válido. Revisá la URL e intentalo de nuevo.", - "skipTitle": "Te estás salteando la lista de espera", - "skipDescription": "Alguien de Peanut te quiere adentro. Creá tu billetera y pasá directo: sin código de invitación y sin fila.", - "skipCta": "Reclamá tu pase directo", "vanityTitle": "Reclamá tu insignia", "vanityDescription": "Ganaste una insignia de Peanut. Para reclamarla, registrate o iniciá sesión.", "vanityCta": "Registrate", - "inviterDescription": "Acceso solo para miembros. Usá esta invitación para abrir tu billetera y empezar a enviar y recibir dinero en todo el mundo.", - "inviterCta": "Reclamá tu lugar", + "inviterDescription": "Creá tu billetera desde este enlace y empezá a enviar y recibir dinero en todo el mundo.", + "inviterCta": "Creá tu billetera", "alreadyHaveAccount": "¿Ya tenés una cuenta? ¡Iniciá sesión!", - "logIn": "Iniciá sesión", - "emailTitle": "Mantenete al tanto", - "emailDescription": "Ingresá tu correo para que podamos avisarte cuando tengas acceso.", - "accountNotLoaded": "Tu cuenta aún no se cargó. Esperá un momento e intentalo de nuevo.", - "emailSavedProfileFailed": "Guardamos tu correo, pero tuvimos problemas para cargar tu perfil. Intentalo de nuevo.", - "emailSubmitFailed": "Algo salió mal. Intentalo de nuevo u omití este paso.", - "notificationsTitle": "¿Querés novedades al instante?", - "skipTheLine": "Salteá la fila: escribí el usuario del miembro que te invitó.", - "pleaseWait": "Esperá un momento..." + "logIn": "Iniciá sesión" }, "badges": { "emptyDescription": "Ganá insignias a medida que usás Peanut. Hacé pagos, invitá amigos y participá en eventos para desbloquearlas y mostrarlas en tu perfil.", @@ -1519,12 +1500,12 @@ "shareSheetTitle": "Compartí tu enlace de invitación" }, "confirmInviteModal": { - "description": "Este enlace te permite saltearte la lista de espera de Peanut. Si usás {method}, perderás tu invitación.", - "joinCta": "Unite" + "description": "Creá una billetera para que esta referencia quede vinculada a tu cuenta. Si usás {method}, vas a continuar sin registrarte.", + "joinCta": "Creá tu billetera" }, "earlyUserModal": { "title": "Ganá con tus invitaciones", - "inviteOnly": "Peanut ahora es solo por invitación, ¡y vos estás dentro!", + "inviteOnly": "Peanut está abierto para todos. Tu enlace personal sigue registrando tus recompensas por referidos.", "earnRules": "Amigos que invitás → ganás una parte de sus comisiones. Sus invitados → ganás una parte de la parte de ellos.", "shareSheetTitle": "Compartí tu enlace de invitación" }, diff --git a/src/i18n/app/messages/pt-BR.json b/src/i18n/app/messages/pt-BR.json index 4a647c51d3..b8f5040ea8 100644 --- a/src/i18n/app/messages/pt-BR.json +++ b/src/i18n/app/messages/pt-BR.json @@ -535,14 +535,10 @@ "allSetTitle": "Tudo pronto", "allSetDescription": "Agora envie ou cobre dinheiro para começar.", "joinPeanut": "Entre no Peanut!", - "inviteOnlyLine1": "O Peanut é só por convite.", - "inviteOnlyLine2": "Vá implorar um link de convite para o seu amigo!", - "invitedLine": "O Peanut é só por convite — e {username} tem um convite para você.", - "joinCta": "Resgate seu convite", - "begShareText": "Mano… estou de joelhos. O Peanut é só por convite e fiquei de fora. Salva minha vida e me manda seu convite", - "begForInvite": "Implorar por um convite", + "invitedLine": "{username} convidou você para o Peanut.", + "joinCta": "Crie sua carteira", "activityPrivateNote": "A atividade é visível só para você, não é pública.", - "noInviteTitle": "Sem convite, sem Peanut", + "noInviteTitle": "Entre no Peanut", "requestAction": "Cobrar" }, "iosCopy": { @@ -722,10 +718,6 @@ "title": "Com o Peanut, dólares ficam fáceis.", "description": "Crie sua conta em segundos para guardar, enviar ou sacar dólares rápido, onde quer que você esteja no mundo." }, - "welcome": { - "title": "O Peanut é só por convite", - "description": "Quem convidou você? Digite o nome de usuário da pessoa para continuar, ou entre na lista de espera e avisaremos quando você puder entrar." - }, "signup": { "title": "Como devemos te chamar?", "description": "Escolha seu nome de usuário. Você vai usá-lo para enviar e receber dinheiro." @@ -743,12 +735,6 @@ "description": "É só confirmar que é você — um toque e pronto." } }, - "existingSession": { - "title": "Você já está conectado", - "description": "Este dispositivo está conectado como {username}. Continue com essa conta ou saia para criar uma nova.", - "continueAs": "Continuar como {username}", - "logoutAndStartFresh": "Sair e começar do zero" - }, "navigation": { "goBack": "Voltar", "skip": "Pular", @@ -766,15 +752,17 @@ "recoverWallet": "Precisa recuperar sua conta Peanut?" }, "waitlist": { - "inviterUsernamePlaceholder": "Nome de usuário", - "inviterNotFound": "Não há nenhum membro do Peanut com esse nome de usuário. Confira o usuário exato de quem convidou você.", - "joinWaitlist": "Entrar na lista de espera", "loginCanceled": "O login foi cancelado. Tente de novo.", "noPasskey": "Nenhuma passkey encontrada. Primeiro crie uma carteira.", "loginUnexpectedError": "Ocorreu um erro inesperado ao fazer login." }, "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", @@ -1287,7 +1275,7 @@ "devconnectApp": "app da Devconnect", "devconnectLogoAlt": "Logo da Devconnect", "starAlt": "estrela", - "invitedBy": "{username} te convidou, você tem acesso antecipado!", + "invitedBy": "Convite de {username}", "requiresVerification": "Requer verificação" }, "minAmount": { @@ -2403,32 +2391,14 @@ "illustrationAlt": "Ilustração da seção", "invalidCodeTitle": "Código de convite inválido", "invalidCodeMessage": "O código de convite que você está tentando usar é inválido. Confira a URL e tente de novo.", - "skipTitle": "Você está furando a fila de espera", - "skipDescription": "Alguém do Peanut quer você aqui. Crie sua carteira e passe direto — sem código de convite, sem fila.", - "skipCta": "Resgatar seu passe livre", "vanityTitle": "Resgate seu selo", "vanityDescription": "Você ganhou um selo do Peanut. Para resgatá-lo, cadastre-se ou faça login.", "vanityCta": "Criar conta", "inviterTitle": "{username} convidou você para o Peanut", - "inviterDescription": "Acesso exclusivo para membros. Use este convite para abrir sua carteira e começar a enviar e receber dinheiro no mundo todo.", - "inviterCta": "Garanta sua vaga", + "inviterDescription": "Crie sua carteira por este link e comece a enviar e receber dinheiro no mundo todo.", + "inviterCta": "Crie sua carteira", "alreadyHaveAccount": "Já tem uma conta? Faça login!", - "logIn": "Faça login", - "emailTitle": "Fique por dentro", - "emailDescription": "Informe seu e-mail para avisarmos você quando tiver acesso.", - "emailLabel": "E-mail", - "accountNotLoaded": "Sua conta ainda não carregou. Aguarde um instante e tente de novo.", - "emailSavedProfileFailed": "E-mail salvo, mas tivemos problemas para carregar seu perfil. Tente de novo.", - "emailSubmitFailed": "Algo deu errado. Tente de novo ou pule esta etapa.", - "notificationsTitle": "Quer novidades na hora?", - "notificationsDescription": "Avisamos você assim que tiver acesso.", - "inviteOnlyTitle": "O Peanut é só por convite", - "inLine": "Você está na fila", - "inLineWithPosition": "Você está em #{position} na fila", - "skipTheLine": "Fure a fila: informe o usuário do membro que convidou você.", - "pleaseWait": "Aguarde...", - "logInDifferentAccount": "Entrar com outra conta", - "emailPlaceholder": "voce@exemplo.com" + "logIn": "Faça login" }, "badges": { "title": "Selos", @@ -3362,14 +3332,14 @@ "descriptionNoRewards": "Compartilhe seu link para que seus amigos entrem no Peanut com você." }, "confirmInviteModal": { - "title": "Não perca seu convite!", - "description": "Este link permite pular a lista de espera da Peanut. Usar {method} vai descartar seu convite.", - "joinCta": "Entrar", + "title": "Criar sua carteira Peanut?", + "description": "Crie uma carteira para manter esta indicação vinculada à sua conta. Se usar {method}, você continuará sem se cadastrar.", + "joinCta": "Criar carteira", "continueWithMethod": "Continuar com {method}" }, "earlyUserModal": { "title": "Ganhe com convites", - "inviteOnly": "A Peanut agora é só por convite, e você está dentro!", + "inviteOnly": "O Peanut está aberto para todos. Seu link pessoal continua registrando suas recompensas por indicações.", "earnRules": "Amigos que você convida → você ganha uma parte das taxas deles. Os convites deles → você ganha uma parte da parte deles.", "shareSheetTitle": "Compartilhe seu link de convite", "shareCta": "Compartilhar link de convite", diff --git a/src/services/__tests__/invites-attribution.test.ts b/src/services/__tests__/invites-attribution.test.ts index 4b562258d3..68c937f27c 100644 --- a/src/services/__tests__/invites-attribution.test.ts +++ b/src/services/__tests__/invites-attribution.test.ts @@ -110,12 +110,36 @@ describe('invite attribution contract', () => { await expect(invitesApi.acceptInvite('not-an-invite', EInviteType.PAYMENT_LINK)).resolves.toEqual({ success: false, + retryable: false, + status: 409, attributionResolved: false, onboardingResolved: false, claims: [], }) }) + it('keeps network, 5xx, and transient throttling failures retryable', async () => { + mockServerFetch.mockResolvedValueOnce(response(503, { error: 'Unavailable' })) + await expect(invitesApi.acceptInvite('alice', EInviteType.PAYMENT_LINK)).resolves.toMatchObject({ + success: false, + retryable: true, + status: 503, + }) + + mockServerFetch.mockRejectedValueOnce(new TypeError('fetch failed')) + await expect(invitesApi.acceptInvite('alice', EInviteType.PAYMENT_LINK)).resolves.toMatchObject({ + success: false, + retryable: true, + }) + + mockServerFetch.mockResolvedValueOnce(response(429, { error: 'Rate limited' })) + await expect(invitesApi.acceptInvite('alice', EInviteType.PAYMENT_LINK)).resolves.toMatchObject({ + success: false, + retryable: true, + status: 429, + }) + }) + it('supports a pre-discriminator HTTP 200 accept response', async () => { mockServerFetch.mockResolvedValue(response(200, { message: 'Invite accepted', claims: [] })) diff --git a/src/services/__tests__/pending-invite-attribution.test.ts b/src/services/__tests__/pending-invite-attribution.test.ts new file mode 100644 index 0000000000..70fa4d3b2a --- /dev/null +++ b/src/services/__tests__/pending-invite-attribution.test.ts @@ -0,0 +1,171 @@ +import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' +import { EInviteType } from '@/services/services.types' +import { settlePendingInviteAttribution } from '../pending-invite-attribution' + +const mockAcceptInvite = jest.fn() +const mockBindInviteToUser = jest.fn() +const mockClearInviteIfOwnedBy = jest.fn() +const mockExtendInviteForRetry = jest.fn() +const mockSettleAcceptedInviteAcquisition = jest.fn() +const mockCapture = jest.fn() +const mockCaptureException = jest.fn() +let inviteCode = 'alice' +let inviteType = EInviteType.PAYMENT_LINK + +jest.mock('../invites', () => ({ + invitesApi: { acceptInvite: (...args: unknown[]) => mockAcceptInvite(...args) }, +})) +jest.mock('../invite-acquisition', () => ({ + settleAcceptedInviteAcquisition: (...args: unknown[]) => mockSettleAcceptedInviteAcquisition(...args), +})) +jest.mock('@/utils/invite-stash', () => ({ + readInviteCode: () => inviteCode, + readInviteType: () => inviteType, + bindInviteToUser: (...args: unknown[]) => mockBindInviteToUser(...args), + clearInviteIfOwnedBy: (...args: unknown[]) => mockClearInviteIfOwnedBy(...args), + extendInviteForRetry: (...args: unknown[]) => mockExtendInviteForRetry(...args), +})) +jest.mock('posthog-js', () => ({ capture: (...args: unknown[]) => mockCapture(...args) })) +jest.mock('@/utils/sentry-lazy', () => ({ + captureException: (...args: unknown[]) => mockCaptureException(...args), +})) + +describe('pending invite attribution', () => { + beforeEach(() => { + jest.clearAllMocks() + inviteCode = 'alice' + inviteType = EInviteType.PAYMENT_LINK + mockBindInviteToUser.mockReturnValue(true) + mockClearInviteIfOwnedBy.mockReturnValue(true) + mockSettleAcceptedInviteAcquisition.mockReturnValue({ destination: '/home', pending: [] }) + }) + + it('clears the hand-off only after the referral edge is confirmed', async () => { + mockAcceptInvite.mockResolvedValue({ + success: true, + attributionResolved: true, + onboardingResolved: true, + claims: [], + }) + + await expect(settlePendingInviteAttribution('user-a')).resolves.toMatchObject({ + status: 'attributed', + inviteCode: 'alice', + inviteType: EInviteType.PAYMENT_LINK, + }) + + expect(mockAcceptInvite).toHaveBeenCalledWith('alice', EInviteType.PAYMENT_LINK) + expect(mockClearInviteIfOwnedBy).toHaveBeenCalledWith('alice', 'user-a') + expect(mockExtendInviteForRetry).not.toHaveBeenCalled() + expect(mockCapture).toHaveBeenCalledWith(ANALYTICS_EVENTS.INVITE_ACCEPTED, { + invite_code: 'alice', + invite_type: EInviteType.PAYMENT_LINK, + }) + }) + + it('keeps the inviter for the next authenticated start after a transient failure', async () => { + mockAcceptInvite.mockResolvedValue({ + success: false, + retryable: true, + attributionResolved: false, + onboardingResolved: false, + claims: [], + }) + + await expect(settlePendingInviteAttribution('user-a')).resolves.toMatchObject({ status: 'retryable' }) + + expect(mockClearInviteIfOwnedBy).not.toHaveBeenCalled() + expect(mockExtendInviteForRetry).toHaveBeenCalledWith(30, { inviteCode: 'alice', userId: 'user-a' }) + expect(mockCaptureException).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ tags: { error_type: 'invite_accept_failed' } }) + ) + }) + + it('clears a terminal invalid invite instead of retrying it into a future referral', async () => { + mockAcceptInvite.mockResolvedValue({ + success: false, + retryable: false, + status: 409, + attributionResolved: false, + onboardingResolved: false, + claims: [], + }) + + await expect(settlePendingInviteAttribution('user-a')).resolves.toMatchObject({ status: 'terminal' }) + + expect(mockClearInviteIfOwnedBy).toHaveBeenCalledWith('alice', 'user-a') + expect(mockExtendInviteForRetry).not.toHaveBeenCalled() + expect(mockCaptureException).not.toHaveBeenCalled() + }) + + it('settles a terminal legacy campaign without pretending it has an inviter', async () => { + const legacyAcquisition = { campaignTag: 'founderhaus' } + mockAcceptInvite.mockResolvedValue({ + success: true, + attributionResolved: false, + onboardingResolved: false, + legacyAcquisition, + claims: [{ badgeCampaign: 'founderhaus', outcome: 'awarded' }], + }) + + await expect(settlePendingInviteAttribution('user-a')).resolves.toMatchObject({ status: 'campaign_only' }) + + expect(mockSettleAcceptedInviteAcquisition).toHaveBeenCalledWith(legacyAcquisition, [ + expect.objectContaining({ badgeCampaign: 'founderhaus', outcome: 'awarded' }), + ]) + expect(mockClearInviteIfOwnedBy).toHaveBeenCalledWith('alice', 'user-a') + expect(mockCapture).not.toHaveBeenCalledWith(ANALYTICS_EVENTS.INVITE_ACCEPT_FAILED, expect.anything()) + }) + + it('de-duplicates registration and auth-provider attempts', async () => { + let resolveAccept!: (value: unknown) => void + mockAcceptInvite.mockReturnValue( + new Promise((resolve) => { + resolveAccept = resolve + }) + ) + + const registrationAttempt = settlePendingInviteAttribution('user-a') + const authProviderAttempt = settlePendingInviteAttribution('user-a') + expect(mockAcceptInvite).toHaveBeenCalledTimes(1) + + resolveAccept({ + success: true, + attributionResolved: true, + onboardingResolved: true, + claims: [], + }) + + await expect(Promise.all([registrationAttempt, authProviderAttempt])).resolves.toEqual([ + expect.objectContaining({ status: 'attributed' }), + expect.objectContaining({ status: 'attributed' }), + ]) + expect(mockClearInviteIfOwnedBy).toHaveBeenCalledTimes(1) + }) + + it('does not let another account join or consume the bound retry', async () => { + let resolveAccept!: (value: unknown) => void + mockBindInviteToUser.mockImplementation((userId: string) => userId === 'user-a') + mockAcceptInvite.mockReturnValue( + new Promise((resolve) => { + resolveAccept = resolve + }) + ) + + const accountAAttempt = settlePendingInviteAttribution('user-a') + await expect(settlePendingInviteAttribution('user-b')).resolves.toMatchObject({ + status: 'account_mismatch', + }) + expect(mockAcceptInvite).toHaveBeenCalledTimes(1) + + resolveAccept({ + success: false, + retryable: true, + attributionResolved: false, + onboardingResolved: false, + claims: [], + }) + await expect(accountAAttempt).resolves.toMatchObject({ status: 'retryable' }) + }) +}) diff --git a/src/services/__tests__/signup-attribution.test.ts b/src/services/__tests__/signup-attribution.test.ts new file mode 100644 index 0000000000..bdc40e756e --- /dev/null +++ b/src/services/__tests__/signup-attribution.test.ts @@ -0,0 +1,99 @@ +import { attachSignupAttribution } from '../signup-attribution' + +const mockApiFetch = jest.fn() +const mockClearSignupAttribution = jest.fn() +const mockClearPendingSignupAttribution = jest.fn() +const mockHasPendingSignupAttribution = jest.fn() +const mockReadSignupAttributionAsync = jest.fn() +const mockSerializeSignupAttribution = jest.fn() + +jest.mock('@/utils/api-fetch', () => ({ + apiFetch: (...args: unknown[]) => mockApiFetch(...args), +})) +jest.mock('@/utils/signup-attribution', () => ({ + clearSignupAttribution: (...args: unknown[]) => mockClearSignupAttribution(...args), + clearPendingSignupAttribution: (...args: unknown[]) => mockClearPendingSignupAttribution(...args), + hasPendingSignupAttribution: (...args: unknown[]) => mockHasPendingSignupAttribution(...args), + readSignupAttributionAsync: (...args: unknown[]) => mockReadSignupAttributionAsync(...args), + serializeSignupAttribution: (...args: unknown[]) => mockSerializeSignupAttribution(...args), +})) + +beforeEach(() => { + jest.clearAllMocks() + mockHasPendingSignupAttribution.mockResolvedValue(true) + mockReadSignupAttributionAsync.mockResolvedValue({ journeyId: 'journey-1' }) + mockSerializeSignupAttribution.mockReturnValue('{"journeyId":"journey-1"}') + mockClearPendingSignupAttribution.mockResolvedValue(undefined) +}) + +describe('signup attribution attachment', () => { + it('keeps the durable device copy when the API does not acknowledge the write', async () => { + mockApiFetch.mockResolvedValue({ ok: false, status: 503 }) + + await expect(attachSignupAttribution('user-a')).rejects.toThrow('signup attribution attach failed: 503') + + expect(mockClearSignupAttribution).not.toHaveBeenCalled() + }) + + it('stops retries but retains the context through signup completion after acknowledgement', async () => { + mockApiFetch.mockResolvedValue({ ok: true, status: 200 }) + + await expect(attachSignupAttribution('user-a')).resolves.toBe(true) + + expect(mockApiFetch).toHaveBeenCalledWith('/users/me/signup-attribution', { + method: 'POST', + body: JSON.stringify({ attribution: '{"journeyId":"journey-1"}' }), + redactTelemetry: true, + }) + expect(mockClearPendingSignupAttribution).toHaveBeenCalledWith('user-a') + expect(mockClearSignupAttribution).not.toHaveBeenCalled() + }) + + it('clears an invalid stored payload without entering a POST retry loop', async () => { + mockSerializeSignupAttribution.mockReturnValue(null) + + await expect(attachSignupAttribution('user-a')).resolves.toBe(false) + + expect(mockApiFetch).not.toHaveBeenCalled() + expect(mockClearSignupAttribution).toHaveBeenCalledTimes(1) + }) + + it('collapses concurrent registration and auth-provider attempts into one POST', async () => { + let resolveResponse!: (response: { ok: boolean; status: number }) => void + mockApiFetch.mockReturnValue( + new Promise((resolve) => { + resolveResponse = resolve + }) + ) + + const registrationAttempt = attachSignupAttribution('user-a') + const authProviderAttempt = attachSignupAttribution('user-a') + await Promise.resolve() + await Promise.resolve() + expect(mockApiFetch).toHaveBeenCalledTimes(1) + + resolveResponse({ ok: true, status: 200 }) + + await expect(Promise.all([registrationAttempt, authProviderAttempt])).resolves.toEqual([true, true]) + expect(mockClearPendingSignupAttribution).toHaveBeenCalledWith('user-a') + expect(mockClearSignupAttribution).not.toHaveBeenCalled() + }) + + it('does not upload or join another registrant’s pending journey', async () => { + mockHasPendingSignupAttribution.mockImplementation(async (userId: string) => userId === 'user-a') + let resolveResponse!: (response: { ok: boolean; status: number }) => void + mockApiFetch.mockReturnValue( + new Promise((resolve) => { + resolveResponse = resolve + }) + ) + + const accountAAttempt = attachSignupAttribution('user-a') + await expect(attachSignupAttribution('user-b')).resolves.toBe(false) + expect(mockApiFetch).toHaveBeenCalledTimes(1) + + resolveResponse({ ok: true, status: 200 }) + await expect(accountAAttempt).resolves.toBe(true) + expect(mockClearPendingSignupAttribution).toHaveBeenCalledWith('user-a') + }) +}) diff --git a/src/services/invites.ts b/src/services/invites.ts index 8df1236e1c..c21d6ac7f0 100644 --- a/src/services/invites.ts +++ b/src/services/invites.ts @@ -8,14 +8,32 @@ import { badgeCampaignClaimsFromPayload, type BadgeCampaignClaim } from './badge import { parseLegacyInviteAcquisition, type LegacyInviteAcquisition } from './invite-acquisition' import { isTypedCampaignOnlyInviteResponse, resolveInviteResolutionFlags } from './invite-response' -export type AcceptInviteResult = { - success: boolean +type AcceptInviteResolution = { attributionResolved: boolean onboardingResolved: boolean claims: BadgeCampaignClaim[] legacyAcquisition?: LegacyInviteAcquisition } +export type AcceptInviteResult = + | ({ success: true } & AcceptInviteResolution) + | ({ success: false; retryable: boolean; status?: number } & AcceptInviteResolution) + +function failedAcceptInvite(retryable: boolean, status?: number): AcceptInviteResult { + return { + success: false, + retryable, + ...(status === undefined ? {} : { status }), + attributionResolved: false, + onboardingResolved: false, + claims: [], + } +} + +function isRetryableInviteStatus(status: number): boolean { + return status >= 500 || status === 408 || status === 425 || status === 429 +} + export type ValidateInviteResult = { success: boolean attributionResolved: boolean @@ -26,8 +44,9 @@ export type ValidateInviteResult = { export const invitesApi = { acceptInvite: async (inviteCode: string, type: EInviteType, campaignTag?: string): Promise => { + let response: Response try { - const response = await serverFetch('/invites/accept', { + response = await serverFetch('/invites/accept', { method: 'POST', // Normalize here so hand-typed input (`@alice `, ` Alice`) works no // matter which screen collected it. Legacy ALICEINVITESYOU610 codes @@ -37,30 +56,37 @@ export const invitesApi = { // tag through this compatibility call. body: JSON.stringify({ inviteCode: toInviteCode(inviteCode), type, campaignTag }), }) - const body: unknown = await response.json() - const typedCampaignOnly = - response.status === 409 && - isTypedCampaignOnlyInviteResponse(body) && - !!body && - typeof body === 'object' && - Array.isArray((body as { claims?: unknown }).claims) - if (!response.ok && !typedCampaignOnly) { - return { success: false, attributionResolved: false, onboardingResolved: false, claims: [] } - } - const legacyAcquisition = parseLegacyInviteAcquisition( - body && typeof body === 'object' - ? (body as { legacyAcquisition?: unknown }).legacyAcquisition - : undefined - ) - const resolution = resolveInviteResolutionFlags(body, response.ok) - return { - success: true, - ...resolution, - claims: legacyAcquisition ? badgeCampaignClaimsFromPayload(body, [legacyAcquisition.campaignTag]) : [], - ...(legacyAcquisition ? { legacyAcquisition } : {}), - } } catch { - return { success: false, attributionResolved: false, onboardingResolved: false, claims: [] } + return failedAcceptInvite(true) + } + + let body: unknown + try { + body = await response.json() + } catch { + return failedAcceptInvite(isRetryableInviteStatus(response.status), response.status) + } + const typedCampaignOnly = + response.status === 409 && + isTypedCampaignOnlyInviteResponse(body) && + !!body && + typeof body === 'object' && + Array.isArray((body as { claims?: unknown }).claims) + if (!response.ok && !typedCampaignOnly) { + // Transport failures, server failures, and explicitly transient + // throttling/timeouts retain the referral. Invalid/forbidden codes + // are terminal so they cannot become a future referral later. + return failedAcceptInvite(isRetryableInviteStatus(response.status), response.status) + } + const legacyAcquisition = parseLegacyInviteAcquisition( + body && typeof body === 'object' ? (body as { legacyAcquisition?: unknown }).legacyAcquisition : undefined + ) + const resolution = resolveInviteResolutionFlags(body, response.ok) + return { + success: true, + ...resolution, + claims: legacyAcquisition ? badgeCampaignClaimsFromPayload(body, [legacyAcquisition.campaignTag]) : [], + ...(legacyAcquisition ? { legacyAcquisition } : {}), } }, @@ -102,22 +128,4 @@ export const invitesApi = { return { success: false, attributionResolved: false, onboardingResolved: false, username: '' } } }, - - getWaitlistQueuePosition: async (): Promise<{ success: boolean; position: number }> => { - try { - const response = await serverFetch('/invites/waitlist-position', { - method: 'GET', - }) - - if (!response.ok) { - return { success: false, position: 0 } - } - - const data = await response.json() - return { success: true, position: Number(data.queuePosition) || 0 } - } catch (e) { - console.error('Error getting waitlist queue position:', e) - return { success: false, position: 0 } - } - }, } diff --git a/src/services/pending-invite-attribution.ts b/src/services/pending-invite-attribution.ts new file mode 100644 index 0000000000..115c15d393 --- /dev/null +++ b/src/services/pending-invite-attribution.ts @@ -0,0 +1,120 @@ +import posthog from 'posthog-js' +import { captureException } from '@/utils/sentry-lazy' +import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' +import { + bindInviteToUser, + clearInviteIfOwnedBy, + extendInviteForRetry, + readInviteCode, + readInviteType, +} from '@/utils/invite-stash' +import { settleAcceptedInviteAcquisition } from './invite-acquisition' +import { invitesApi, type AcceptInviteResult } from './invites' +import type { EInviteType } from './services.types' + +export type PendingInviteAttributionOutcome = { + status: 'none' | 'attributed' | 'campaign_only' | 'retryable' | 'terminal' | 'account_mismatch' + inviteCode?: string + inviteType?: EInviteType + result?: AcceptInviteResult + pendingCampaigns?: string[] +} + +const inFlight = new Map>() + +/** + * Records a stashed inviter after authentication. The invite cookie is only + * cleared after the API confirms either the immutable referral edge or a + * terminal legacy campaign-only hand-off. Failures remain durable for the + * next authenticated app start and never block open signup. + */ +export function settlePendingInviteAttribution(userId: string): Promise { + const inviteCode = readInviteCode().trim() + if (!inviteCode) return Promise.resolve({ status: 'none' }) + if (!bindInviteToUser(userId)) return Promise.resolve({ status: 'account_mismatch', inviteCode }) + + const attemptKey = `${userId}:${inviteCode}` + const existing = inFlight.get(attemptKey) + if (existing) return existing + + const attempt = settlePendingInviteAttributionOnce(userId, inviteCode).catch( + (error): PendingInviteAttributionOutcome => { + // Cookie/storage reads are expected to be safe, but attribution is + // never allowed to turn a completed registration into a failed one. + captureException(error, { tags: { error_type: 'invite_attribution_recovery_failed' } }) + return { status: 'retryable' } + } + ) + const trackedAttempt = attempt.finally(() => { + if (inFlight.get(attemptKey) === trackedAttempt) inFlight.delete(attemptKey) + }) + inFlight.set(attemptKey, trackedAttempt) + return trackedAttempt +} + +async function settlePendingInviteAttributionOnce( + userId: string, + inviteCode: string +): Promise { + const inviteType = readInviteType() + try { + const result = await invitesApi.acceptInvite(inviteCode, inviteType) + let pendingCampaigns: string[] = [] + + if (result.success && result.legacyAcquisition) { + pendingCampaigns = settleAcceptedInviteAcquisition(result.legacyAcquisition, result.claims).pending + if (pendingCampaigns.length > 0) { + captureException(new Error('accept-time legacy acquisition retained for retry'), { + tags: { error_type: 'invite_accept_campaign_retryable' }, + extra: { inviteCode, pendingCampaigns, claims: result.claims }, + }) + } + + if (!result.onboardingResolved) { + clearInviteIfOwnedBy(inviteCode, userId) + return { status: 'campaign_only', inviteCode, inviteType, result, pendingCampaigns } + } + } + + if (result.success && result.onboardingResolved) { + posthog.capture(ANALYTICS_EVENTS.INVITE_ACCEPTED, { + invite_code: inviteCode, + invite_type: inviteType, + }) + clearInviteIfOwnedBy(inviteCode, userId) + return { status: 'attributed', inviteCode, inviteType, result, pendingCampaigns } + } + + const retryable = !result.success && result.retryable + posthog.capture(ANALYTICS_EVENTS.INVITE_ACCEPT_FAILED, { + invite_code: inviteCode, + error_message: retryable + ? `Retryable invite failure${result.status ? ` (${result.status})` : ''}` + : result.success + ? 'Invite did not resolve onboarding' + : `Terminal invite failure${result.status ? ` (${result.status})` : ''}`, + }) + if (retryable) { + captureException(new Error('authenticated invite attribution unresolved'), { + tags: { error_type: 'invite_accept_failed' }, + extra: { inviteCode, result }, + }) + extendInviteForRetry(30, { inviteCode, userId }) + return { status: 'retryable', inviteCode, inviteType, result, pendingCampaigns } + } + + clearInviteIfOwnedBy(inviteCode, userId) + return { status: 'terminal', inviteCode, inviteType, result, pendingCampaigns } + } catch (error) { + posthog.capture(ANALYTICS_EVENTS.INVITE_ACCEPT_FAILED, { + invite_code: inviteCode, + error_message: String(error), + }) + captureException(error, { + tags: { error_type: 'invite_accept_failed' }, + extra: { inviteCode }, + }) + extendInviteForRetry(30, { inviteCode, userId }) + return { status: 'retryable', inviteCode, inviteType } + } +} diff --git a/src/services/signup-attribution.ts b/src/services/signup-attribution.ts new file mode 100644 index 0000000000..6a6d9d8cc8 --- /dev/null +++ b/src/services/signup-attribution.ts @@ -0,0 +1,50 @@ +import { apiFetch } from '@/utils/api-fetch' +import { + clearPendingSignupAttribution, + clearSignupAttribution, + hasPendingSignupAttribution, + readSignupAttributionAsync, + serializeSignupAttribution, +} from '@/utils/signup-attribution' + +const attachInFlight = new Map>() + +/** + * 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(userId: string): Promise { + const existing = attachInFlight.get(userId) + if (existing) return existing + const attempt = doAttachSignupAttribution(userId).finally(() => { + if (attachInFlight.get(userId) === attempt) attachInFlight.delete(userId) + }) + attachInFlight.set(userId, attempt) + return attempt +} + +async function doAttachSignupAttribution(userId: string): Promise { + if (!(await hasPendingSignupAttribution(userId))) return false + const context = await readSignupAttributionAsync() + if (!context) return false + const serialized = serializeSignupAttribution(context) + if (!serialized) { + await clearSignupAttribution() + return false + } + + const response = await apiFetch('/users/me/signup-attribution', { + method: 'POST', + body: JSON.stringify({ attribution: serialized }), + 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. Stop delivery retries, but retain the + // bounded context until signup_completed emits the client-side join key. + await clearPendingSignupAttribution(userId) + return true +} diff --git a/src/utils/__tests__/deferred-link.test.ts b/src/utils/__tests__/deferred-link.test.ts index 575aa12ae4..ad562d0fe7 100644 --- a/src/utils/__tests__/deferred-link.test.ts +++ b/src/utils/__tests__/deferred-link.test.ts @@ -8,10 +8,12 @@ import { playStoreUrlWithReferrer, restoreDeferredContext, APP_LOCALE_KEY, + MAX_PLAY_REFERRER_LENGTH, } from '../deferred-link' 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() @@ -51,10 +53,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), }, })) @@ -83,6 +87,111 @@ 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('preserves the inviter when a maximum-size attribution cannot fit the Play referrer', () => { + const maxTag = 'x'.repeat(128) + saveToCookie('inviteCode', 'alice') + saveToCookie(SIGNUP_ATTRIBUTION_COOKIE, { + schemaVersion: '1', + journeyId: '33333333-3333-4333-8333-333333333333', + platform: 'android', + analyticsState: 'enabled', + captureMethod: 'browser', + firstTouch: { + occurredAt: new Date().toISOString(), + utmSource: maxTag, + utmMedium: maxTag, + utmCampaign: maxTag, + utmContent: maxTag, + referrerHost: `${maxTag}.example`, + path: `/en/blog/${maxTag}`, + }, + lastTouch: { + occurredAt: new Date().toISOString(), + utmSource: maxTag, + utmMedium: maxTag, + utmCampaign: maxTag, + utmContent: maxTag, + referrerHost: `${maxTag}.example`, + path: `/en/blog/${maxTag}`, + }, + }) + + const payload = buildDeferredPayload('/home') + expect(encodeURIComponent(payload).length).toBeLessThanOrEqual(512) + expect(parseDeferredPayload(payload)).toMatchObject({ invite: 'alice' }) + expect(parseDeferredPayload(payload)?.attribution).toBeUndefined() + }) + + it('drops oversized attribution instead of aborting a direct Play handoff', () => { + const maxTag = 'x'.repeat(128) + saveToCookie(SIGNUP_ATTRIBUTION_COOKIE, { + schemaVersion: '1', + journeyId: '33333333-3333-4333-8333-333333333333', + platform: 'android', + analyticsState: 'enabled', + captureMethod: 'browser', + firstTouch: { + occurredAt: new Date().toISOString(), + utmSource: maxTag, + utmMedium: maxTag, + utmCampaign: maxTag, + utmContent: maxTag, + referrerHost: `${maxTag}.example`, + path: `/en/blog/${maxTag}`, + }, + lastTouch: { + occurredAt: new Date().toISOString(), + utmSource: maxTag, + utmMedium: maxTag, + utmCampaign: maxTag, + utmContent: maxTag, + referrerHost: `${maxTag}.example`, + path: `/en/blog/${maxTag}`, + }, + }) + + const payload = buildDeferredPayload('/home') + expect(encodeURIComponent(payload).length).toBeLessThanOrEqual(512) + expect(parseDeferredPayload(payload)?.attribution).toBeUndefined() + expect(playStoreUrlWithReferrer(payload)).toContain('&referrer=') + }) + + it('keeps the store handoff usable when even the inviter exceeds the Play limit', () => { + window.history.replaceState({}, '', '/es-419/home') + const payload = buildDeferredPayload('/home', 'x'.repeat(MAX_PLAY_REFERRER_LENGTH)) + + expect(encodeURIComponent(payload).length).toBeLessThanOrEqual(MAX_PLAY_REFERRER_LENGTH) + expect(parseDeferredPayload(payload)).toEqual({}) + expect(playStoreUrlWithReferrer(payload)).toContain('&referrer=') + expect(playStoreUrlWithReferrer('x'.repeat(MAX_PLAY_REFERRER_LENGTH + 1))).not.toContain('&referrer=') + }) + it('round-trips a full payload including an encoded dest with query', () => { window.history.replaceState({}, '', '/es-419/some-page') saveToCookie('inviteCode', 'abc123') @@ -390,6 +499,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__/invite-stash.test.ts b/src/utils/__tests__/invite-stash.test.ts index 7055a6e458..744d24abf3 100644 --- a/src/utils/__tests__/invite-stash.test.ts +++ b/src/utils/__tests__/invite-stash.test.ts @@ -5,11 +5,20 @@ // from an earlier flow misclassify the invite. These run the REAL cookie // round trip — every other spec mocks this module. import { EInviteType } from '@/services/services.types' -import { stashInvite, readInviteCode, readInviteType, extendInviteForRetry, clearInvite } from '@/utils/invite-stash' +import { + bindInviteToUser, + clearInvite, + clearInviteIfOwnedBy, + extendInviteForRetry, + readInviteCode, + readInviteType, + readInviteUserId, + stashInvite, +} from '@/utils/invite-stash' import { getFromCookie } from '@/utils/general.utils' const wipeCookies = () => { - for (const name of ['inviteCode', 'inviteType']) { + for (const name of ['inviteCode', 'inviteType', 'inviteAttributionUserId']) { document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT` } } @@ -38,6 +47,15 @@ describe('invite-stash', () => { stashInvite('bob', EInviteType.DIRECT) expect(readInviteCode()).toBe('bob') expect(readInviteType()).toBe(EInviteType.DIRECT) + expect(readInviteUserId()).toBe('') + }) + + it('binds a pending invite to only the first authenticated account', () => { + stashInvite('alice', EInviteType.DIRECT) + expect(bindInviteToUser('user-a')).toBe(true) + expect(readInviteUserId()).toBe('user-a') + expect(bindInviteToUser('user-b')).toBe(false) + expect(readInviteUserId()).toBe('user-a') }) it('extendInviteForRetry keeps code AND type together', () => { @@ -55,8 +73,21 @@ describe('invite-stash', () => { it('clearInvite blanks both fields', () => { stashInvite('alice', EInviteType.PAYMENT_LINK) + bindInviteToUser('user-a') clearInvite() expect(readInviteCode()).toBe('') expect(readInviteType()).toBe(EInviteType.DIRECT) + expect(readInviteUserId()).toBe('') + }) + + it('an older request cannot clear a newer account hand-off', () => { + stashInvite('alice', EInviteType.DIRECT) + bindInviteToUser('user-a') + stashInvite('bob', EInviteType.DIRECT) + bindInviteToUser('user-b') + + expect(clearInviteIfOwnedBy('alice', 'user-a')).toBe(false) + expect(readInviteCode()).toBe('bob') + expect(readInviteUserId()).toBe('user-b') }) }) diff --git a/src/utils/__tests__/qr-sdk-wiring.test.ts b/src/utils/__tests__/qr-sdk-wiring.test.ts index 9a3206b22d..f6eab6b12a 100644 --- a/src/utils/__tests__/qr-sdk-wiring.test.ts +++ b/src/utils/__tests__/qr-sdk-wiring.test.ts @@ -1,5 +1,6 @@ const mockInit = jest.fn() const mockSentryInit = jest.fn() +const mockCaptureSignupAttribution = jest.fn() jest.mock('posthog-js', () => ({ __esModule: true, default: { @@ -17,6 +18,10 @@ jest.mock('@sentry/nextjs', () => ({ })) jest.mock('../defer-analytics', () => ({ whenIdle: jest.fn() })) jest.mock('../web-vitals-shim', () => ({ startWebVitalsShim: jest.fn() })) +jest.mock('@/utils/signup-attribution', () => ({ + captureSignupAttribution: mockCaptureSignupAttribution, + signupAttributionPosthogProperties: jest.fn(() => ({})), +})) const payload = '/qr-pay?qrCode=private-payload&pixKey=private-key' const savedEnv = { ...process.env } @@ -35,6 +40,7 @@ it.each([false, true])('installs QR privacy for PostHog pageviews, replay and na delete process.env.NEXT_PUBLIC_PERF_BARE mockInit.mockClear() mockSentryInit.mockClear() + mockCaptureSignupAttribution.mockClear() window.history.replaceState({}, '', payload) jest.isolateModules(() => require('../../../instrumentation-client')) await new Promise((resolve) => setTimeout(resolve, 0)) @@ -55,8 +61,11 @@ it.each([false, true])('installs QR privacy for PostHog pageviews, replay and na config.before_send({ event: 'web_vital', properties: { navigationURL: payload } }).properties.navigationURL ).toBe('[REDACTED QR DATA]') if (native) { + expect(mockCaptureSignupAttribution).not.toHaveBeenCalled() const sentry = mockSentryInit.mock.calls[0][0] expect(sentry.beforeSendTransaction({ transaction: payload }).transaction).toBe('[REDACTED QR DATA]') expect(sentry.beforeSend({ request: { url: payload } }).request.url).toBe('[REDACTED QR DATA]') + } else { + expect(mockCaptureSignupAttribution).toHaveBeenCalledWith({ includeDocumentReferrer: 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..58b3629515 --- /dev/null +++ b/src/utils/__tests__/signup-attribution.test.ts @@ -0,0 +1,267 @@ +import { + buildSignupAttributionHeader, + captureSignupAttribution, + clearSignupAttribution, + clearPendingSignupAttribution, + ensureSignupAttributionForRegistration, + hasPendingSignupAttribution, + markSignupAttributionPending, + parseSignupAttribution, + readSignupAttribution, + readSignupAttributionAsync, + restoreSignupAttribution, + serializeSignupAttribution, + SIGNUP_ATTRIBUTION_COOKIE, + signupAttributionPosthogProperties, +} from '../signup-attribution' + +const mockPreferencesGet = jest.fn() +const mockPreferencesSet = jest.fn() +const mockPreferencesRemove = jest.fn() + +jest.mock('@capacitor/preferences', () => ({ + Preferences: { + get: (...args: unknown[]) => mockPreferencesGet(...args), + set: (...args: unknown[]) => mockPreferencesSet(...args), + remove: (...args: unknown[]) => mockPreferencesRemove(...args), + }, +})) + +const clearAttributionCookie = () => { + document.cookie = 'signupAttribution=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/' +} + +beforeEach(async () => { + process.env.NEXT_PUBLIC_CAPACITOR_BUILD = 'false' + await clearPendingSignupAttribution() + clearAttributionCookie() + window.history.replaceState({}, '', '/') + Object.defineProperty(document, 'referrer', { configurable: true, value: '' }) + mockPreferencesGet.mockResolvedValue({ value: null }) + mockPreferencesSet.mockResolvedValue(undefined) + mockPreferencesRemove.mockResolvedValue(undefined) +}) + +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({ includeDocumentReferrer: true }) + 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', + }) + expect(second?.lastTouch?.referrerHost).toBeUndefined() + }) + + it('does not replay the document referrer on an untagged SPA navigation', () => { + window.history.replaceState({}, '', '/blog/creator-guide') + Object.defineProperty(document, 'referrer', { + configurable: true, + value: 'https://example.com/article', + }) + + const first = captureSignupAttribution({ includeDocumentReferrer: true }) + window.history.replaceState({}, '', '/signup') + const second = captureSignupAttribution() + + expect(first?.lastTouch).toMatchObject({ referrerHost: 'example.com', path: '/blog/creator-guide' }) + expect(second?.lastTouch).toEqual(first?.lastTouch) + }) + + 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) + }) + + it('creates and persists a journey for a direct native registration', async () => { + process.env.NEXT_PUBLIC_CAPACITOR_BUILD = 'true' + window.history.replaceState({}, '', '/setup') + + const context = await ensureSignupAttributionForRegistration() + + expect(context).toMatchObject({ platform: 'android', captureMethod: 'browser', firstTouch: { path: '/setup' } }) + expect(mockPreferencesSet).toHaveBeenCalledWith({ + key: 'signup-attribution', + value: JSON.stringify(context), + }) + }) + + it('recovers a Preferences-only journey after the native WebView cookie is lost', async () => { + process.env.NEXT_PUBLIC_CAPACITOR_BUILD = 'true' + const context = { + schemaVersion: '1' as const, + journeyId: '33333333-3333-4333-8333-333333333333', + platform: 'android' as const, + analyticsState: 'enabled' as const, + captureMethod: 'browser' as const, + firstTouch: { occurredAt: new Date().toISOString(), path: '/setup' }, + } + mockPreferencesGet.mockResolvedValue({ value: JSON.stringify(context) }) + + expect(readSignupAttribution()).toBeNull() + await expect(readSignupAttributionAsync()).resolves.toEqual(context) + }) + + 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('rejects extra fields and bounds canonical uploads', () => { + const occurredAt = new Date().toISOString() + const maxTag = 'x'.repeat(128) + const touch = { + occurredAt, + utmSource: maxTag, + utmMedium: maxTag, + utmCampaign: maxTag, + utmContent: maxTag, + referrerHost: `${maxTag}.example`, + path: `/blog/${'x'.repeat(500)}`, + } + const context = { + schemaVersion: '1' as const, + journeyId: '33333333-3333-4333-8333-333333333333', + platform: 'android' as const, + analyticsState: 'enabled' as const, + captureMethod: 'deferred_link' as const, + firstTouch: touch, + firstContentTouch: touch, + lastTouch: touch, + } + + expect(`${SIGNUP_ATTRIBUTION_COOKIE}=${encodeURIComponent(JSON.stringify(context))}`.length).toBeGreaterThan( + 4096 + ) + const serialized = serializeSignupAttribution(context) + expect(serialized).not.toBeNull() + expect(serialized!.length).toBeLessThanOrEqual(4096) + expect(parseSignupAttribution(serialized)).not.toBeNull() + expect(parseSignupAttribution(JSON.stringify({ ...context, unexpected: 'field' }))).toBeNull() + expect(parseSignupAttribution(JSON.stringify({ ...context, unexpected: 'x'.repeat(5000) }))).toBeNull() + }) + + it('bounds the percent-encoded cookie while preserving the first campaign touch', () => { + const occurredAt = new Date().toISOString() + const maxSpacedTag = `x${' '.repeat(126)}x` + const slashHeavyPath = `/blog/${'a/'.repeat(250)}` + const touch = { + occurredAt, + utmSource: maxSpacedTag, + utmMedium: maxSpacedTag, + utmCampaign: maxSpacedTag, + utmContent: maxSpacedTag, + referrerHost: `${'x'.repeat(120)}.example`, + path: slashHeavyPath, + } + const context = { + schemaVersion: '1' as const, + journeyId: '33333333-3333-4333-8333-333333333333', + platform: 'web' as const, + analyticsState: 'enabled' as const, + captureMethod: 'browser' as const, + firstTouch: touch, + firstContentTouch: touch, + lastTouch: touch, + } + + const serialized = serializeSignupAttribution(context) + expect(serialized).not.toBeNull() + expect(`${SIGNUP_ATTRIBUTION_COOKIE}=${encodeURIComponent(serialized!)}`.length).toBeLessThanOrEqual(4096) + expect(parseSignupAttribution(serialized)?.firstTouch).toMatchObject({ + utmSource: maxSpacedTag, + utmCampaign: maxSpacedTag, + path: slashHeavyPath, + }) + }) + + it('awaits both native attribution removals at an account boundary', async () => { + process.env.NEXT_PUBLIC_CAPACITOR_BUILD = 'true' + + await clearSignupAttribution() + + expect(mockPreferencesRemove).toHaveBeenCalledWith({ key: 'signup-attribution-pending' }) + expect(mockPreferencesRemove).toHaveBeenCalledWith({ key: 'signup-attribution' }) + }) + + it('removes an invalid native payload so it cannot retry forever', async () => { + process.env.NEXT_PUBLIC_CAPACITOR_BUILD = 'true' + mockPreferencesGet.mockResolvedValue({ value: JSON.stringify({ unexpected: 'legacy-payload' }) }) + + await expect(readSignupAttributionAsync()).resolves.toBeNull() + + expect(mockPreferencesRemove).toHaveBeenCalledWith({ key: 'signup-attribution-pending' }) + expect(mockPreferencesRemove).toHaveBeenCalledWith({ key: 'signup-attribution' }) + }) + + it('limits authenticated finalization retries to a completed signup marker', async () => { + expect(await hasPendingSignupAttribution('user-a')).toBe(false) + await markSignupAttributionPending('user-a') + expect(await hasPendingSignupAttribution('user-a')).toBe(true) + expect(await hasPendingSignupAttribution('user-b')).toBe(false) + await clearPendingSignupAttribution('user-b') + expect(await hasPendingSignupAttribution('user-a')).toBe(true) + await clearPendingSignupAttribution('user-a') + expect(await hasPendingSignupAttribution('user-a')).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 2417e6ca4d..73fe691bc8 100644 --- a/src/utils/deferred-link.ts +++ b/src/utils/deferred-link.ts @@ -22,10 +22,20 @@ import { parsePendingBadgeCampaigns, queuePendingBadgeCampaigns, } from '@/components/Invites/badge-campaign-context' +import { + parseSignupAttribution, + persistSignupAttribution, + readSignupAttribution, + restoreSignupAttribution, + signupAttributionPosthogProperties, + type SignupAttributionContext, +} from './signup-attribution' // 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 @@ -51,9 +61,108 @@ 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 } +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 }> }>( @@ -106,6 +215,9 @@ export function buildDeferredPayload(dest?: string, invite?: string): string { params.append(BADGE_CAMPAIGN_QUERY_PARAM, badgeCampaign) } + 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 // restored claim page would render unclaimable. the working path is the @@ -114,12 +226,38 @@ 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) + if (encodeURIComponent(params.toString()).length > MAX_PLAY_REFERRER_LENGTH) { + // Destination and badge identities are useful but optional. Preserve + // the inviter ahead of marketing attribution: the invite creates the + // durable person-to-person rewards edge, while attribution can still + // be captured directly by the newly installed app. + params.delete('dest') + params.delete('badge_campaign') + params.delete('badgeCampaign') + } + + if (encodeURIComponent(params.toString()).length > MAX_PLAY_REFERRER_LENGTH) { + params.delete(ATTRIBUTION_PARAM) + } + + if (encodeURIComponent(params.toString()).length > MAX_PLAY_REFERRER_LENGTH) { + // Keep the inviter ahead of the locale. A malformed or unexpectedly + // long invite must not prevent the QR and store links from rendering. + params.delete('lang') + } + + if (encodeURIComponent(params.toString()).length > MAX_PLAY_REFERRER_LENGTH) { + params.delete('invite') + } + return params.toString() } /** 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) return PLAY_STORE_URL + return `${PLAY_STORE_URL}&referrer=${encoded}` } /** @@ -158,10 +296,13 @@ 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 = + parseAttributionHandoff(params.get(ATTRIBUTION_PARAM)) ?? 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 +448,29 @@ async function doRestore(): Promise { } const restored = applyDeferredPayload(payload) - captureRestore(channel, DEFERRED_LINK_OUTCOMES.RESTORED, { + if (payload.attribution) { + const persisted = readSignupAttribution() + if (persisted) await persistSignupAttribution(persisted) + } + 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 +491,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/general.utils.ts b/src/utils/general.utils.ts index 31ae64029a..c27b0a0af4 100644 --- a/src/utils/general.utils.ts +++ b/src/utils/general.utils.ts @@ -1222,8 +1222,8 @@ export function slugify(text: string): string { * the backend (peanut-api-ts `extractUsernameFromInvite` uppercases the input * and matches the old suffixes), so existing shared links keep working. * - * Also tolerates hand-typed input ("Who invited you?" asks for a username, so - * people paste `@alice ` or ` Alice`): trims whitespace and strips a leading @. + * Also tolerates copied profile handles (`@alice ` or ` Alice`): trims + * whitespace and strips a leading @. */ export { toInviteCode } export { jsonStringify, jsonParse, saveToCookie, getFromCookie, sanitizeRedirectURL } from '@/utils/cookie-url.utils' diff --git a/src/utils/invite-code.utils.ts b/src/utils/invite-code.utils.ts index 18b67d95cc..992ac3954a 100644 --- a/src/utils/invite-code.utils.ts +++ b/src/utils/invite-code.utils.ts @@ -1,8 +1,8 @@ /** * Normalises a username into an invite code. * - * Tolerates hand-typed input ("Who invited you?" asks for a username, so people - * paste `@alice ` or ` Alice`): trims whitespace and strips a leading @. + * Tolerates copied profile handles (`@alice ` or ` Alice`): trims whitespace + * and strips a leading @. * * Kept in its own module because `demo.ts` needs only this helper, and * importing it from `general.utils` drags that module's chain/token metadata diff --git a/src/utils/invite-stash.ts b/src/utils/invite-stash.ts index 3e6fac7083..2613d773d1 100644 --- a/src/utils/invite-stash.ts +++ b/src/utils/invite-stash.ts @@ -24,11 +24,14 @@ import { getFromCookie, saveToCookie } from '@/utils/cookie-url.utils' const INVITE_CODE_COOKIE = 'inviteCode' const INVITE_TYPE_COOKIE = 'inviteType' +const INVITE_USER_COOKIE = 'inviteAttributionUserId' /** Session scope (no expiry): attribution self-heals on app restart. */ export function stashInvite(code: string, type: EInviteType): void { saveToCookie(INVITE_CODE_COOKIE, code) saveToCookie(INVITE_TYPE_COOKIE, type) + // A newly opened invite is not owned by an earlier authenticated retry. + saveToCookie(INVITE_USER_COOKIE, '') } export function readInviteCode(): string { @@ -40,19 +43,49 @@ export function readInviteType(): EInviteType { return Object.values(EInviteType).includes(raw as EInviteType) ? (raw as EInviteType) : EInviteType.DIRECT } +export function readInviteUserId(): string { + const raw = getFromCookie(INVITE_USER_COOKIE) + return typeof raw === 'string' ? raw : '' +} + +/** Bind a pre-auth invite to the first account that attempts acceptance. */ +export function bindInviteToUser(userId: string): boolean { + const normalizedUserId = userId.trim() + if (!normalizedUserId) return false + const boundUserId = readInviteUserId() + if (boundUserId && boundUserId !== normalizedUserId) return false + if (!boundUserId) saveToCookie(INVITE_USER_COOKIE, normalizedUserId) + return true +} + +function isOwnedInvite(inviteCode: string, userId: string): boolean { + return readInviteCode() === inviteCode && readInviteUserId() === userId +} + /** * A failed /invites/accept keeps the invite for a later retry — both fields, * same extended lifetime, or the retry would re-send the code with a decayed * type (Chip review round 1: the old path extended the code alone). */ -export function extendInviteForRetry(days: number = 30): void { +export function extendInviteForRetry(days: number = 30, expected?: { inviteCode: string; userId: string }): void { + if (expected && !isOwnedInvite(expected.inviteCode, expected.userId)) return const code = readInviteCode() if (!code) return saveToCookie(INVITE_CODE_COOKIE, code, days) saveToCookie(INVITE_TYPE_COOKIE, readInviteType(), days) + const userId = readInviteUserId() + if (userId) saveToCookie(INVITE_USER_COOKIE, userId, days) } export function clearInvite(): void { saveToCookie(INVITE_CODE_COOKIE, '') saveToCookie(INVITE_TYPE_COOKIE, '') + saveToCookie(INVITE_USER_COOKIE, '') +} + +/** Do not let an older in-flight request clear a newer account/code hand-off. */ +export function clearInviteIfOwnedBy(inviteCode: string, userId: string): boolean { + if (!isOwnedInvite(inviteCode, userId)) return false + clearInvite() + return true } diff --git a/src/utils/signup-attribution.ts b/src/utils/signup-attribution.ts new file mode 100644 index 0000000000..260bfb6ea4 --- /dev/null +++ b/src/utils/signup-attribution.ts @@ -0,0 +1,470 @@ +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 MAX_SERIALIZED_ATTRIBUTION_LENGTH = 4096 +// Browser limits vary around 4 KiB and some count cookie attributes too. +// Keep the percent-encoded name/value below 3.8 KiB so expiry, path, +// SameSite, and Secure attributes still fit without silent eviction. +const MAX_ATTRIBUTION_COOKIE_NAME_VALUE_LENGTH = 3800 +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 IPV4_VALUE_PATTERN = /^(?:\d{1,3}\.){3}\d{1,3}$/ +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' + +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 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 cleanHost(value: string | null | undefined): string | undefined { + const cleaned = cleanValue(value, 255)?.toLowerCase() + if (!cleaned || !/^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?(?::\d{1,5})?$/.test(cleaned)) return undefined + const hostname = cleaned.replace(/:\d{1,5}$/, '') + if (UUID_VALUE_PATTERN.test(hostname) || WALLET_VALUE_PATTERN.test(hostname) || IPV4_VALUE_PATTERN.test(hostname)) { + return undefined + } + return cleaned +} + +function cleanPath(path: string): string | undefined { + const clean = cleanValue(path, MAX_PATH_LENGTH)?.split(/[?#]/, 1)[0] + return clean && PUBLIC_PATH_PATTERN.test(clean) && !clean.includes('..') && !clean.includes('%') ? 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(includeDocumentReferrer: boolean): SignupAttributionTouch | null { + if (typeof window === 'undefined') return null + + const url = new URL(window.location.href) + // document.referrer describes the initial document request and stays + // unchanged across App Router navigations. Reusing it would turn every + // later path into a fabricated external last touch. + const referrer = includeDocumentReferrer ? document.referrer : '' + let referrerHost: string | undefined + if (referrer) { + try { + const parsedReferrer = new URL(referrer) + if (parsedReferrer.origin !== url.origin) referrerHost = cleanHost(parsedReferrer.hostname) + } catch {} + } + + return { + occurredAt: new Date().toISOString(), + 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), + } +} + +function isTouch(value: unknown): value is SignupAttributionTouch { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false + const touch = value as Partial + 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 && !cleanHost(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) + ) +} + +function isContext(value: unknown): value is SignupAttributionContext { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false + const context = value as Partial + return ( + Object.keys(context).every((key) => + [ + 'schemaVersion', + 'journeyId', + 'platform', + 'analyticsState', + 'captureMethod', + 'firstTouch', + 'firstContentTouch', + 'lastTouch', + ].includes(key) + ) && + 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)) + ) +} + +function canonicalTouch(touch: SignupAttributionTouch): SignupAttributionTouch { + return { + occurredAt: new Date(touch.occurredAt).toISOString(), + ...(cleanMarketingValue(touch.utmSource) ? { utmSource: cleanMarketingValue(touch.utmSource) } : {}), + ...(cleanMarketingValue(touch.utmMedium) ? { utmMedium: cleanMarketingValue(touch.utmMedium) } : {}), + ...(cleanMarketingValue(touch.utmCampaign) ? { utmCampaign: cleanMarketingValue(touch.utmCampaign) } : {}), + ...(cleanMarketingValue(touch.utmContent) ? { utmContent: cleanMarketingValue(touch.utmContent) } : {}), + ...(cleanHost(touch.referrerHost) ? { referrerHost: cleanHost(touch.referrerHost) } : {}), + ...(touch.path && cleanPath(touch.path) ? { path: cleanPath(touch.path) } : {}), + } +} + +function fitsAttributionStorage(candidate: SignupAttributionContext): boolean { + const serialized = JSON.stringify(candidate) + return ( + serialized.length <= MAX_SERIALIZED_ATTRIBUTION_LENGTH && + `${SIGNUP_ATTRIBUTION_COOKIE}=${encodeURIComponent(serialized)}`.length <= + MAX_ATTRIBUTION_COOKIE_NAME_VALUE_LENGTH + ) +} + +/** Rebuild from allowlisted fields and trim lower-priority touches to the API and cookie caps. */ +function canonicalSignupAttribution(value: unknown): SignupAttributionContext | null { + if (!isContext(value)) return null + const canonical: SignupAttributionContext = { + schemaVersion: SIGNUP_ATTRIBUTION_SCHEMA_VERSION, + journeyId: value.journeyId, + platform: value.platform, + analyticsState: 'enabled', + captureMethod: value.captureMethod, + firstTouch: canonicalTouch(value.firstTouch), + ...(value.firstContentTouch ? { firstContentTouch: canonicalTouch(value.firstContentTouch) } : {}), + ...(value.lastTouch ? { lastTouch: canonicalTouch(value.lastTouch) } : {}), + } + const candidates: SignupAttributionContext[] = [ + canonical, + { ...canonical, lastTouch: undefined }, + { ...canonical, firstContentTouch: undefined, lastTouch: undefined }, + ] + return candidates.find(fitsAttributionStorage) ?? null +} + +export function readSignupAttribution(): SignupAttributionContext | null { + if (!analyticsCollectionAvailable()) { + void clearSignupAttribution() + return null + } + const rawStored = getFromCookie(SIGNUP_ATTRIBUTION_COOKIE) + const stored = canonicalSignupAttribution(rawStored) + if (rawStored && !stored) void clearSignupAttribution() + return stored +} + +export function captureSignupAttribution({ + includeDocumentReferrer = false, +}: { includeDocumentReferrer?: boolean } = {}): SignupAttributionContext | null { + if (!analyticsCollectionAvailable()) return null + const touch = currentTouch(includeDocumentReferrer) + 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 } + : {}), + } + + const bounded = canonicalSignupAttribution(context) + if (!bounded) return null + saveToCookie(SIGNUP_ATTRIBUTION_COOKIE, bounded, SIGNUP_ATTRIBUTION_EXPIRY_DAYS) + return bounded +} + +export function restoreSignupAttribution(context: SignupAttributionContext): SignupAttributionContext { + if (!analyticsCollectionAvailable()) return context + const existing = readSignupAttribution() + const restored = canonicalSignupAttribution({ + ...context, + captureMethod: 'deferred_link', + firstContentTouch: context.firstContentTouch ?? existing?.firstContentTouch, + lastTouch: context.lastTouch ?? existing?.lastTouch, + }) + if (!restored) return context + saveToCookie(SIGNUP_ATTRIBUTION_COOKIE, restored, SIGNUP_ATTRIBUTION_EXPIRY_DAYS) + void persistNativeSignupAttribution(restored) + return restored +} + +function readPendingSignupAttributionUserIdLocally(): string | null { + try { + return localStorage.getItem(PENDING_SIGNUP_KEY) + } catch { + return null + } +} + +function clearPendingSignupAttributionLocally(expectedUserId?: string): void { + try { + if (expectedUserId && localStorage.getItem(PENDING_SIGNUP_KEY) !== expectedUserId) return + localStorage.removeItem(PENDING_SIGNUP_KEY) + } catch {} +} + +async function removeNativeSignupKeys(keys: string[]): Promise { + if (process.env.NEXT_PUBLIC_CAPACITOR_BUILD !== 'true') return + try { + const { Preferences } = await import('@capacitor/preferences') + await Promise.all(keys.map((key) => Preferences.remove({ key }))) + } catch {} +} + +export async function clearSignupAttribution(): Promise { + saveToCookie(SIGNUP_ATTRIBUTION_COOKIE, '', -1) + clearPendingSignupAttributionLocally() + await removeNativeSignupKeys([PENDING_SIGNUP_KEY, NATIVE_STORAGE_KEY]) +} + +/** Bind a completed passkey ceremony to the account eligible to attach it. */ +export async function markSignupAttributionPending(userId: string): Promise { + try { + localStorage.setItem(PENDING_SIGNUP_KEY, userId) + } catch {} + if (process.env.NEXT_PUBLIC_CAPACITOR_BUILD === 'true') { + try { + const { Preferences } = await import('@capacitor/preferences') + await Preferences.set({ key: PENDING_SIGNUP_KEY, value: userId }) + } catch {} + } +} + +export async function clearPendingSignupAttribution(expectedUserId?: string): Promise { + clearPendingSignupAttributionLocally(expectedUserId) + if (process.env.NEXT_PUBLIC_CAPACITOR_BUILD !== 'true') return + try { + const { Preferences } = await import('@capacitor/preferences') + if (expectedUserId && (await Preferences.get({ key: PENDING_SIGNUP_KEY })).value !== expectedUserId) return + await Preferences.remove({ key: PENDING_SIGNUP_KEY }) + } catch {} +} + +/** Only a client that just completed registration may finalize a journey. */ +export async function hasPendingSignupAttribution(userId: string): Promise { + const localUserId = readPendingSignupAttributionUserIdLocally() + if (localUserId !== null) return localUserId === userId + 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 === userId + } catch { + return false + } +} + +export function signupAnalyticsState(): 'enabled' | 'disabled' | 'unknown' { + // Jest exercises the capture contract without a public build key. + if (process.env.NODE_ENV === 'test') return 'enabled' + if (!process.env.NEXT_PUBLIC_POSTHOG_KEY) return 'unknown' + try { + if (typeof posthog.has_opted_out_capturing !== 'function') return 'unknown' + return posthog.has_opted_out_capturing() ? 'disabled' : 'enabled' + } catch { + return 'unknown' + } +} + +function analyticsCollectionAvailable(): boolean { + return signupAnalyticsState() === 'enabled' +} + +async function persistNativeSignupAttribution(context: SignupAttributionContext): Promise { + if (process.env.NEXT_PUBLIC_CAPACITOR_BUILD !== 'true') return + const serialized = serializeSignupAttribution(context) + if (!serialized) return + try { + const { Preferences } = await import('@capacitor/preferences') + await Preferences.set({ key: NATIVE_STORAGE_KEY, value: serialized }) + } catch {} +} + +/** Persist the context before a native app can be killed between restore and signup. */ +export async function persistSignupAttribution(context: SignupAttributionContext): Promise { + const bounded = canonicalSignupAttribution(context) + if (!bounded) { + await clearSignupAttribution() + return + } + saveToCookie(SIGNUP_ATTRIBUTION_COOKIE, bounded, SIGNUP_ATTRIBUTION_EXPIRY_DAYS) + await persistNativeSignupAttribution(bounded) +} + +/** 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 }) + const parsed = parseSignupAttribution(stored.value) + if (stored.value && !parsed) await clearSignupAttribution() + return parsed + } catch { + return null + } +} + +/** + * Ensure a registration has a durable attribution journey before its passkey + * ceremony starts. Deferred-link restoration runs first on native; a direct + * install falls back to a first-party browser touch for the current /setup + * route. Persisting the result in Preferences lets a killed WebView finish the + * authenticated attachment after restart. + */ +export async function ensureSignupAttributionForRegistration(): Promise { + if (!analyticsCollectionAvailable()) { + await clearSignupAttribution() + return null + } + + const context = (await readSignupAttributionAsync()) ?? captureSignupAttribution() + if (!context) return null + + await persistSignupAttribution(context) + return context +} + +export function serializeSignupAttribution( + context: SignupAttributionContext | null = readSignupAttribution() +): string | null { + const canonical = canonicalSignupAttribution(context) + return canonical ? JSON.stringify(canonical) : null +} + +export function parseSignupAttribution(value: string | null | undefined): SignupAttributionContext | null { + if (!value || value.length > MAX_SERIALIZED_ATTRIBUTION_LENGTH) return null + try { + const parsed: unknown = JSON.parse(value) + return canonicalSignupAttribution(parsed) + } 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, + } +}