From c6f490c21bf8afa63e78c0b921c1532c87179a2f Mon Sep 17 00:00:00 2001 From: Juanma Hidalgo Date: Mon, 27 Apr 2026 14:58:24 +0200 Subject: [PATCH 1/2] feat: align checkpoint tracking with download-first onboarding redesign --- .../Pages/AvatarSetupPage/AvatarSetupPage.tsx | 46 --------------- .../Pages/CallbackPage/CallbackPage.tsx | 11 ++-- .../Pages/LoginPage/AutoLoginRedirect.tsx | 8 +-- .../Pages/LoginPage/LoginPage.spec.tsx | 4 -- src/components/Pages/LoginPage/LoginPage.tsx | 38 ++++++------ .../Pages/QuickSetupPage/QuickSetupPage.tsx | 10 ---- src/components/Pages/SetupPage/SetupPage.tsx | 21 ------- .../onboarding/markReturningUser.spec.ts | 58 ------------------- src/shared/onboarding/markReturningUser.ts | 27 --------- src/shared/onboarding/trackCheckpoint.ts | 28 +++++++-- 10 files changed, 50 insertions(+), 201 deletions(-) delete mode 100644 src/shared/onboarding/markReturningUser.spec.ts delete mode 100644 src/shared/onboarding/markReturningUser.ts diff --git a/src/components/Pages/AvatarSetupPage/AvatarSetupPage.tsx b/src/components/Pages/AvatarSetupPage/AvatarSetupPage.tsx index 6b81deed..17b65a87 100644 --- a/src/components/Pages/AvatarSetupPage/AvatarSetupPage.tsx +++ b/src/components/Pages/AvatarSetupPage/AvatarSetupPage.tsx @@ -21,7 +21,6 @@ import { useCurrentConnectionData } from '../../../shared/connection' import { isEmailValid } from '../../../shared/email' import { locations } from '../../../shared/locations' import { getStoredEmail } from '../../../shared/onboarding/getStoredEmail' -import { trackCheckpoint } from '../../../shared/onboarding/trackCheckpoint' import { isProfileComplete } from '../../../shared/profile' import { handleError } from '../../../shared/utils/errorHandler' import { checkWebGpuSupport } from '../../../shared/utils/webgpu' @@ -170,29 +169,6 @@ const AvatarSetupPage: React.FC = () => { name: state.username }) - const storedEmail = state.email || getStoredEmail() - - // CP3 completed: user accepted TOS and submitted username - trackCheckpoint({ - checkpointId: 3, - action: 'completed', - source: 'auth', - userIdentifier: storedEmail || account?.toLowerCase(), - identifierType: storedEmail ? 'email' : 'wallet', - email: storedEmail || undefined, - wallet: account?.toLowerCase() - }) - - // CP4 reached: avatar creator / starting look screen shown - trackCheckpoint({ - checkpointId: 4, - action: 'reached', - source: 'auth', - userIdentifier: storedEmail || account?.toLowerCase(), - identifierType: storedEmail ? 'email' : 'wallet', - email: storedEmail || undefined, - wallet: account?.toLowerCase() - }) } } catch (e) { // Display the error in the error box below the continue button @@ -272,18 +248,6 @@ const AvatarSetupPage: React.FC = () => { skipped: event.data.payload.result?.skipped }) - // CP4 completed: avatar customization finished and deployed - const emailForCheckpoint = state.email || getStoredEmail() - trackCheckpoint({ - checkpointId: 4, - action: 'completed', - source: 'auth', - userIdentifier: emailForCheckpoint || account.toLowerCase(), - identifierType: emailForCheckpoint ? 'email' : 'wallet', - email: emailForCheckpoint || undefined, - wallet: account.toLowerCase() - }) - if (referrer && EthAddress.validate(referrer)) { try { await trackReferral(referrer, 'PATCH') @@ -389,16 +353,6 @@ const AvatarSetupPage: React.FC = () => { setState(prev => ({ ...prev, email: storedEmail, isEmailInherited: true })) } - trackCheckpoint({ - checkpointId: 3, - action: 'reached', - source: 'auth', - userIdentifier: storedEmail || account.toLowerCase(), - identifierType: storedEmail ? 'email' : 'wallet', - email: storedEmail || undefined, - wallet: account.toLowerCase() - }) - if (referrer && EthAddress.validate(referrer) && !hasTrackedReferral.current) { await trackReferral(referrer, 'POST') hasTrackedReferral.current = true diff --git a/src/components/Pages/CallbackPage/CallbackPage.tsx b/src/components/Pages/CallbackPage/CallbackPage.tsx index 2eb611ac..b5fdde20 100644 --- a/src/components/Pages/CallbackPage/CallbackPage.tsx +++ b/src/components/Pages/CallbackPage/CallbackPage.tsx @@ -13,7 +13,6 @@ import { isMagicExtensionError, isMagicRpcError } from '../../../shared/errors' import { extractReferrerFromSearchParameters, locations } from '../../../shared/locations' import { isMobileSession } from '../../../shared/mobile' import { getStoredEmail } from '../../../shared/onboarding/getStoredEmail' -import { markReturningUser } from '../../../shared/onboarding/markReturningUser' import { trackCheckpoint } from '../../../shared/onboarding/trackCheckpoint' import { handleError } from '../../../shared/utils/errorHandler' import { OAUTH_ACCESS_DENIED_ERROR, createMagicInstance } from '../../../shared/utils/magicSdk' @@ -82,14 +81,14 @@ const DesktopCallbackPage = () => { const ethAddress = connectionData.account?.toLowerCase() ?? '' - // CP2 reached: social login callback — now we have account + email + // CP2 completed: social login callback finished — we have the wallet + // and (for Magic) an email. user_id stays as the anonymousId from the + // shared Segment cookie; we just enrich the row. const storedEmail = getStoredEmail() trackCheckpoint({ checkpointId: 2, - action: 'reached', + action: 'completed', source: 'auth', - userIdentifier: storedEmail || ethAddress, - identifierType: storedEmail ? 'email' : 'wallet', email: storedEmail || undefined, wallet: ethAddress, metadata: { loginMethod: 'social' } @@ -113,8 +112,6 @@ const DesktopCallbackPage = () => { if (!profile) return } - // Flag this user so future visits skip first-time flows - markReturningUser(account) redirect() } catch (error) { handleError(error, 'Error in callback continue flow') diff --git a/src/components/Pages/LoginPage/AutoLoginRedirect.tsx b/src/components/Pages/LoginPage/AutoLoginRedirect.tsx index 4d2a2040..09f5cd7f 100644 --- a/src/components/Pages/LoginPage/AutoLoginRedirect.tsx +++ b/src/components/Pages/LoginPage/AutoLoginRedirect.tsx @@ -9,7 +9,6 @@ import { ConnectionType } from '../../../modules/analytics/types' import { useCurrentConnectionData } from '../../../shared/connection' import { isUserRejectedTransaction } from '../../../shared/errors' import { locations } from '../../../shared/locations' -import { markReturningUser } from '../../../shared/onboarding/markReturningUser' import { trackCheckpoint } from '../../../shared/onboarding/trackCheckpoint' import { checkClockSync } from '../../../shared/utils/clockSync' import { handleError } from '../../../shared/utils/errorHandler' @@ -83,13 +82,11 @@ export const AutoLoginRedirect = ({ connectionType }: Props) => { // MetaMask connected — now verify and redirect setPhase('verifying') - // Track CP2 reached after wallet connects (matches LoginPage behavior) + // CP2 completed: identity established via Metamask auto-login. trackCheckpoint({ checkpointId: 2, - action: 'reached', + action: 'completed', source: 'auth', - userIdentifier: ethAddress, - identifierType: 'wallet', wallet: ethAddress, metadata: { loginMethod: connectionType } }) @@ -126,7 +123,6 @@ export const AutoLoginRedirect = ({ connectionType }: Props) => { if (!profile) return } - markReturningUser(connectionData.account ?? '') redirect() } catch (error) { if (isUserRejectedTransaction(error)) { diff --git a/src/components/Pages/LoginPage/LoginPage.spec.tsx b/src/components/Pages/LoginPage/LoginPage.spec.tsx index 6d1241b9..a84036ba 100644 --- a/src/components/Pages/LoginPage/LoginPage.spec.tsx +++ b/src/components/Pages/LoginPage/LoginPage.spec.tsx @@ -88,10 +88,6 @@ jest.mock('../../../shared/utils/clockSync', () => ({ checkClockSync: jest.fn().mockResolvedValue(true) })) -jest.mock('../../../shared/onboarding/markReturningUser', () => ({ - markReturningUser: jest.fn() -})) - jest.mock('../../../shared/onboarding/trackCheckpoint', () => ({ trackCheckpoint: jest.fn() })) diff --git a/src/components/Pages/LoginPage/LoginPage.tsx b/src/components/Pages/LoginPage/LoginPage.tsx index 44673627..77bba5c6 100644 --- a/src/components/Pages/LoginPage/LoginPage.tsx +++ b/src/components/Pages/LoginPage/LoginPage.tsx @@ -24,7 +24,6 @@ import { ConnectionType } from '../../../modules/analytics/types' import { useCurrentConnectionData } from '../../../shared/connection' import { isErrorWithName, isUserRejectedTransaction } from '../../../shared/errors' import { extractReferrerFromSearchParameters } from '../../../shared/locations' -import { markReturningUser } from '../../../shared/onboarding/markReturningUser' import { trackCheckpoint } from '../../../shared/onboarding/trackCheckpoint' import { disconnectWallet, sendEmailOTP } from '../../../shared/thirdweb' import { checkClockSync } from '../../../shared/utils/clockSync' @@ -104,6 +103,12 @@ export const LoginPage = () => { const { identity, getIdentitySignature } = useCurrentConnectionData() const { trackLoginClick, trackLoginSuccess, trackGuestLogin } = useAnalytics() + // CP2 reached: user opened the auth login page. Anonymous identifier comes + // from the Segment cookie shared with landing on decentraland.org. + useEffect(() => { + trackCheckpoint({ checkpointId: 2, action: 'reached', source: 'auth' }) + }, []) + const handleGuestLogin = useCallback(async () => { await trackGuestLogin() }, [trackGuestLogin]) @@ -121,7 +126,6 @@ export const LoginPage = () => { if (!profile) return } - markReturningUser(account) redirect() onRedirect?.() }, @@ -159,16 +163,6 @@ export const LoginPage = () => { // Keep the flow going even if cleanup fails. } - trackCheckpoint({ - checkpointId: 2, - action: 'reached', - source: 'auth', - userIdentifier: email, - identifierType: 'email', - email, - metadata: { loginMethod: ConnectionOptionType.EMAIL } - }) - try { // Send OTP to email await sendEmailOTP(email) @@ -232,13 +226,13 @@ export const LoginPage = () => { setLoadingState(ConnectionLayoutState.CONNECTING_WALLET) const connectionData = await connectToProvider(connectionType) - // Track CP2 reached after wallet connects so we have the account address + // CP2 completed: wallet identity established. The anonymousId from + // the shared Segment cookie remains the user_id; we just enrich the + // row with the wallet (and email if any was stored from a prior flow). trackCheckpoint({ checkpointId: 2, - action: 'reached', + action: 'completed', source: 'auth', - userIdentifier: connectionData.account?.toLowerCase() ?? '', - identifierType: 'wallet', wallet: connectionData.account?.toLowerCase(), metadata: { loginMethod: connectionType } }) @@ -346,6 +340,16 @@ export const LoginPage = () => { const freshIdentity = await getIdentitySignature() + // CP2 completed: Email OTP verified, identity established with email + wallet. + trackCheckpoint({ + checkpointId: 2, + action: 'completed', + source: 'auth', + email: currentEmail || undefined, + wallet: address, + metadata: { loginMethod: ConnectionOptionType.EMAIL } + }) + await trackLoginSuccess({ ethAddress: address, type: ConnectionType.WEB2 @@ -370,7 +374,7 @@ export const LoginPage = () => { setConfirmingLoginError(errorMessage || t('login.errors.something_went_wrong')) } }, - [trackLoginSuccess, checkClockSynchronization, runProfileRedirect, getReferrerFromCurrentSearch, getIdentitySignature, t] + [trackLoginSuccess, checkClockSynchronization, runProfileRedirect, getReferrerFromCurrentSearch, getIdentitySignature, currentEmail, t] ) const handleEmailInputChange = useCallback(() => { diff --git a/src/components/Pages/QuickSetupPage/QuickSetupPage.tsx b/src/components/Pages/QuickSetupPage/QuickSetupPage.tsx index 9c208751..5e7fef64 100644 --- a/src/components/Pages/QuickSetupPage/QuickSetupPage.tsx +++ b/src/components/Pages/QuickSetupPage/QuickSetupPage.tsx @@ -7,7 +7,6 @@ import randomizeIconSvg from '../../../assets/images/randomize-icon.svg' import { useAfterLoginRedirection } from '../../../hooks/redirection' import { useDisabledCatalysts } from '../../../hooks/useDisabledCatalysts' import { useCurrentConnectionData } from '../../../shared/connection' -import { trackCheckpoint } from '../../../shared/onboarding/trackCheckpoint' import { handleError } from '../../../shared/utils/errorHandler' import { AnimatedBackground } from '../../AnimatedBackground' import { CustomWearablePreview } from '../../CustomWearablePreview' @@ -136,15 +135,6 @@ export const QuickSetupPage = () => { } } - trackCheckpoint({ - checkpointId: 3, - action: 'completed', - source: 'auth', - userIdentifier: account.toLowerCase(), - identifierType: 'wallet', - wallet: account.toLowerCase() - }) - setShowCelebration(true) getCelebrateAnimation() .then(setCelebrationAnimData) diff --git a/src/components/Pages/SetupPage/SetupPage.tsx b/src/components/Pages/SetupPage/SetupPage.tsx index 9b6ac034..416a59b3 100644 --- a/src/components/Pages/SetupPage/SetupPage.tsx +++ b/src/components/Pages/SetupPage/SetupPage.tsx @@ -20,7 +20,6 @@ import { createAuthServerHttpClient, createAuthServerWsClient } from '../../../s import { useCurrentConnectionData } from '../../../shared/connection' import { locations } from '../../../shared/locations' import { getStoredEmail } from '../../../shared/onboarding/getStoredEmail' -import { trackCheckpoint } from '../../../shared/onboarding/trackCheckpoint' import { isProfileComplete } from '../../../shared/profile' import { handleError } from '../../../shared/utils/errorHandler' import { ConnectionModal } from '../../ConnectionModal' @@ -315,16 +314,6 @@ export const SetupPage = () => { name }) - trackCheckpoint({ - checkpointId: 3, - action: 'completed', - source: 'auth', - userIdentifier: email || account.toLowerCase(), - identifierType: email ? 'email' : 'wallet', - email: email || undefined, - wallet: account.toLowerCase() - }) - // If the site to be redirect to is a request site, we need to recover the request and sign in. if (requestId && provider && flags[FeatureFlagsKeys.LOGIN_ON_SETUP]) { await signRequest(provider, requestId, account) @@ -387,16 +376,6 @@ export const SetupPage = () => { setEmail(storedEmail) } - trackCheckpoint({ - checkpointId: 3, - action: 'reached', - source: 'auth', - userIdentifier: storedEmail || account.toLowerCase(), - identifierType: storedEmail ? 'email' : 'wallet', - email: storedEmail || undefined, - wallet: account.toLowerCase() - }) - if (referrer && EthAddress.validate(referrer) && !hasTrackedReferral.current) { try { await trackReferral(referrer, 'POST') diff --git a/src/shared/onboarding/markReturningUser.spec.ts b/src/shared/onboarding/markReturningUser.spec.ts deleted file mode 100644 index 0f63f8ae..00000000 --- a/src/shared/onboarding/markReturningUser.spec.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { getStoredEmail } from './getStoredEmail' -import { markReturningUser } from './markReturningUser' -import { trackCheckpoint } from './trackCheckpoint' - -jest.mock('./getStoredEmail') -jest.mock('./trackCheckpoint') - -describe('markReturningUser', () => { - afterEach(() => { - jest.clearAllMocks() - }) - - describe('when the user has a stored email and account', () => { - let account: string - - beforeEach(() => { - account = '0xABC123' - jest.mocked(getStoredEmail).mockReturnValueOnce('user@test.com') - }) - - it('should track checkpoint for the email identifier', () => { - markReturningUser(account) - expect(trackCheckpoint).toHaveBeenCalledWith({ - checkpointId: 2, - action: 'completed', - userIdentifier: 'user@test.com', - identifierType: 'email', - wallet: '0xabc123' - }) - }) - - it('should track checkpoint for the wallet identifier', () => { - markReturningUser(account) - expect(trackCheckpoint).toHaveBeenCalledWith({ - checkpointId: 2, - action: 'completed', - userIdentifier: account, - identifierType: 'wallet', - wallet: '0xabc123' - }) - }) - }) - - describe('when the user has no stored email', () => { - let account: string - - beforeEach(() => { - account = '0xABC123' - jest.mocked(getStoredEmail).mockReturnValueOnce(null) - }) - - it('should only track checkpoint for the wallet identifier', () => { - markReturningUser(account) - expect(trackCheckpoint).toHaveBeenCalledTimes(1) - expect(trackCheckpoint).toHaveBeenCalledWith(expect.objectContaining({ identifierType: 'wallet' })) - }) - }) -}) diff --git a/src/shared/onboarding/markReturningUser.ts b/src/shared/onboarding/markReturningUser.ts deleted file mode 100644 index 8bd673bd..00000000 --- a/src/shared/onboarding/markReturningUser.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { getStoredEmail } from './getStoredEmail' -import { trackCheckpoint } from './trackCheckpoint' - -/** - * Marks CP2 as completed for returning users (those with an existing profile). - * Prevents nudge emails being sent to people who are already onboarded. - * Fires with wallet address AND email (if available) so both identifier types are covered. - */ -export function markReturningUser(account: string) { - const storedEmail = getStoredEmail() - if (storedEmail) { - trackCheckpoint({ - checkpointId: 2, - action: 'completed', - userIdentifier: storedEmail, - identifierType: 'email', - wallet: account.toLowerCase() - }) - } - trackCheckpoint({ - checkpointId: 2, - action: 'completed', - userIdentifier: account, - identifierType: 'wallet', - wallet: account.toLowerCase() - }) -} diff --git a/src/shared/onboarding/trackCheckpoint.ts b/src/shared/onboarding/trackCheckpoint.ts index 2205f34f..f20de61a 100644 --- a/src/shared/onboarding/trackCheckpoint.ts +++ b/src/shared/onboarding/trackCheckpoint.ts @@ -4,19 +4,37 @@ type CheckpointParams = { checkpointId: number action?: 'reached' | 'completed' userIdentifier?: string - identifierType?: 'email' | 'wallet' + identifierType?: 'anon' | 'email' | 'wallet' email?: string wallet?: string - source?: 'auth' + source?: 'auth' | 'landing' | 'explorer' metadata?: Record } +/** + * Sends an Onboarding Checkpoint event to Segment. The user_id is the + * Segment anonymousId by default — landing and auth share the same domain + * (`decentraland.org`) so the cookie carries the same id end-to-end. + * + * Callers can override `userIdentifier`/`identifierType` after auth completes + * (when wallet/email become known), but the anonymousId stays the canonical + * link with CP1 (landing) and CP3 (Explorer via campaign_anon_user_id). + */ export function trackCheckpoint(params: CheckpointParams): void { - getAnalytics()?.track('Onboarding Checkpoint', { + const analytics = getAnalytics() + if (!analytics) return + + const anonymousId = analytics.user().anonymousId() as string | undefined + const userIdentifier = params.userIdentifier ?? anonymousId + const identifierType = params.identifierType ?? (anonymousId ? 'anon' : undefined) + + if (!userIdentifier || !identifierType) return + + analytics.track('Onboarding Checkpoint', { checkpointId: params.checkpointId, action: params.action ?? 'reached', - userIdentifier: params.userIdentifier, - identifierType: params.identifierType, + userIdentifier, + identifierType, email: params.email, wallet: params.wallet, source: params.source ?? 'auth', From 41bcf5021bbd11d4941455ff6dc2e1ed275ae09b Mon Sep 17 00:00:00 2001 From: Juanma Hidalgo Date: Mon, 27 Apr 2026 16:29:57 +0200 Subject: [PATCH 2/2] feat: add trackCheckpointWhenReady --- .../Pages/LoginPage/LoginPage.spec.tsx | 3 +- src/components/Pages/LoginPage/LoginPage.tsx | 6 ++- src/shared/onboarding/trackCheckpoint.ts | 50 ++++++++++++++++++- 3 files changed, 54 insertions(+), 5 deletions(-) diff --git a/src/components/Pages/LoginPage/LoginPage.spec.tsx b/src/components/Pages/LoginPage/LoginPage.spec.tsx index a84036ba..685a1cf5 100644 --- a/src/components/Pages/LoginPage/LoginPage.spec.tsx +++ b/src/components/Pages/LoginPage/LoginPage.spec.tsx @@ -89,7 +89,8 @@ jest.mock('../../../shared/utils/clockSync', () => ({ })) jest.mock('../../../shared/onboarding/trackCheckpoint', () => ({ - trackCheckpoint: jest.fn() + trackCheckpoint: jest.fn(), + trackCheckpointWhenReady: jest.fn() })) jest.mock('../../../shared/thirdweb', () => ({ diff --git a/src/components/Pages/LoginPage/LoginPage.tsx b/src/components/Pages/LoginPage/LoginPage.tsx index 77bba5c6..4bcebb95 100644 --- a/src/components/Pages/LoginPage/LoginPage.tsx +++ b/src/components/Pages/LoginPage/LoginPage.tsx @@ -24,7 +24,7 @@ import { ConnectionType } from '../../../modules/analytics/types' import { useCurrentConnectionData } from '../../../shared/connection' import { isErrorWithName, isUserRejectedTransaction } from '../../../shared/errors' import { extractReferrerFromSearchParameters } from '../../../shared/locations' -import { trackCheckpoint } from '../../../shared/onboarding/trackCheckpoint' +import { trackCheckpoint, trackCheckpointWhenReady } from '../../../shared/onboarding/trackCheckpoint' import { disconnectWallet, sendEmailOTP } from '../../../shared/thirdweb' import { checkClockSync } from '../../../shared/utils/clockSync' import { handleError } from '../../../shared/utils/errorHandler' @@ -105,8 +105,10 @@ export const LoginPage = () => { // CP2 reached: user opened the auth login page. Anonymous identifier comes // from the Segment cookie shared with landing on decentraland.org. + // Wrapped in `trackCheckpointWhenReady` because the analytics.js stub may + // shadow `analytics.user()` until the real library loads. useEffect(() => { - trackCheckpoint({ checkpointId: 2, action: 'reached', source: 'auth' }) + trackCheckpointWhenReady({ checkpointId: 2, action: 'reached', source: 'auth' }) }, []) const handleGuestLogin = useCallback(async () => { diff --git a/src/shared/onboarding/trackCheckpoint.ts b/src/shared/onboarding/trackCheckpoint.ts index f20de61a..a462dc37 100644 --- a/src/shared/onboarding/trackCheckpoint.ts +++ b/src/shared/onboarding/trackCheckpoint.ts @@ -11,6 +11,22 @@ type CheckpointParams = { metadata?: Record } +/** + * Reads the anonymousId from analytics.js. Wrapped in try/catch because the + * stub queue created by the Segment snippet can shadow `user()` until the + * real library finishes loading, in which case `analytics.user().anonymousId()` + * throws TypeError. Returns undefined on any failure. + */ +function safeAnonymousId(): string | undefined { + try { + const analytics = getAnalytics() + const id = analytics?.user?.()?.anonymousId?.() + return typeof id === 'string' ? id : undefined + } catch { + return undefined + } +} + /** * Sends an Onboarding Checkpoint event to Segment. The user_id is the * Segment anonymousId by default — landing and auth share the same domain @@ -19,12 +35,14 @@ type CheckpointParams = { * Callers can override `userIdentifier`/`identifierType` after auth completes * (when wallet/email become known), but the anonymousId stays the canonical * link with CP1 (landing) and CP3 (Explorer via campaign_anon_user_id). + * + * Safe to call before analytics.js finishes loading — falls through silently. */ -export function trackCheckpoint(params: CheckpointParams): void { +function trackCheckpoint(params: CheckpointParams): void { const analytics = getAnalytics() if (!analytics) return - const anonymousId = analytics.user().anonymousId() as string | undefined + const anonymousId = safeAnonymousId() const userIdentifier = params.userIdentifier ?? anonymousId const identifierType = params.identifierType ?? (anonymousId ? 'anon' : undefined) @@ -41,3 +59,31 @@ export function trackCheckpoint(params: CheckpointParams): void { metadata: params.metadata }) } + +/** + * Schedules a checkpoint to fire as soon as analytics.js is ready. If the + * library is already loaded, runs synchronously. Otherwise the snippet's + * stub `analytics.ready` queues the callback until the real library + * finishes loading. + * + * Use this for fire-on-mount checkpoints (e.g. CP2 reached on LoginPage) + * where the component may render before the analytics library is ready. + */ +function trackCheckpointWhenReady(params: CheckpointParams): void { + const analytics = getAnalytics() + if (!analytics) return + + const fire = () => trackCheckpoint(params) + + if (typeof analytics.ready === 'function') { + try { + analytics.ready(fire) + return + } catch { + // fall through + } + } + fire() +} + +export { trackCheckpoint, trackCheckpointWhenReady }