Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
60f839f
feat: persist signup attribution journey context
innolope-dev Sep 10, 2026
72e6f68
fix: address signup attribution review feedback
innolope-dev Sep 10, 2026
dc59ce9
Merge remote-tracking branch 'origin/dev' into innolope/durable-signu…
innolope-dev Sep 17, 2026
34230e2
chore: apply CI formatting
innolope-dev Sep 17, 2026
3b6ddb3
Merge remote-tracking branch 'origin/dev' into innolope/durable-signu…
innolope-dev Sep 17, 2026
e490f7c
Open signup while preserving referral attribution
innolope-dev Sep 17, 2026
8537a80
Persist direct signup attribution before registration
innolope-dev Sep 17, 2026
484e390
Exclude the stateful setup router from captures
innolope-dev Sep 17, 2026
a08da4c
Test signup attribution attachment retries
innolope-dev Sep 17, 2026
77799cb
Remove invalid setup completion capture
innolope-dev Sep 17, 2026
b2562f2
Preserve referral invites in store handoff
innolope-dev Sep 17, 2026
ee9bef5
Harden pending signup attribution
innolope-dev Sep 17, 2026
be64725
Preserve attribution through signup completion
innolope-dev Sep 17, 2026
b394e4d
Recover native attribution at signup completion
innolope-dev Sep 17, 2026
0f7ccfe
Keep document referrer out of SPA touches
innolope-dev Sep 17, 2026
bfef98d
Capture external referrer before hydration
innolope-dev Sep 17, 2026
eb787e8
Bound encoded attribution cookie size
innolope-dev Sep 17, 2026
77758bd
Stop retrying terminal invite attribution
innolope-dev Sep 17, 2026
dddb319
Bind deferred attribution to accounts
innolope-dev Sep 17, 2026
5206beb
Merge dev into open signup and preserve current native flows
innolope-dev Sep 22, 2026
bf8dc55
fix: keep store handoffs usable when referrer payload exceeds cap
innolope-dev Sep 23, 2026
b516685
Offer optional inviter attribution on username setup
innolope-dev Sep 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion instrumentation-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions scripts/screens/inventory.mjs
Original file line number Diff line number Diff line change
@@ -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'],
Expand Down
8 changes: 8 additions & 0 deletions scripts/screens/inventory.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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/)
})
14 changes: 13 additions & 1 deletion src/app/(mobile-ui)/__tests__/layout-backend-error.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,6 @@ jest.mock('@/components/Global/SupportDrawer', () => ({ __esModule: true, defaul
jest.mock('@/components/Global/SupportDeepLink', () => ({ __esModule: true, default: () => <div /> }))
jest.mock('@/components/Global/QRScannerOverlay', () => ({ __esModule: true, default: () => <div /> }))
jest.mock('@/components/Global/SecurityVerificationOverlay', () => ({ __esModule: true, default: () => <div /> }))
jest.mock('@/components/Invites/JoinWaitlistPage', () => ({ __esModule: true, default: () => <div /> }))
jest.mock('@/components/Migration/SunsetScreen', () => ({ __esModule: true, default: () => <div /> }))

import Layout from '../layout'
Expand Down Expand Up @@ -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()
})
})
2 changes: 0 additions & 2 deletions src/app/(mobile-ui)/dev/ds/audit/app/audit-app-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
],
},
Expand All @@ -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',
],
},
{
Expand Down
7 changes: 3 additions & 4 deletions src/app/(mobile-ui)/dev/ds/audit/audit-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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)',
],
},
{
Expand Down
6 changes: 0 additions & 6 deletions src/app/(mobile-ui)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -221,11 +220,6 @@ const Layout = ({ children }: { children: React.ReactNode }) => {
return <SunsetScreen />
}

// Show waitlist page if user doesn't have app access
if (!isFetchingUser && user && !user?.user.hasAppAccess && !isPublicPath) {
return <JoinWaitlistPage />
}

return (
<NavHeaderPresenceProvider>
<AppShell
Expand Down
40 changes: 26 additions & 14 deletions src/app/(setup)/setup/__tests__/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,15 @@ it('suppresses timeout recovery when a completed session starts leaving for home
expect(screen.queryByRole('button', { name: 'Try again' })).not.toBeInTheDocument()
})

it('treats a stale legacy no-access profile as an authenticated account', async () => {
mockAuth.user = { user: { username: 'peanutter', hasAppAccess: false } }

renderWithIntl(<SetupPage />)

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', {
Expand Down Expand Up @@ -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(<SetupPage />)

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(<SetupPage />)

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 } }
Expand Down
Loading
Loading