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.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() {
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}}
-