diff --git a/src/Expensify.tsx b/src/Expensify.tsx
index a7dd389611b6..8bec964d0907 100644
--- a/src/Expensify.tsx
+++ b/src/Expensify.tsx
@@ -12,6 +12,7 @@ import CONST from './CONST';
import DeepLinkHandler from './DeepLinkHandler';
import DelegateAccessHandler from './DelegateAccessHandler';
import FullstoryInitHandler from './FullstoryInitHandler';
+import FullstoryUserContextHandler from './FullstoryUserContextHandler';
import GlobalModals from './GlobalModals';
import useDebugShortcut from './hooks/useDebugShortcut';
import useIsAuthenticated from './hooks/useIsAuthenticated';
@@ -285,6 +286,7 @@ function Expensify() {
+
{/* Wait for the initial URL to resolve before mounting NavigationRoot, because its initialState
diff --git a/src/FullstoryUserContextHandler.tsx b/src/FullstoryUserContextHandler.tsx
new file mode 100644
index 000000000000..acc95da9cecd
--- /dev/null
+++ b/src/FullstoryUserContextHandler.tsx
@@ -0,0 +1,67 @@
+import {useEffect, useMemo, useRef} from 'react';
+import type {OnyxEntry} from 'react-native-onyx';
+import useOnyx from './hooks/useOnyx';
+import FS from './libs/Fullstory';
+import type {FullstoryUserVars} from './libs/Fullstory/types';
+import {buildFullstoryUserVars} from './libs/Fullstory/utils';
+import {shallowCompare} from './libs/ObjectUtils';
+import ONYXKEYS from './ONYXKEYS';
+
+function FullstoryUserContextHandler() {
+ const [account] = useOnyx(ONYXKEYS.ACCOUNT);
+ const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID);
+ const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED);
+ const [loginList] = useOnyx(ONYXKEYS.LOGIN_LIST);
+ const [onboarding] = useOnyx(ONYXKEYS.NVP_ONBOARDING);
+ const [onboardingCompanySize] = useOnyx(ONYXKEYS.ONBOARDING_COMPANY_SIZE);
+ const [onboardingLastVisitedPath] = useOnyx(ONYXKEYS.ONBOARDING_LAST_VISITED_PATH);
+ const [onboardingPurposeSelected] = useOnyx(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED);
+ const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY);
+ const [session] = useOnyx(ONYXKEYS.SESSION);
+ const [userMetadata] = useOnyx(ONYXKEYS.USER_METADATA);
+
+ const activePolicy = useMemo(() => {
+ if (!activePolicyID) {
+ return;
+ }
+
+ return policies?.[`${ONYXKEYS.COLLECTION.POLICY}${activePolicyID}`];
+ }, [activePolicyID, policies]);
+
+ const userVars = useMemo(
+ () =>
+ buildFullstoryUserVars({
+ account,
+ activePolicy,
+ introSelected,
+ loginList,
+ onboarding,
+ onboardingCompanySize,
+ onboardingLastVisitedPath,
+ onboardingPurposeSelected,
+ policies,
+ session,
+ userMetadata,
+ }),
+ [account, activePolicy, introSelected, loginList, onboarding, onboardingCompanySize, onboardingLastVisitedPath, onboardingPurposeSelected, policies, session, userMetadata],
+ );
+
+ const previousUserVars = useRef>(undefined);
+
+ useEffect(() => {
+ if (!userMetadata?.accountID) {
+ return;
+ }
+
+ if (shallowCompare(previousUserVars.current, userVars)) {
+ return;
+ }
+
+ previousUserVars.current = userVars;
+ FS.setUserVars(userVars);
+ }, [userMetadata?.accountID, userVars]);
+
+ return null;
+}
+
+export default FullstoryUserContextHandler;
diff --git a/src/libs/Fullstory/index.native.ts b/src/libs/Fullstory/index.native.ts
index 114d03215321..c54689be14bf 100644
--- a/src/libs/Fullstory/index.native.ts
+++ b/src/libs/Fullstory/index.native.ts
@@ -57,8 +57,8 @@ const FS: Fullstory = {
return FullStory.getCurrentSessionURL();
},
- event: (eventName, eventProperties = {}) => {
- FullStory.event(eventName, eventProperties);
+ event: (eventName, eventProperties) => {
+ FullStory.event(eventName, eventProperties ?? {});
},
log: (level, message) => {
diff --git a/src/libs/Fullstory/index.ts b/src/libs/Fullstory/index.ts
index d357c97c0e91..ba3a8f0fa922 100644
--- a/src/libs/Fullstory/index.ts
+++ b/src/libs/Fullstory/index.ts
@@ -102,11 +102,11 @@ const FS: Fullstory = {
return FullStory('getSessionAsync', {format: 'url'});
},
- event: (eventName, eventProperties = {}) => {
+ event: (eventName, eventProperties) => {
if (!isInitialized()) {
return;
}
- FullStory(CONST.FULLSTORY.OPERATION.TRACK_EVENT, {name: eventName, properties: eventProperties});
+ FullStory(CONST.FULLSTORY.OPERATION.TRACK_EVENT, {name: eventName, properties: eventProperties ?? {}});
},
log: (level, message) => {
diff --git a/src/libs/Fullstory/types.ts b/src/libs/Fullstory/types.ts
index 1a6146d8cf77..108a4229486d 100644
--- a/src/libs/Fullstory/types.ts
+++ b/src/libs/Fullstory/types.ts
@@ -7,6 +7,139 @@ type FSClass = ValueOf;
type PropertiesWithoutPageName = Record & {pageName?: never};
+/* eslint-disable @typescript-eslint/naming-convention -- FullStory schema uses external snake_case keys. */
+type FullstoryUserVars = {
+ user_type_path?: string;
+ account_type?: 'personal' | 'business';
+ user_status?: 'new' | 'returning';
+ has_completed_onboarding?: boolean;
+ onb_step?: 'registration' | 'accounting' | 'completed';
+ user_role?: 'admin' | 'auditor' | 'member';
+ workspace_state?: 'has_workspaces' | 'no_workspaces';
+ workspace_count?: number;
+ workspace_member_count?: number;
+ free_trial_end_date?: string;
+ days_till_trial_end?: number;
+ free_trial_status?: 'active' | 'expiring_soon' | 'expired' | 'expired_last30days';
+ plan_type?: 'collect' | 'control';
+ paid_member?: boolean;
+ auth_method?: 'email' | 'google' | 'apple';
+ reg_method?: 'Google signup' | 'email/phone signup';
+ login_status?: 'success' | 'failure';
+};
+
+type FullstoryEventPropertiesMap = {
+ Page_viewed: {
+ screen_name: string;
+ entry_point?: string;
+ onb_step?: FullstoryUserVars['onb_step'];
+ };
+ Component_viewed: {
+ screen_name: string;
+ location?: string;
+ component_name?: string;
+ onb_step?: FullstoryUserVars['onb_step'];
+ };
+ Component_closed: {
+ screen_name: string;
+ location?: string;
+ component_name?: string;
+ onb_step?: FullstoryUserVars['onb_step'];
+ };
+ clickable_action: {
+ screen_name?: string;
+ location?: string;
+ component_name?: string;
+ onb_step?: FullstoryUserVars['onb_step'];
+ element_label?: string;
+ checked_box?: boolean;
+ // cspell:disable-next-line
+ toggle_swith_on?: boolean;
+ result_type?: string;
+ action_status?: string;
+ position?: number;
+ };
+ Input_field: {
+ screen_name?: string;
+ location?: string;
+ component_name?: string;
+ onb_step?: FullstoryUserVars['onb_step'];
+ input_field_name?: string;
+ input_field_type?: string;
+ input_field_status?: string;
+ };
+ Error_message: {
+ screen_name?: string;
+ location?: string;
+ component_name?: string;
+ onb_step?: FullstoryUserVars['onb_step'];
+ error_location?: string;
+ error_code?: string;
+ error_message?: string;
+ error_type?: string;
+ };
+ Search_submitted: {
+ screen_name?: string;
+ result_type?: string;
+ search_results_count?: number;
+ search_type?: string;
+ };
+ Chat_opened: {
+ screen_name?: string;
+ chat_type?: 'Full-page chat' | 'side-panel chat';
+ };
+ Login_submitted: {
+ action_status?: string;
+ };
+ sign_up: {
+ entry_point?: string;
+ action_status?: string;
+ };
+ File_upload_started: {
+ upload_method?: string;
+ };
+ File_upload_completed: {
+ upload_success?: boolean;
+ };
+ Concierge_message_sent: {
+ has_attachment?: boolean;
+ attachment_count?: number;
+ attachment_types?: string;
+ upload_method?: string;
+ };
+ Chatbot_response_received: {
+ error_type?: string;
+ };
+ Expense_created: {
+ expense_type?: string;
+ expense_creation_method?: string;
+ amount_range?: string;
+ };
+ Report_created: {
+ expense_count?: number;
+ report_type?: string;
+ };
+ Report_submitted: {
+ expense_count?: number;
+ report_type?: string;
+ approver_count?: number;
+ };
+ Bank_account_added: {
+ bank_account_type?: string;
+ card_connection_method?: string;
+ bank_region?: string;
+ };
+ Card_added: {
+ card_connection_method?: string;
+ card_type?: string;
+ card_provider?: string;
+ card_country?: string;
+ };
+};
+
+type FullstoryEventName = keyof FullstoryEventPropertiesMap;
+/* eslint-enable @typescript-eslint/naming-convention */
+
/**
* Represents the common FSPage class signature that will be used in both platform implementations.
*/
@@ -87,7 +220,7 @@ type Fullstory = {
/**
* Sends a custom event to FullStory.
*/
- event: (eventName: string, eventProperties?: Record) => void;
+ event: (eventName: TEventName, eventProperties?: FullstoryEventPropertiesMap[TEventName]) => void;
/**
* Sends a log message to FullStory with the specified log level.
@@ -97,7 +230,7 @@ type Fullstory = {
/**
* Updates user properties without re-identifying.
*/
- setUserVars: (userVars: Record) => void;
+ setUserVars: (userVars: FullstoryUserVars) => void;
/**
* Resets the idle timer to prevent session timeout.
@@ -148,4 +281,4 @@ type ForwardedFSClassProps = {
forwardedFSClass?: FSClass;
};
-export type {FSPageLike, Fullstory, GetChatFSClass, ForwardedFSClassProps, ShouldInitialize};
+export type {FSPageLike, Fullstory, FullstoryEventName, FullstoryEventPropertiesMap, FullstoryUserVars, GetChatFSClass, ForwardedFSClassProps, ShouldInitialize};
diff --git a/src/libs/Fullstory/utils.ts b/src/libs/Fullstory/utils.ts
new file mode 100644
index 000000000000..c7906cc77b5f
--- /dev/null
+++ b/src/libs/Fullstory/utils.ts
@@ -0,0 +1,236 @@
+import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
+import type {ValueOf} from 'type-fest';
+import {filterObject} from '@libs/ObjectUtils';
+import {getActivePolicies, isControlPolicy} from '@libs/PolicyUtils';
+import CONST from '@src/CONST';
+import ROUTES from '@src/ROUTES';
+import type {Account, IntroSelected, LoginList, Onboarding, Policy, Session, UserMetadata} from '@src/types/onyx';
+import FS from '.';
+import type {FullstoryEventName, FullstoryEventPropertiesMap, FullstoryUserVars} from './types';
+
+type BuildFullstoryUserVarsParams = {
+ account: OnyxEntry;
+ activePolicy: OnyxEntry;
+ introSelected: OnyxEntry;
+ loginList: OnyxEntry;
+ onboarding: OnyxEntry;
+ onboardingCompanySize: OnyxEntry;
+ onboardingLastVisitedPath: OnyxEntry;
+ onboardingPurposeSelected: OnyxEntry>;
+ policies: OnyxCollection | undefined;
+ session: OnyxEntry;
+ userMetadata: OnyxEntry;
+};
+
+function sanitizeSegment(value: string): string {
+ return value
+ .toLowerCase()
+ .replaceAll(/[^a-z0-9]+/g, '_')
+ .replaceAll(/^_+|_+$/g, '');
+}
+
+function getNormalizedOnboardingChoice(choice: OnyxEntry>): string | undefined {
+ if (!choice) {
+ return;
+ }
+
+ const choiceMap: Partial, string>> = {
+ [CONST.ONBOARDING_CHOICES.MANAGE_TEAM]: 'team',
+ [CONST.ONBOARDING_CHOICES.TRACK_BUSINESS]: 'track_business',
+ [CONST.ONBOARDING_CHOICES.TRACK_PERSONAL]: 'track_personal',
+ [CONST.ONBOARDING_CHOICES.PERSONAL_SPEND]: 'personal_spend',
+ [CONST.ONBOARDING_CHOICES.LOOKING_AROUND]: 'looking_around',
+ [CONST.ONBOARDING_CHOICES.EMPLOYER]: 'employer',
+ [CONST.ONBOARDING_CHOICES.CHAT_SPLIT]: 'chat_split',
+ [CONST.ONBOARDING_CHOICES.ADMIN]: 'admin',
+ [CONST.ONBOARDING_CHOICES.SUBMIT]: 'submit',
+ [CONST.ONBOARDING_CHOICES.TEST_DRIVE_RECEIVER]: 'test_drive_receiver',
+ };
+
+ return choiceMap[choice] ?? sanitizeSegment(choice);
+}
+
+function buildUserTypePath(choice: OnyxEntry>, companySize: OnyxEntry, isFromPublicDomain?: boolean): string | undefined {
+ const normalizedChoice = getNormalizedOnboardingChoice(choice);
+ const normalizedCompanySize = companySize ? sanitizeSegment(companySize) : undefined;
+ let domainType: string | undefined;
+ if (isFromPublicDomain !== undefined) {
+ domainType = isFromPublicDomain ? 'public' : 'private';
+ }
+
+ const segments = [normalizedChoice, normalizedCompanySize, domainType].filter(Boolean);
+ if (segments.length === 0) {
+ return;
+ }
+
+ return segments.join('_');
+}
+
+function getDaysTillDate(dateString: string | undefined): number | undefined {
+ if (!dateString) {
+ return;
+ }
+
+ const endDate = new Date(dateString);
+ if (Number.isNaN(endDate.getTime())) {
+ return;
+ }
+
+ return Math.ceil((endDate.getTime() - Date.now()) / (1000 * 60 * 60 * 24));
+}
+
+function getFreeTrialStatus(daysTillTrialEnd: number | undefined): FullstoryUserVars['free_trial_status'] {
+ if (daysTillTrialEnd === undefined) {
+ return;
+ }
+ if (daysTillTrialEnd < -30) {
+ return 'expired';
+ }
+ if (daysTillTrialEnd < 0) {
+ return 'expired_last30days';
+ }
+ if (daysTillTrialEnd <= 14) {
+ return 'expiring_soon';
+ }
+ return 'active';
+}
+
+function getOnboardingStep(onboardingPath: string | undefined, hasCompletedOnboarding?: boolean): FullstoryUserVars['onb_step'] {
+ if (hasCompletedOnboarding) {
+ return 'completed';
+ }
+
+ if (!onboardingPath) {
+ return;
+ }
+
+ if (onboardingPath.includes(ROUTES.ONBOARDING_ACCOUNTING.route)) {
+ return 'accounting';
+ }
+
+ const onboardingRoutes = [
+ ROUTES.ONBOARDING_ROOT.route,
+ ROUTES.ONBOARDING_WORK_EMAIL.route,
+ ROUTES.ONBOARDING_WORK_EMAIL_VALIDATION.route,
+ ROUTES.ONBOARDING_PRIVATE_DOMAIN.route,
+ ROUTES.ONBOARDING_PERSONAL_DETAILS.route,
+ ROUTES.ONBOARDING_WORKSPACES.route,
+ ROUTES.ONBOARDING_PURPOSE.route,
+ ROUTES.ONBOARDING_EMPLOYEES.route,
+ ROUTES.ONBOARDING_INTERESTED_FEATURES.route,
+ ROUTES.ONBOARDING_WORKSPACE.route,
+ ROUTES.ONBOARDING_WORKSPACE_CONFIRMATION.route,
+ ROUTES.ONBOARDING_WORKSPACE_CURRENCY.route,
+ ROUTES.ONBOARDING_WORKSPACE_INVITE.route,
+ ];
+
+ if (onboardingRoutes.some((route) => onboardingPath.includes(route))) {
+ return 'registration';
+ }
+}
+
+function getUserRole(policies: OnyxCollection | undefined): FullstoryUserVars['user_role'] {
+ let userRole: FullstoryUserVars['user_role'] = 'member';
+
+ for (const policy of Object.values(policies ?? {})) {
+ if (policy?.role === CONST.POLICY.ROLE.ADMIN) {
+ return 'admin';
+ }
+ if (policy?.role === CONST.POLICY.ROLE.AUDITOR) {
+ userRole = 'auditor';
+ }
+ }
+
+ return userRole;
+}
+
+function getAuthMethod(loginList: OnyxEntry): FullstoryUserVars['auth_method'] {
+ const normalizedPartnerNames = Object.values(loginList ?? {})
+ .map((login) => login?.partnerName?.toLowerCase())
+ .filter(Boolean);
+
+ if (normalizedPartnerNames.some((partnerName) => partnerName?.includes('google'))) {
+ return 'google';
+ }
+ if (normalizedPartnerNames.some((partnerName) => partnerName?.includes('apple'))) {
+ return 'apple';
+ }
+
+ return 'email';
+}
+
+function getPlanType(policies: Policy[]): FullstoryUserVars['plan_type'] {
+ if (policies.some(isControlPolicy)) {
+ return 'control';
+ }
+ if (policies.some((policy) => policy.type === CONST.POLICY.TYPE.TEAM)) {
+ return 'collect';
+ }
+}
+
+function buildFullstoryUserVars({
+ account,
+ activePolicy,
+ introSelected,
+ loginList,
+ onboarding,
+ onboardingCompanySize,
+ onboardingLastVisitedPath,
+ onboardingPurposeSelected,
+ policies,
+ session,
+ userMetadata,
+}: BuildFullstoryUserVarsParams): FullstoryUserVars {
+ const activePolicies = getActivePolicies(policies ?? null, session?.email);
+ const hasCompletedOnboarding = onboarding?.hasCompletedGuidedSetupFlow;
+ const currentOnboardingChoice = introSelected?.choice ?? onboardingPurposeSelected;
+ const companySize = introSelected?.companySize ?? onboardingCompanySize;
+ const daysTillTrialEnd = getDaysTillDate(userMetadata?.freeTrialEndDate);
+ let userStatus: FullstoryUserVars['user_status'];
+
+ if (hasCompletedOnboarding !== undefined) {
+ userStatus = hasCompletedOnboarding ? 'returning' : 'new';
+ }
+
+ /* eslint-disable @typescript-eslint/naming-convention -- FullStory schema uses external snake_case keys. */
+ return filterObject(
+ {
+ user_type_path: buildUserTypePath(currentOnboardingChoice, companySize, account?.isFromPublicDomain),
+ account_type: activePolicies.length > 0 ? 'business' : 'personal',
+ user_status: userStatus,
+ has_completed_onboarding: hasCompletedOnboarding,
+ onb_step: getOnboardingStep(onboardingLastVisitedPath, hasCompletedOnboarding),
+ user_role: getUserRole(policies),
+ workspace_state: activePolicies.length > 0 ? 'has_workspaces' : 'no_workspaces',
+ workspace_count: activePolicies.length,
+ workspace_member_count: activePolicy ? Object.keys(activePolicy.employeeList ?? {}).length : undefined,
+ free_trial_end_date: userMetadata?.freeTrialEndDate,
+ days_till_trial_end: daysTillTrialEnd,
+ free_trial_status: getFreeTrialStatus(daysTillTrialEnd),
+ plan_type: getPlanType(activePolicies),
+ paid_member: userMetadata?.paidMember,
+ auth_method: getAuthMethod(loginList),
+ } satisfies FullstoryUserVars,
+ (_key, value) => value !== undefined,
+ );
+ /* eslint-enable @typescript-eslint/naming-convention */
+}
+
+function trackFullstoryEvent(eventName: TEventName, eventProperties: FullstoryEventPropertiesMap[TEventName]) {
+ FS.event(
+ eventName,
+ filterObject(eventProperties, (_key, value) => value !== undefined),
+ );
+}
+
+function buildPageViewedEvent(screenName: string, entryPoint: string): FullstoryEventPropertiesMap['Page_viewed'] {
+ /* eslint-disable @typescript-eslint/naming-convention -- FullStory schema uses external snake_case keys. */
+ return {
+ screen_name: screenName,
+ entry_point: entryPoint,
+ onb_step: getOnboardingStep(entryPoint),
+ };
+ /* eslint-enable @typescript-eslint/naming-convention */
+}
+
+export {buildFullstoryUserVars, buildPageViewedEvent, getOnboardingStep, trackFullstoryEvent};
diff --git a/src/libs/Navigation/NavigationRoot.tsx b/src/libs/Navigation/NavigationRoot.tsx
index 2338fda22531..a77c9ff441fa 100644
--- a/src/libs/Navigation/NavigationRoot.tsx
+++ b/src/libs/Navigation/NavigationRoot.tsx
@@ -11,6 +11,7 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useTheme from '@hooks/useTheme';
import useThemePreference from '@hooks/useThemePreference';
import FS from '@libs/Fullstory';
+import {buildPageViewedEvent, trackFullstoryEvent} from '@libs/Fullstory/utils';
import Log from '@libs/Log';
import {setupNavigationFocusReturn, teardownNavigationFocusReturn} from '@libs/NavigationFocusReturn';
import {sanitizeUrlForLogging} from '@libs/sanitizeLogParams';
@@ -98,6 +99,7 @@ function parseAndLogRoute(state: NavigationState) {
const focusedRouteName = focusedRoute?.name;
if (focusedRouteName) {
new FS.Page(focusedRouteName, {path: currentPath}).start();
+ trackFullstoryEvent('Page_viewed', buildPageViewedEvent(focusedRouteName, currentPath));
}
}
diff --git a/tests/unit/FullstoryUtilsTest.ts b/tests/unit/FullstoryUtilsTest.ts
new file mode 100644
index 000000000000..e8e554effec8
--- /dev/null
+++ b/tests/unit/FullstoryUtilsTest.ts
@@ -0,0 +1,78 @@
+/* eslint-disable @typescript-eslint/naming-convention */
+import CONST from '@src/CONST';
+import {buildFullstoryUserVars, buildPageViewedEvent, getOnboardingStep} from '@src/libs/Fullstory/utils';
+import type {Policy} from '@src/types/onyx';
+
+describe('FullstoryUtils', () => {
+ it('builds expected FullStory user vars from onboarding and workspace context', () => {
+ const policy = {
+ id: '1',
+ name: 'Test Workspace',
+ type: CONST.POLICY.TYPE.TEAM,
+ role: CONST.POLICY.ROLE.ADMIN,
+ employeeList: {
+ 1: {email: 'a@test.com'},
+ 2: {email: 'b@test.com'},
+ },
+ } as unknown as Policy;
+
+ const userVars = buildFullstoryUserVars({
+ account: {isFromPublicDomain: true},
+ activePolicy: policy,
+ introSelected: {
+ choice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM,
+ companySize: '1-10',
+ },
+ loginList: {
+ test: {
+ partnerName: 'google.com',
+ },
+ },
+ onboarding: {
+ hasCompletedGuidedSetupFlow: false,
+ signupQualifier: CONST.ONBOARDING_SIGNUP_QUALIFIERS.SMB,
+ },
+ onboardingCompanySize: '1-10',
+ onboardingLastVisitedPath: '/onboarding/workspace',
+ onboardingPurposeSelected: CONST.ONBOARDING_CHOICES.MANAGE_TEAM,
+ policies: {
+ [`policy_${policy.id}`]: policy,
+ },
+ session: {email: 'test@test.com'},
+ userMetadata: {
+ freeTrialEndDate: '2099-05-31 23:59:59',
+ paidMember: true,
+ },
+ });
+
+ expect(userVars).toMatchObject({
+ user_type_path: 'team_1_10_public',
+ account_type: 'business',
+ user_status: 'new',
+ has_completed_onboarding: false,
+ onb_step: 'registration',
+ user_role: 'admin',
+ workspace_state: 'has_workspaces',
+ workspace_count: 1,
+ workspace_member_count: 2,
+ free_trial_end_date: '2099-05-31 23:59:59',
+ free_trial_status: 'active',
+ plan_type: 'collect',
+ paid_member: true,
+ auth_method: 'google',
+ });
+ expect(userVars.days_till_trial_end).toBeGreaterThan(0);
+ });
+
+ it('builds page viewed event metadata', () => {
+ expect(buildPageViewedEvent('OnboardingWorkspace', '/onboarding/workspace')).toEqual({
+ screen_name: 'OnboardingWorkspace',
+ entry_point: '/onboarding/workspace',
+ onb_step: 'registration',
+ });
+ });
+
+ it('returns completed onboarding step when flow is finished', () => {
+ expect(getOnboardingStep('/settings/workspaces', true)).toBe('completed');
+ });
+});