Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,13 @@ const createExpenseOnboardingChoices = {
SUBMIT: backendOnboardingChoices.SUBMIT,
} as const;

// Values accepted by the `intent` param on the onboarding deeplink (e.g. `onboarding?intent=submit`). These are
// short, stable, marketing-friendly aliases rather than the internal onboarding choice strings, because they are
// embedded in emails and other links we can't redeploy.
const onboardingIntents = {
SUBMIT: 'submit',
} as const;

const signupQualifiers = {
INDIVIDUAL: 'individual',
VSB: 'vsb',
Expand Down Expand Up @@ -6653,6 +6660,7 @@ const CONST = {
EXPENSIFY_ICON_NAME: 'Expensify',

ONBOARDING_CHOICES: {...onboardingChoices},
ONBOARDING_INTENTS: {...onboardingIntents},
SELECTABLE_ONBOARDING_CHOICES: {...selectableOnboardingChoices},
CREATE_EXPENSE_ONBOARDING_CHOICES: {...createExpenseOnboardingChoices},
ONBOARDING_SIGNUP_QUALIFIERS: {...signupQualifiers},
Expand Down Expand Up @@ -9578,6 +9586,9 @@ type IOUActionParams = ValueOf<typeof CONST.IOU.ACTION_PARAMS>;
type SubscriptionType = ValueOf<typeof CONST.SUBSCRIPTION.TYPE>;
type CancellationType = ValueOf<typeof CONST.CANCELLATION_TYPE>;

/** Valid values for the `intent` param on the onboarding deeplink */
type OnboardingIntent = ValueOf<typeof CONST.ONBOARDING_INTENTS>;

/** Valid `page` values for the Enable Payments flow */
type EnablePaymentsPageType = ValueOf<typeof CONST.ENABLE_PAYMENTS.PAGE_NAMES>;

Expand All @@ -9598,6 +9609,7 @@ export type {
CancellationType,
OnboardingInvite,
OnboardingAccounting,
OnboardingIntent,
IOUActionParams,
EnablePaymentsPageType,
EnablePaymentsSubPageType,
Expand Down
8 changes: 6 additions & 2 deletions src/ROUTES.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import type {UpperCaseCharacters} from 'type-fest/source/internal';

import type {SearchFilterKey, SearchQueryString, UserFriendlyKey} from './components/Search/types';
import type CONST from './CONST';
import type {EnablePaymentsPageType, EnablePaymentsSubPageType, IOUAction, IOURequestType, IOUType, OdometerImageType} from './CONST';
import type {EnablePaymentsPageType, EnablePaymentsSubPageType, IOUAction, IOURequestType, IOUType, OdometerImageType, OnboardingIntent} from './CONST';
import type {ReplacementReason} from './libs/actions/Card';
import type {RootNavigatorParamList} from './libs/Navigation/types';
import type {Screen} from './SCREENS';
Expand Down Expand Up @@ -3939,7 +3939,11 @@ const ROUTES = {
ONBOARDING_ROOT: {
route: 'onboarding',

getRoute: () => 'onboarding' as const,
/**
* @param intent - Pre-selects an onboarding outcome so a one-click link can land the user on the
* matching workspace instead of making them pick the intent in the UI.
*/
getRoute: (intent?: OnboardingIntent) => (intent ? (`onboarding?intent=${intent}` as const) : ('onboarding' as const)),
},
ONBOARDING_PERSONAL_DETAILS: {
route: 'onboarding/personal-details',
Expand Down
4 changes: 2 additions & 2 deletions src/components/SubmitPlanWelcomeModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,8 @@ function SubmitPlanWelcomeModal() {

const handleConfirm = () => {
// The user has already completed onboarding, so we skip CompleteGuidedSetup and just create the
// Submit workspace. autoCreateSubmitWorkspace then dismisses this modal and navigates to Categories
// with #admins in the RHP, which triggers the useBeforeRemove persistence above.
// Submit workspace. autoCreateSubmitWorkspace then dismisses this modal and navigates to Spend
// with #admins in the side panel, which triggers the useBeforeRemove persistence above.
autoCreateSubmitWorkspace(firstName ?? '', lastName ?? '', false);
};

Expand Down
24 changes: 24 additions & 0 deletions src/hooks/useOnboardingDeeplinkIntent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import {useInitialURLState} from '@components/InitialURLContextProvider';

import getOnboardingIntentFromUrl from '@libs/getOnboardingIntentFromUrl';
import getCurrentUrl from '@libs/Navigation/currentUrl';

import type {OnboardingIntent} from '@src/CONST';

import {useState} from 'react';

/**
* Resolves the onboarding outcome requested by the deeplink this session was opened with, e.g. `onboarding?intent=submit`.
*
* The URL is latched at mount because the app rewrites it as soon as it navigates, which happens well before the
* intent has been acted on. The initial URL is the only source on native, where the browser URL is empty and the
* deeplink resolves asynchronously.
*/
function useOnboardingDeeplinkIntent(): OnboardingIntent | undefined {
const {initialURL} = useInitialURLState();
const [urlAtMount] = useState(getCurrentUrl);

return getOnboardingIntentFromUrl(urlAtMount) ?? getOnboardingIntentFromUrl(initialURL);
Comment thread
MelvinBot marked this conversation as resolved.
Comment thread
MelvinBot marked this conversation as resolved.
}

export default useOnboardingDeeplinkIntent;
2 changes: 2 additions & 0 deletions src/libs/Navigation/AppNavigator/AuthScreens.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ import MultifactorAuthenticationModalNavigator from './Navigators/MultifactorAut
import OnboardingModalNavigator from './Navigators/OnboardingModalNavigator';
import SubmitPlanWelcomeModalNavigator from './Navigators/SubmitPlanWelcomeModalNavigator';
import TestToolsModalNavigator from './Navigators/TestToolsModalNavigator';
import SubmitIntentDeeplinkHandler from './SubmitIntentDeeplinkHandler';
import TestDriveDemoNavigator from './TestDriveDemoNavigator';
import ThreeDSAuthHandler from './ThreeDSAuthHandler';
import useModalCardStyleInterpolator from './useModalCardStyleInterpolator';
Expand Down Expand Up @@ -164,6 +165,7 @@ function AuthScreens() {
return (
<>
<AuthScreensInitHandler />
<SubmitIntentDeeplinkHandler />
<ThreeDSAuthHandler />
<UserStatusHandler />
<SupportalPermissionDeniedModal />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import useAutoCreateSubmitWorkspace from '@hooks/useAutoCreateSubmitWorkspace';
import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
import useOnyx from '@hooks/useOnyx';

import {setSubmitMigrationModalShown} from '@userActions/User';

import ONYXKEYS from '@src/ONYXKEYS';

import {hasCompletedGuidedSetupFlowSelector} from '@selectors/Onboarding';
import {isSupportalSessionSelector} from '@selectors/Session';
import {useEffect, useRef} from 'react';

/**
* Creates the Submit workspace requested by an `intent=submit` onboarding deeplink.
*
* The link is only sent to existing users, so it acts solely on recipients who have already been through guided
* setup: for them the workspace is created outright and they land wherever the "Submit to my employer" flow normally
* leaves them. Anyone who still has onboarding ahead of them is left to it untouched, since that flow already offers
* the Submit outcome.
*
* Only rendered once the deeplink has been recognized, so the Onyx subscriptions behind `useAutoCreateSubmitWorkspace`
* are never set up for ordinary sessions.
*/
function ApplySubmitOnboardingIntent() {
const {firstName, lastName} = useCurrentUserPersonalDetails();
const autoCreateSubmitWorkspace = useAutoCreateSubmitWorkspace();

// HAS_LOADED_APP only flips true once this session's account data has landed, so waiting on it keeps the
// eligibility checks inside useAutoCreateSubmitWorkspace (existing workspaces, restricted policy creation)
// from running against a half-populated store and creating a duplicate workspace.
const [hasLoadedApp] = useOnyx(ONYXKEYS.HAS_LOADED_APP);
const [isOnboardingCompleted] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasCompletedGuidedSetupFlowSelector});
const [isSupportalSession] = useOnyx(ONYXKEYS.SESSION, {selector: isSupportalSessionSelector});

const hasRun = useRef(false);

useEffect(() => {
if (hasRun.current || !hasLoadedApp || isOnboardingCompleted === undefined || isSupportalSession) {
return;
}
hasRun.current = true;

// Recipients who never finished guided setup are left to the normal onboarding flow, which already offers
// the Submit outcome.
if (!isOnboardingCompleted) {
return;
}

// The deeplink delivers the same outcome as the Submit plan welcome modal, so record the modal as seen to
// stop it from opening on top of the workspace we're about to create.
setSubmitMigrationModalShown();

// Guided setup is already done, so it must not run again. When the user already owns a Submit workspace,
// useAutoCreateSubmitWorkspace skips creation and navigates to that workspace instead, which is what makes
// repeat clicks of the link idempotent.
autoCreateSubmitWorkspace(firstName ?? '', lastName ?? '', false);
}, [autoCreateSubmitWorkspace, firstName, hasLoadedApp, isOnboardingCompleted, isSupportalSession, lastName]);

return null;
}

export default ApplySubmitOnboardingIntent;
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import useOnboardingDeeplinkIntent from '@hooks/useOnboardingDeeplinkIntent';

import CONST from '@src/CONST';

import React from 'react';

import ApplySubmitOnboardingIntent from './ApplySubmitOnboardingIntent';

/**
* Recognizes the `intent=submit` onboarding deeplink and hands off to the component that acts on it.
*/
function SubmitIntentDeeplinkHandler() {
const onboardingDeeplinkIntent = useOnboardingDeeplinkIntent();

if (onboardingDeeplinkIntent !== CONST.ONBOARDING_INTENTS.SUBMIT) {
return null;
}

return <ApplySubmitOnboardingIntent />;
}

export default SubmitIntentDeeplinkHandler;
47 changes: 47 additions & 0 deletions src/libs/getOnboardingIntentFromUrl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/**
* Reads the `intent` param of the onboarding deeplink (e.g. `onboarding?intent=submit`), which lets a one-click
* link pre-select an onboarding outcome instead of asking the recipient to pick it in the UI.
*
* The param arrives in one of two shapes:
* - directly, when the recipient is already signed in: `/onboarding?intent=submit`
* - nested in the `exitTo` of an auth handoff link, where `onboarding?intent=submit` is URL-encoded:
* `/transition?...&exitTo=<encoded>` or `/v/<accountID>/<validateCode>?exitTo=<encoded>`
*
* Nesting it in `exitTo` is what carries the intent across the logged-out -> logged-in transition: the deeplink
* outlives the sign-in itself, so the intent is still readable once the authenticated screens mount.
*/
import type {OnboardingIntent} from '@src/CONST';
import CONST from '@src/CONST';
import ROUTES from '@src/ROUTES';

import {getSearchParamFromPath} from './Url';

const ONBOARDING_INTENT_VALUES = new Set<string>(Object.values(CONST.ONBOARDING_INTENTS));

function isOnboardingIntent(value: string | null): value is OnboardingIntent {
return !!value && ONBOARDING_INTENT_VALUES.has(value);
}

/** Strips the scheme and host so absolute URLs and in-app paths can be inspected the same way. */
function getPathWithQuery(url: string): string {
const [withoutHash] = url.replace(/^[a-z][\w+.-]*:\/\/[^/]*/i, '').split('#', 2);
return withoutHash.replace(/^\/+/, '');
Comment thread
MelvinBot marked this conversation as resolved.
Outdated
}

function getOnboardingIntentFromUrl(url: string | null | undefined): OnboardingIntent | undefined {
if (!url) {
return undefined;
}

const pathWithQuery = getPathWithQuery(url);
const onboardingPathWithQuery = pathWithQuery.startsWith(ROUTES.ONBOARDING_ROOT.route) ? pathWithQuery : getSearchParamFromPath(pathWithQuery, 'exitTo');

if (!onboardingPathWithQuery?.startsWith(ROUTES.ONBOARDING_ROOT.route)) {
return undefined;
}

const intent = getSearchParamFromPath(onboardingPathWithQuery, 'intent');
return isOnboardingIntent(intent) ? intent : undefined;
}

export default getOnboardingIntentFromUrl;
42 changes: 42 additions & 0 deletions tests/unit/getOnboardingIntentFromUrlTest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import getOnboardingIntentFromUrl from '@libs/getOnboardingIntentFromUrl';

import CONST from '@src/CONST';

describe('getOnboardingIntentFromUrl', () => {
it('reads the intent from a direct onboarding link', () => {
expect(getOnboardingIntentFromUrl('https://new.expensify.com/onboarding?intent=submit')).toBe(CONST.ONBOARDING_INTENTS.SUBMIT);
});

it('reads the intent from an in-app path without an origin', () => {
expect(getOnboardingIntentFromUrl('/onboarding?intent=submit')).toBe(CONST.ONBOARDING_INTENTS.SUBMIT);
});

it('reads the intent from the exitTo of an OldDot transition link', () => {
const url = 'https://new.expensify.com/transition?email=me%40example.com&shortLivedAuthToken=abc123&exitTo=onboarding%3Fintent%3Dsubmit';

expect(getOnboardingIntentFromUrl(url)).toBe(CONST.ONBOARDING_INTENTS.SUBMIT);
});

it('reads the intent from the exitTo of a magic link', () => {
const url = 'https://new.expensify.com/v/12345/678910?exitTo=onboarding%3Fintent%3Dsubmit';

expect(getOnboardingIntentFromUrl(url)).toBe(CONST.ONBOARDING_INTENTS.SUBMIT);
});

it('reads the intent from an unencoded exitTo, which OldDot mobile does not encode', () => {
const url = 'https://new.expensify.com/transition?shortLivedAuthToken=abc123&exitTo=onboarding?intent=submit';

expect(getOnboardingIntentFromUrl(url)).toBe(CONST.ONBOARDING_INTENTS.SUBMIT);
});

it.each([
['no url', undefined],
['an empty url', ''],
['an onboarding link without an intent', 'https://new.expensify.com/onboarding'],
['an unknown intent value', 'https://new.expensify.com/onboarding?intent=notARealIntent'],
['an intent on a non-onboarding route', 'https://new.expensify.com/settings/profile?intent=submit'],
['an intent on a non-onboarding exitTo', 'https://new.expensify.com/transition?exitTo=workspace%2Fnew%3Fintent%3Dsubmit'],
])('returns undefined for %s', (_description, url) => {
expect(getOnboardingIntentFromUrl(url)).toBeUndefined();
});
});
Loading