[Submit] In-product modal for existing "Get paid back" intent users (Wave 4)#95099
[Submit] In-product modal for existing "Get paid back" intent users (Wave 4)#95099abzokhattab wants to merge 17 commits into
Conversation
… users Introduces a guard-triggered, dismiss-once modal (behind the SUBMIT_2026 beta) for users who previously selected the "Get paid back" (EMPLOYER) onboarding intent but are not on any paid workspace. "Get the free plan" creates a Submit workspace via useAutoCreateSubmitWorkspace and routes to Categories with the #admins room in the RHP; "No thanks" dismisses it permanently via the DismissedProductTraining NVP.
|
Hey, I noticed you changed If you want to automatically generate translations for other locales, an Expensify employee will have to:
Alternatively, if you are an external contributor, you can run the translation script locally with your own OpenAI API key. To learn more, try running: npx ts-node ./scripts/generateTranslations.ts --helpTypically, you'd want to translate only what you changed by running |
Codecov Report✅ Changes either increased or maintained existing code coverage, great job!
|
Update the title/intro copy and add the four-item benefits bullet list
("Get reimbursed faster", "Build expense reports in seconds", "Categorize
your expenses", "Invite your boss when you're ready") to match the design
mockup, across all supported languages.
Per the design doc, the modal targets the legacy cohort who selected the "Get paid back" intent before the Submit workspace existed and are therefore "not on a workspace". Gate on getGroupPoliciesWhereReportCanBeCreated (paid Team/Corporate AND free Submit workspaces) instead of only paid policies, so users who already own a Submit workspace - including anyone who just completed the EMPLOYER onboarding flow - are excluded. This also removes the transient NotFound /home flash caused by the guard racing the onboarding navigation.
- Persist "modal shown" via new nvp_submitMigrationModalShown NVP (SetNameValuePair) instead of the product-training NVP, per BE decision - Skip CompleteGuidedSetup for already-onboarded users so the modal closes immediately on "Get the free plan" - Use the ReceiptWrangler lasso illustration on a yellow panel to match the design
Add SUBMIT_PLAN_MODAL_NAVIGATOR to MODAL_ROUTES_TO_DISMISS so that Navigation.dismissModal() actually pops the modal when the user taps "Get the free plan", instead of leaving it on screen while navigating to Categories.
|
it looks like the backend doesn't preserve the choice value yet ... am i passing the values right? @iwiznia
|
|
also @garrettmknight let me know when the new illustration is ready so that i can use it |
|
Also, I have another question ... given that a user does the following steps
now should the |
I'm not sure what I am looking there exactly, if your refer that the NVP is not returned in openapp nor updates are sent to it, that's because it was deployed: 6 hours ago (Jul 3, 2026, 6:33 AM GMT+2) |
When the guard proactively opens the modal on boot, the active route can be a screen that isn't a valid entry screen for the submit-plan-welcome dynamic route (e.g. /settings/troubleshoot after "Clear cache and restart"), which made the route resolve to NotFound. Because the modal never mounted, it also never persisted the "shown" NVP and kept re-triggering. Fall back to HOME when the active route isn't an allowed entry screen so the modal renders correctly and records that it was seen.
|
@iwiznia thanks! but it still doesn't work even after the deploy .. repro:
i'm setting it via SetNameValuePair with name: can you check if it's being persisted/returned on the BE side? Screen.Recording.2026-07-05.at.12.26.54.mov |
|
@abzokhattab illustration has been added here, but hasn't deployed yet. |
|
great thanks for tackling that ... i will get back on this once this BE is handled #95099 (comment) |
That's the problem. The NVPs in onyx are prefixed with |
The backend NVP is named `submitMigrationModalShown`; the `nvp_` prefix is an Onyx-key-only convention. Sending the prefixed key as the API name meant the value was never persisted or returned in OpenApp, so the modal kept re-triggering. Send the unprefixed name to the API while still storing the value under the prefixed Onyx key.
…odal-87912 # Conflicts: # src/CONST/index.ts # src/NAVIGATORS.ts # src/ROUTES.ts # src/SCREENS.ts # src/languages/en.ts # src/libs/Navigation/AppNavigator/AuthScreens.tsx # src/libs/Navigation/guards/index.ts # src/libs/Navigation/linkingConfig/config.ts # src/libs/Navigation/types.ts
Coalesce the guard's proactive-navigation decision onto a microtask so it runs once after the OpenApp Onyx batch fully settles, preventing a callback-ordering race where a stale hasShownSubmitMigrationModal reopened the modal for users who already saw it. Data subscriptions now only feed the cache; the one-shot redirect is driven solely by the app-load/session signal. Also swaps the modal illustration to ReceiptWranglerSpaceCowgirl with the correct aspect ratio and inset padding.
Drop the unused shouldShowSubmitPlanWelcomeModal export and add a UI test asserting setSubmitMigrationModalShown fires when the modal is removed from the navigation stack.
Gate the submit plan welcome decision on HAS_LOADED_APP so it waits for OpenApp to deliver this session's account data (including the shown-flag). This closes the sign-in race where SignInUser authenticates and delivers the eligibility NVPs before OpenApp fetches nvp_submitMigrationModalShown, which made the modal reappear for users who had already seen it. Also removes the debug/info logs from the guard and modal and drops the onboarding-completion warn log (the catch now swallows with an explanation).
…ayload type guard - Log.warn in getValidModalBasePath catch for debuggability - Condense scheduleSubmitPlanWelcomeModalEvaluation doc comment - Replace unsafe RESET payload cast with isNavigationStatePayload type guard
|
This PR adds a new |
Good question! Yeah, if a new user picks "Get paid back by my employer" during onboarding today (with the beta on), we auto-create the Submit workspace for them, so they won't see this modal. This modal is a migration modal — it targets existing users who chose the EMPLOYER intent in the past (before we started auto-creating the Submit workspace) and therefore don't own/belong to any group workspace yet. The guard only fires when the user has zero group workspaces ( So for QA there's no real migrated account handy, and deleting the workspace is just the easiest way to reproduce that target state (EMPLOYER intent + no group workspace) on a fresh account. Once you delete it, the modal should pop. |
Reviewer Checklist
Screenshots/VideosAndroid: mWeb ChromeScreen.Recording.2026-07-12.at.21.47.02.moviOS: HybridAppScreen.Recording.2026-07-12.at.22.12.38.moviOS: mWeb SafariScreen.Recording.2026-07-12.at.21.27.52.movScreen.Recording.2026-07-12.at.21.46.04.movMacOS: Chrome / SafariScreen.Recording.2026-07-12.at.21.27.27.mov |
|
@abzokhattab Can you fix the test? |
|
This PR adds a new |
|
@abzokhattab On web or mWeb, after I deleted Workspace, In-Product modal will show up, but with native. It only show up after a refresh, is that expected? Screen.Recording.2026-07-12.at.22.10.28.mov |
|
per the project doc, the important thing is that it shows up on logging in .. the steps are just a simluation for old accounts ... also lets check with product if its fine for the modal to show when a workspace is deleted or should it be only shown on logging in ,, See also my earlier question. Proposed backend change: Set the |
tgolen
left a comment
There was a problem hiding this comment.
I agree with your explanation of why it's OK to use connectWithoutView() in the guards.
|
|
||
| import MigratedUserWelcomeModalGuard, {onSessionOrLoadingAppChanged} from './MigratedUserWelcomeModalGuard'; | ||
| import OnboardingGuard from './OnboardingGuard'; | ||
| import SubmitPlanWelcomeModalGuard, {onSessionOrLoadingAppChanged as onSessionOrLoadingAppChangedForSubmitPlanModal} from './SubmitPlanWelcomeModalGuard'; |
There was a problem hiding this comment.
I am not a very big fan of this pattern. It's fine to duplicate the connectWithoutView() calls in the different guards (eg. having multiple guards connected to SESSION is just fine). The subscriptions themselves are meant to be super cheap.
I think these callbacks add more complexity than they should, and ultimately, I don't think this index.ts. file needs any Onyx references (only the guards themselves).
Could you please refactor this? I know you aren't adding onSessionOrLoadingAppChanged but I think it makes sense that if this PR follows the right pattern, then doing a little bit of cleanup to also remove onSessionOrLoadingAppChanged is worth it. I'm also fine with cleaning onSessionOrLoadingAppChanged up in a separate PR, as long as SubmitPlanWelcomeModalGuard follows the proper pattern in this PR.
There was a problem hiding this comment.
Great point, agreed.
SubmitPlanWelcomeModalGuard now owns its own connectWithoutView subscriptions for SESSION and IS_LOADING_APP (it already self-subscribed to everything else — betas/intro/onboarding/policies/etc.), so I removed the exported onSessionOrLoadingAppChanged callback and its wiring from index.ts. index.ts no longer references this guard for any Onyx data.
I left MigratedUserWelcomeModalGuard's onSessionOrLoadingAppChanged (and the session/isLoadingApp subscriptions that createGuardContext still relies on) as-is, since that touches another guard and the shared context
…eModalGuard Subscribe to SESSION and IS_LOADING_APP directly in the guard instead of receiving them via an onSessionOrLoadingAppChanged callback wired from guards/index.ts, so the guard owns all its subscriptions and index.ts no longer references it for Onyx data (per @tgolen).
|
The illustration feels quite big compared to Figma. Is it showing at 264x160? |




Explanation of Change
Wave 4 of the Submit plan project (#87912). Adds an in-product welcome modal that nudges existing users who previously selected the "Get paid back" (
EMPLOYER) onboarding intent but are not an owner or member of any paid workspace to adopt the free Submit plan.The whole feature is gated behind the
SUBMIT_2026beta and mirrors the existingMigratedUserWelcomeModalarchitecture: a centered modal in its own navigator, proactively shown by a navigation guard, and dismissed once via theDismissedProductTrainingNVP.Behavior:
useAutoCreateSubmitWorkspace(firstName, lastName), which creates aSUBMITworkspace, completes onboarding (producing the#adminsConcierge welcome message), and routes the user to Workspace → Categories with#adminsopen in the RHP.Trigger conditions (all required): authenticated and app not loading,
SUBMIT_2026beta enabled,introSelected.choice === EMPLOYER, onboarding completed, no paid (Team/Corporate) policy, and not previously dismissed.Main changes:
SubmitPlanWelcomeModalcomponent,SubmitPlanWelcomeModalNavigator, andSubmitPlanWelcomeModalGuard(registered inguards/index.ts).SCREENS,NAVIGATORS,ROUTES(submit-plan-welcomedynamic route), navigation param-list types,linkingConfig, and aRootStack.ScreeninAuthScreens.SUBMIT_PLAN_WELCOME_MODALconstant +DismissedProductTrainingtype entry.Fixed Issues
$ #87912
PROPOSAL:
Tests
Enable the
SUBMIT_2026beta for your account.Use an eligible account: chose "Get paid back by my employer" during onboarding, finished onboarding, and is not in any group workspace — either log in with an account that has no workspace, or delete all existing workspaces.
Open the app. The "Get paid back, for free" modal should appear automatically.
Tap Get the free plan and verify a Submit workspace is created and you are routed to Workspace → Categories.
Reload the app and verify the modal does not appear again.
Repeat with another eligible account, tap No thanks (or backdrop / ESC), and verify the modal closes and does not reappear after reload.
Verify that no errors appear in the JS console
Offline tests
QA Steps
Same as tests.
PR Author Checklist
### Fixed Issuessection aboveTestssectionOffline stepssectionQA stepssectiontoggleReportand notonIconClick)Avatar, I verified the components usingAvatarare working as expected)StyleUtils.getBackgroundAndBorderStyle(theme.componentBG))npm run compress-svg)Avataris modified, I verified thatAvataris working as expected in all cases)Designlabel and/or tagged@Expensify/designso the design team can review the changes.mainbranch was merged into this PR after a review, I tested again and verified the outcome was still expected according to theTeststeps.Screenshots/Videos
Android: Native
Android: mWeb Chrome
iOS: Native
Screen.Recording.2026-07-11.at.21.53.45.mov
iOS: mWeb Safari
Screen.Recording.2026-07-11.at.21.56.04.mov
MacOS: Chrome / Safari
Screen.Recording.2026-07-11.at.21.35.10.mov
Screen.Recording.2026-07-11.at.21.51.14.mov