From e05a88c6a6ea46fb751778cbaf947505095aac6d Mon Sep 17 00:00:00 2001
From: gijoe0295 <153004152+gijoe0295@users.noreply.github.com>
Date: Fri, 19 Jun 2026 11:41:56 +0700
Subject: [PATCH 01/35] Reapply "Merge pull request #92007 from
 gijoe0295/feat/ai-feature-promo-modal"

This reverts commit 9586f9688b0a0d5f3723c6caa8e605b0a333ec04.
---
 assets/animations/CustomAgents.lottie         | Bin 0 -> 39784 bytes
 assets/animations/ExpenseAssistant.lottie     | Bin 0 -> 26689 bytes
 assets/animations/SpendAnalysis.lottie        | Bin 0 -> 21421 bytes
 config/eslint/eslint.seatbelt.tsv             |   5 +-
 jest/setupAfterEnv.ts                         |  10 +
 src/CONST/index.ts                            |   9 +
 src/NAVIGATORS.ts                             |   1 +
 src/ROUTES.ts                                 |   1 +
 src/SCREENS.ts                                |   4 +
 src/components/AIFeaturesPromoModal/index.tsx |  92 +++++
 .../FeatureTrainingModalBody.tsx              | 110 ++++++
 .../FeatureTrainingModalCarouselBody.tsx      | 302 ++++++++++++++
 .../FeatureTrainingModalContent.tsx           | 138 +++++++
 .../FeatureTrainingModalIllustration.tsx      | 179 +++++++++
 .../FeatureTrainingModalTextContent.tsx       |  76 ++++
 .../index.tsx}                                | 370 +++++++-----------
 src/components/Lottie/index.tsx               |  12 +-
 src/components/LottieAnimations/index.tsx     |  16 +
 src/components/Section/index.tsx              |   1 +
 src/languages/de.ts                           |  16 +
 src/languages/en.ts                           |  16 +
 src/languages/es.ts                           |  16 +
 src/languages/fr.ts                           |  16 +
 src/languages/it.ts                           |  16 +
 src/languages/ja.ts                           |  16 +
 src/languages/nl.ts                           |  16 +
 src/languages/pl.ts                           |  16 +
 src/languages/pt-BR.ts                        |  16 +
 src/languages/zh-hans.ts                      |  16 +
 .../Navigation/AppNavigator/AuthScreens.tsx   |   6 +
 .../AIFeaturesPromoModalNavigator.tsx         |  26 ++
 .../Navigation/guards/AIFeaturesPromoGuard.ts | 226 +++++++++++
 src/libs/Navigation/guards/index.ts           |  10 +-
 src/libs/Navigation/linkingConfig/config.ts   |   9 +
 src/libs/Navigation/types.ts                  |   6 +
 src/styles/index.ts                           |   6 +
 src/styles/utils/index.ts                     |   9 +
 src/styles/variables.ts                       |   1 +
 src/types/onyx/DismissedProductTraining.ts    |   5 +
 39 files changed, 1552 insertions(+), 238 deletions(-)
 create mode 100644 assets/animations/CustomAgents.lottie
 create mode 100644 assets/animations/ExpenseAssistant.lottie
 create mode 100644 assets/animations/SpendAnalysis.lottie
 create mode 100644 src/components/AIFeaturesPromoModal/index.tsx
 create mode 100644 src/components/FeatureTrainingModal/FeatureTrainingModalBody.tsx
 create mode 100644 src/components/FeatureTrainingModal/FeatureTrainingModalCarouselBody.tsx
 create mode 100644 src/components/FeatureTrainingModal/FeatureTrainingModalContent.tsx
 create mode 100644 src/components/FeatureTrainingModal/FeatureTrainingModalIllustration.tsx
 create mode 100644 src/components/FeatureTrainingModal/FeatureTrainingModalTextContent.tsx
 rename src/components/{FeatureTrainingModal.tsx => FeatureTrainingModal/index.tsx} (51%)
 create mode 100644 src/libs/Navigation/AppNavigator/Navigators/AIFeaturesPromoModalNavigator.tsx
 create mode 100644 src/libs/Navigation/guards/AIFeaturesPromoGuard.ts

diff --git a/assets/animations/CustomAgents.lottie b/assets/animations/CustomAgents.lottie
new file mode 100644
index 0000000000000000000000000000000000000000..6f6b40b8c4634fe0c16f6057ae1ffa5356a92988
GIT binary patch
diff --git a/assets/animations/ExpenseAssistant.lottie b/assets/animations/ExpenseAssistant.lottie
new file mode 100644
index 0000000000000000000000000000000000000000..e2c0d9729eae30384d5a8d40622ea296edc6d7b0
GIT binary patch
diff --git a/assets/animations/SpendAnalysis.lottie b/assets/animations/SpendAnalysis.lottie
new file mode 100644
index 0000000000000000000000000000000000000000..f1cc7ab61cb541c7e831cdbe0ed09b21294961b6
GIT binary patch
diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv
index 70b186393a83..56d6dad47613 100644
--- a/config/eslint/eslint.seatbelt.tsv
+++ b/config/eslint/eslint.seatbelt.tsv
@@ -171,8 +171,8 @@
 "../../src/components/EmptySelectionListContent.tsx"	"@typescript-eslint/no-unsafe-type-assertion"	1
 "../../src/components/EnvironmentBadge.tsx"	"no-restricted-syntax"	1
 "../../src/components/ExplanationModal.tsx"	"no-restricted-syntax"	1
-"../../src/components/FeatureTrainingModal.tsx"	"@typescript-eslint/no-unsafe-type-assertion"	2
-"../../src/components/FeatureTrainingModal.tsx"	"react-hooks/set-state-in-effect"	1
+"../../src/components/FeatureTrainingModal/FeatureTrainingModalIllustration.tsx"	"@typescript-eslint/no-unsafe-type-assertion"	2
+"../../src/components/FeatureTrainingModal/FeatureTrainingModalIllustration.tsx"	"react-hooks/set-state-in-effect"	1
 "../../src/components/FeedbackSurvey.tsx"	"react-hooks/set-state-in-effect"	1
 "../../src/components/FilePicker/index.native.tsx"	"react-hooks/refs"	1
 "../../src/components/FilePicker/index.tsx"	"react-hooks/refs"	1
@@ -670,6 +670,7 @@
 "../../src/libs/Navigation/PlatformStackNavigation/ScreenLayout.tsx"	"@typescript-eslint/no-unsafe-type-assertion"	1
 "../../src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/ScreenFreezeWrapper/index.native.tsx"	"@typescript-eslint/no-unsafe-type-assertion"	2
 "../../src/libs/Navigation/PlatformStackNavigation/navigationOptions/animation/withAnimation.ts"	"@typescript-eslint/no-unsafe-type-assertion"	6
+"../../src/libs/Navigation/guards/AIFeaturesPromoGuard.ts"	"@typescript-eslint/no-unsafe-type-assertion"	1
 "../../src/libs/Navigation/guards/MigratedUserWelcomeModalGuard.ts"	"@typescript-eslint/no-unsafe-type-assertion"	1
 "../../src/libs/Navigation/guards/OnboardingGuard.ts"	"@typescript-eslint/no-unsafe-type-assertion"	2
 "../../src/libs/Navigation/helpers/createNormalizedConfigs.ts"	"@typescript-eslint/no-deprecated/escape"	1
diff --git a/jest/setupAfterEnv.ts b/jest/setupAfterEnv.ts
index cf6a137be8e3..4f1a66081887 100644
--- a/jest/setupAfterEnv.ts
+++ b/jest/setupAfterEnv.ts
@@ -6,6 +6,16 @@ import ONYXKEYS from '@src/ONYXKEYS';
 
 jest.useRealTimers();
 
+// Globally short-circuit AIFeaturesPromoGuard in tests. The real guard proactively
+// navigates any authenticated session to /ai-features-promo unless the dismissal NVP
+// is set, which would otherwise intercept navigation in unrelated UI tests. Tests
+// that need the real guard can override this mock locally.
+jest.mock('@libs/Navigation/guards/AIFeaturesPromoGuard', () => ({
+    __esModule: true,
+    default: {name: 'AIFeaturesPromoGuard', evaluate: () => ({type: 'ALLOW'})},
+    onSessionOrLoadingAppChanged: jest.fn(),
+}));
+
 // Patch Keyboard.addListener to return a subscription object with .remove() so that
 // @react-navigation/bottom-tabs useIsKeyboardShown hook doesn't crash on cleanup.
 if (Keyboard && typeof Keyboard.addListener === 'function') {
diff --git a/src/CONST/index.ts b/src/CONST/index.ts
index 6af591bed322..811031a296e8 100644
--- a/src/CONST/index.ts
+++ b/src/CONST/index.ts
@@ -7258,6 +7258,7 @@ const CONST = {
         SCREENS.SAML_SIGN_IN,
         SCREENS.VALIDATE_LOGIN,
         SCREENS.MIGRATED_USER_WELCOME_MODAL.ROOT,
+        SCREENS.AI_FEATURES_PROMO_MODAL.ROOT,
         SCREENS.MONEY_REQUEST.STEP_SCAN,
         SCREENS.DOMAIN.MEMBERS_MOVE_TO_GROUP,
         ...Object.values(SCREENS.MULTIFACTOR_AUTHENTICATION),
@@ -7777,6 +7778,14 @@ const CONST = {
 
     MIGRATED_USER_WELCOME_MODAL: 'migratedUserWelcomeModal',
 
+    AI_FEATURES_PROMO_MODAL: 'aiFeaturesPromoModal',
+
+    AI_FEATURES_PROMO_LEARN_MORE_URLS: {
+        SPEND_ANALYSIS: 'https://help.expensify.com/articles/new-expensify/concierge-ai/How-Concierge-Analyzes-Spend',
+        EXPENSE_ASSISTANT: 'https://help.expensify.com/articles/new-expensify/concierge-ai/Expense-Assistant',
+        BUILD_AGENTS: 'https://help.expensify.com/articles/new-expensify/ai-agents/Create-Agent-Rules',
+    },
+
     BASE_LIST_ITEM_TEST_ID: 'base-list-item-',
     SELECTION_BUTTON_TEST_ID: 'selection-button-',
     PRODUCT_TRAINING_TOOLTIP_NAMES: {
diff --git a/src/NAVIGATORS.ts b/src/NAVIGATORS.ts
index 7f24aedd382e..3a99812352d3 100644
--- a/src/NAVIGATORS.ts
+++ b/src/NAVIGATORS.ts
@@ -8,6 +8,7 @@ export default {
     ONBOARDING_MODAL_NAVIGATOR: 'OnboardingModalNavigator',
     FEATURE_TRAINING_MODAL_NAVIGATOR: 'FeatureTrainingModalNavigator',
     MIGRATED_USER_MODAL_NAVIGATOR: 'MigratedUserModalNavigator',
+    AI_FEATURES_PROMO_MODAL_NAVIGATOR: 'AIFeaturesPromoModalNavigator',
     TEST_DRIVE_MODAL_NAVIGATOR: 'TestDriveModalNavigator',
     TEST_DRIVE_DEMO_NAVIGATOR: 'TestDriveDemoNavigator',
     REPORTS_SPLIT_NAVIGATOR: 'ReportsSplitNavigator',
diff --git a/src/ROUTES.ts b/src/ROUTES.ts
index d9790039e6ab..8cc6bdcf979d 100644
--- a/src/ROUTES.ts
+++ b/src/ROUTES.ts
@@ -3375,6 +3375,7 @@ const ROUTES = {
 
         getRoute: (backTo?: string) => getUrlWithBackToParam('onboarding/migrated-user-welcome', backTo, false),
     },
+    AI_FEATURES_PROMO_MODAL: 'ai-features-promo',
 
     TRANSACTION_RECEIPT: {
         route: 'r/:reportID/transaction/:transactionID/receipt/:action?/:iouType?',
diff --git a/src/SCREENS.ts b/src/SCREENS.ts
index 5ff83221799a..e448e95c7b71 100644
--- a/src/SCREENS.ts
+++ b/src/SCREENS.ts
@@ -970,6 +970,10 @@ const SCREENS = {
         ROOT: 'MigratedUserWelcomeModal_Root',
     },
 
+    AI_FEATURES_PROMO_MODAL: {
+        ROOT: 'AIFeaturesPromoModal_Root',
+    },
+
     TEST_DRIVE_MODAL: {
         ROOT: 'TestDrive_Modal_Root',
     },
diff --git a/src/components/AIFeaturesPromoModal/index.tsx b/src/components/AIFeaturesPromoModal/index.tsx
new file mode 100644
index 000000000000..a161b7688029
--- /dev/null
+++ b/src/components/AIFeaturesPromoModal/index.tsx
@@ -0,0 +1,92 @@
+import React, {useRef} from 'react';
+import {View} from 'react-native';
+import Badge from '@components/Badge';
+import FeatureTrainingModal from '@components/FeatureTrainingModal';
+import type {FeatureTrainingModalPageProps} from '@components/FeatureTrainingModal';
+import LottieAnimations from '@components/LottieAnimations';
+import Text from '@components/Text';
+import useLocalize from '@hooks/useLocalize';
+import usePermissions from '@hooks/usePermissions';
+import useThemeStyles from '@hooks/useThemeStyles';
+import {dismissProductTraining} from '@libs/actions/Welcome';
+import Log from '@libs/Log';
+import variables from '@styles/variables';
+import CONST from '@src/CONST';
+
+function AIFeaturesPromoModal() {
+    const {translate} = useLocalize();
+    const styles = useThemeStyles();
+    const {isBetaEnabled} = usePermissions();
+    const canUseCustomAgent = isBetaEnabled(CONST.BETAS.CUSTOM_AGENT);
+
+    const customAgentPromoTitle = (
+        <View style={[styles.dFlex, styles.flexRow]}>
+            <Text style={[styles.textHeadlineH1, styles.mb2]}>{translate('aiFeaturesPromoModal.customAgents.title')}</Text>
+            <Badge
+                isStrong
+                isCondensed
+                text={translate('common.beta')}
+                badgeStyles={styles.mb2}
+            />
+        </View>
+    );
+
+    const pages: FeatureTrainingModalPageProps[] = [
+        {
+            animation: LottieAnimations.SpendAnalysis,
+            title: translate('aiFeaturesPromoModal.spendAnalysis.title'),
+            subtitle: translate('aiFeaturesPromoModal.subtitle'),
+            description: translate('aiFeaturesPromoModal.spendAnalysis.description'),
+            confirmText: translate('common.next'),
+        },
+        {
+            animation: LottieAnimations.ExpenseAssistant,
+            title: translate('aiFeaturesPromoModal.expenseAssistant.title'),
+            subtitle: translate('aiFeaturesPromoModal.subtitle'),
+            description: translate('aiFeaturesPromoModal.expenseAssistant.description'),
+            confirmText: canUseCustomAgent ? translate('common.next') : translate('aiFeaturesPromoModal.confirmText'),
+        },
+        ...(canUseCustomAgent
+            ? [
+                  {
+                      animation: LottieAnimations.CustomAgents,
+                      title: customAgentPromoTitle,
+                      subtitle: translate('aiFeaturesPromoModal.subtitle'),
+                      description: translate('aiFeaturesPromoModal.customAgents.description'),
+                      confirmText: translate('aiFeaturesPromoModal.confirmText'),
+                  },
+              ]
+            : []),
+    ];
+
+    const wasDismissedViaConfirmRef = useRef(false);
+
+    const onConfirm = () => {
+        Log.hmmm('[AIFeaturesPromoModal] onConfirm called, recording click dismissal');
+        wasDismissedViaConfirmRef.current = true;
+    };
+
+    const onClose = () => {
+        const isCloseButtonDismissal = !wasDismissedViaConfirmRef.current;
+        Log.hmmm(`[AIFeaturesPromoModal] onClose called, dismissing product training via ${isCloseButtonDismissal ? 'x' : 'click'}`);
+        dismissProductTraining(CONST.AI_FEATURES_PROMO_MODAL, isCloseButtonDismissal);
+    };
+
+    return (
+        <FeatureTrainingModal
+            pages={pages}
+            onConfirm={onConfirm}
+            onClose={onClose}
+            width={variables.aiFeaturesPromoModalWidth}
+            shouldRenderHTMLDescription
+            shouldUseScrollView
+            illustrationOuterContainerStyle={styles.p0}
+            illustrationAspectRatio={LottieAnimations.SpendAnalysis.w / LottieAnimations.SpendAnalysis.h}
+            contentInnerContainerStyles={styles.mb4}
+            modalInnerContainerStyle={styles.pt0}
+            titleStyles={styles.mb2}
+        />
+    );
+}
+
+export default AIFeaturesPromoModal;
diff --git a/src/components/FeatureTrainingModal/FeatureTrainingModalBody.tsx b/src/components/FeatureTrainingModal/FeatureTrainingModalBody.tsx
new file mode 100644
index 000000000000..8bbcc63c56d1
--- /dev/null
+++ b/src/components/FeatureTrainingModal/FeatureTrainingModalBody.tsx
@@ -0,0 +1,110 @@
+import React from 'react';
+import {View} from 'react-native';
+import useResponsiveLayout from '@hooks/useResponsiveLayout';
+import useStyleUtils from '@hooks/useStyleUtils';
+import FeatureTrainingModalContent from './FeatureTrainingModalContent';
+import FeatureTrainingModalIllustration from './FeatureTrainingModalIllustration';
+import type {BaseFeatureTrainingModalProps, FeatureTrainingModalPageProps} from './index';
+
+type FeatureTrainingModalBodyProps = BaseFeatureTrainingModalProps &
+    FeatureTrainingModalPageProps & {
+        /** Padding for the modal */
+        modalPadding: number;
+
+        /** Whether the modal should be shown again */
+        willShowAgain: boolean;
+
+        /** A callback to call when the modal should be shown again */
+        toggleWillShowAgain: () => void;
+
+        /** A callback to call when we want to close the modal */
+        closeModal: (didPressHelpButton?: boolean) => void;
+
+        /** A callback to call when we want to close the modal and confirm */
+        confirmModal: () => void;
+
+        /** Whether to show the back button to navigate back to the previous page in carousel mode */
+        shouldShowBackButton?: boolean;
+
+        /** A callback to call when we want to navigate back to the previous page in carousel mode */
+        onBack?: () => void;
+    };
+
+function FeatureTrainingModalBody({
+    illustrationInnerContainerStyle,
+    illustrationOuterContainerStyle,
+    illustrationAspectRatio: illustrationAspectRatioProp,
+    width,
+    title = '',
+    subtitle = '',
+    description = '',
+    secondaryDescription = '',
+    titleStyles,
+    shouldShowDismissModalOption = false,
+    confirmText = '',
+    helpText = '',
+    onHelp = () => {},
+    children,
+    contentInnerContainerStyles,
+    contentOuterContainerStyles,
+    shouldRenderSVG = true,
+    shouldRenderHTMLDescription = false,
+    shouldShowConfirmationLoader = false,
+    canConfirmWhileOffline = true,
+    shouldCallOnHelpWhenModalHidden = false,
+    helpSentryLabel,
+    confirmSentryLabel,
+    modalPadding,
+    willShowAgain = true,
+    toggleWillShowAgain,
+    closeModal,
+    confirmModal,
+    shouldShowBackButton = false,
+    onBack,
+    ...props
+}: FeatureTrainingModalBodyProps) {
+    const StyleUtils = useStyleUtils();
+    const {onboardingIsMediumOrLargerScreenWidth} = useResponsiveLayout();
+
+    return (
+        <View style={width && onboardingIsMediumOrLargerScreenWidth ? StyleUtils.getWidthStyle(width) : undefined}>
+            <FeatureTrainingModalIllustration
+                illustrationAspectRatio={illustrationAspectRatioProp}
+                illustrationInnerContainerStyle={illustrationInnerContainerStyle}
+                illustrationOuterContainerStyle={illustrationOuterContainerStyle}
+                shouldRenderSVG={shouldRenderSVG}
+                modalPadding={modalPadding}
+                {...props}
+            />
+            <FeatureTrainingModalContent
+                title={title}
+                subtitle={subtitle}
+                description={description}
+                secondaryDescription={secondaryDescription}
+                confirmText={confirmText}
+                helpText={helpText}
+                onHelp={onHelp}
+                shouldCallOnHelpWhenModalHidden={shouldCallOnHelpWhenModalHidden}
+                helpSentryLabel={helpSentryLabel}
+                confirmSentryLabel={confirmSentryLabel}
+                shouldShowDismissModalOption={shouldShowDismissModalOption}
+                willShowAgain={willShowAgain}
+                toggleWillShowAgain={toggleWillShowAgain}
+                closeModal={closeModal}
+                confirmModal={confirmModal}
+                shouldShowBackButton={shouldShowBackButton}
+                onBack={onBack}
+                shouldShowConfirmationLoader={shouldShowConfirmationLoader}
+                canConfirmWhileOffline={canConfirmWhileOffline}
+                titleStyles={titleStyles}
+                contentInnerContainerStyles={contentInnerContainerStyles}
+                contentOuterContainerStyles={contentOuterContainerStyles}
+                shouldRenderHTMLDescription={shouldRenderHTMLDescription}
+            >
+                {children}
+            </FeatureTrainingModalContent>
+        </View>
+    );
+}
+
+export default FeatureTrainingModalBody;
diff --git a/src/components/FeatureTrainingModal/FeatureTrainingModalCarouselBody.tsx b/src/components/FeatureTrainingModal/FeatureTrainingModalCarouselBody.tsx
new file mode 100644
index 000000000000..5f2d0c0b0e1e
--- /dev/null
+++ b/src/components/FeatureTrainingModal/FeatureTrainingModalCarouselBody.tsx
@@ -0,0 +1,302 @@
+import React, {useEffect, useRef, useState} from 'react';
+import {FlatList, View} from 'react-native';
+import type {LayoutChangeEvent, FlatList as RNFlatList, StyleProp, ViewabilityConfig, ViewStyle, ViewToken} from 'react-native';
+import Icon from '@components/Icon';
+import PressableWithFeedback from '@components/Pressable/PressableWithFeedback';
+import Tooltip from '@components/Tooltip';
+import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
+import useLocalize from '@hooks/useLocalize';
+import useResponsiveLayout from '@hooks/useResponsiveLayout';
+import useStyleUtils from '@hooks/useStyleUtils';
+import useTheme from '@hooks/useTheme';
+import useThemeStyles from '@hooks/useThemeStyles';
+import useWindowDimensions from '@hooks/useWindowDimensions';
+import isInLandscapeModeUtil from '@libs/isInLandscapeMode';
+import variables from '@styles/variables';
+import CONST from '@src/CONST';
+import FeatureTrainingModalContent from './FeatureTrainingModalContent';
+import FeatureTrainingModalIllustration from './FeatureTrainingModalIllustration';
+import FeatureTrainingModalTextContent from './FeatureTrainingModalTextContent';
+import type {BaseFeatureTrainingModalProps, FeatureTrainingModalCarouselProps, FeatureTrainingModalPageProps} from './index';
+
+// A page is considered "viewable" — and `currentPage` updates — only once it occupies at least
+// 95% of the viewport. The viewability event fires for both user swipes and programmatic
+// scrollToIndex once the scroll has practically settled on a new page.
+const CAROUSEL_VIEWABILITY_CONFIG: ViewabilityConfig = {itemVisiblePercentThreshold: 95};
+const CAROUSEL_DOT_SIZE = 6;
+const PAGINATION_DOTS_BOTTOM_OFFSET = 5;
+
+type FeatureTrainingModalCarouselBodyProps = Pick<
+    BaseFeatureTrainingModalProps,
+    | 'illustrationAspectRatio'
+    | 'illustrationInnerContainerStyle'
+    | 'illustrationOuterContainerStyle'
+    | 'titleStyles'
+    | 'shouldRenderSVG'
+    | 'shouldRenderHTMLDescription'
+    | 'shouldShowDismissModalOption'
+    | 'helpText'
+    | 'onHelp'
+    | 'shouldCallOnHelpWhenModalHidden'
+    | 'helpSentryLabel'
+    | 'confirmSentryLabel'
+    | 'shouldShowConfirmationLoader'
+    | 'canConfirmWhileOffline'
+    | 'contentInnerContainerStyles'
+    | 'contentOuterContainerStyles'
+    | 'width'
+> &
+    FeatureTrainingModalCarouselProps & {
+        /** Padding for the modal */
+        modalPadding: number;
+
+        /** Styles for the wrapper */
+        wrapperStyles?: StyleProp<ViewStyle>;
+
+        /** Whether the modal should be shown again */
+        willShowAgain: boolean;
+
+        /** Callback when the "Don't show me this again" option is toggled */
+        toggleWillShowAgain: () => void;
+
+        /** Callback to close the modal */
+        closeModal: (didPressHelpButton?: boolean) => void;
+
+        /** Callback fired when the user presses the confirm button on the LAST page */
+        onConfirm: () => void;
+
+        /** Called when the user swipes to a different page */
+        onPageChange?: (index: number) => void;
+    };
+
+function FeatureTrainingModalCarouselBody({
+    pages,
+    modalPadding,
+    width = variables.featureTrainingModalWidth,
+    titleStyles,
+    wrapperStyles,
+    illustrationAspectRatio,
+    illustrationInnerContainerStyle,
+    illustrationOuterContainerStyle,
+    shouldRenderSVG = true,
+    shouldRenderHTMLDescription = false,
+    shouldShowDismissModalOption = false,
+    helpText = '',
+    onHelp = () => {},
+    shouldCallOnHelpWhenModalHidden = false,
+    helpSentryLabel,
+    confirmSentryLabel,
+    shouldShowConfirmationLoader = false,
+    canConfirmWhileOffline = true,
+    contentInnerContainerStyles,
+    contentOuterContainerStyles,
+    willShowAgain,
+    toggleWillShowAgain,
+    closeModal,
+    onConfirm,
+    onPageChange,
+}: FeatureTrainingModalCarouselBodyProps) {
+    const styles = useThemeStyles();
+    const StyleUtils = useStyleUtils();
+    const theme = useTheme();
+    const {translate} = useLocalize();
+    const expensifyIcons = useMemoizedLazyExpensifyIcons(['Close']);
+    const {onboardingIsMediumOrLargerScreenWidth} = useResponsiveLayout();
+    const {windowHeight, windowWidth} = useWindowDimensions();
+    const isInLandscapeMode = isInLandscapeModeUtil(windowWidth, windowHeight);
+
+    const [currentPage, setCurrentPage] = useState(0);
+    const [carouselViewportWidth, setCarouselViewportWidth] = useState(0);
+    const horizontalListRef = useRef<RNFlatList<FeatureTrainingModalPageProps>>(null);
+    const lastReportedPage = useRef(0);
+
+    const [contentMinHeight, setContentMinHeight] = useState<number | undefined>(undefined);
+    const measuredHeightsRef = useRef<Record<number, number>>({});
+    const handleProbeLayout = (index: number) => (event: LayoutChangeEvent) => {
+        const measured = event.nativeEvent.layout.height;
+        if (measuredHeightsRef.current[index] === measured) {
+            return;
+        }
+        measuredHeightsRef.current[index] = measured;
+        if (Object.keys(measuredHeightsRef.current).length < pages.length) {
+            return;
+        }
+        setContentMinHeight(Math.max(...Object.values(measuredHeightsRef.current)));
+    };
+
+    // FlatList's `onViewableItemsChanged` must keep a stable identity (it errors otherwise).
+    // The handler reads the latest `onPageChange` via a ref so the callback identity never changes.
+    const onPageChangeRef = useRef(onPageChange);
+    useEffect(() => {
+        onPageChangeRef.current = onPageChange;
+    }, [onPageChange]);
+
+    const onViewableItemsChanged = ({viewableItems}: {viewableItems: ViewToken[]}) => {
+        const entry = viewableItems.at(0);
+        if (entry?.index == null || entry.index === lastReportedPage.current) {
+            return;
+        }
+        lastReportedPage.current = entry.index;
+        setCurrentPage(entry.index);
+        onPageChangeRef.current?.(entry.index);
+    };
+
+    const advanceCarousel = () => {
+        horizontalListRef.current?.scrollToIndex({index: Math.min(currentPage + 1, pages.length - 1), animated: true});
+    };
+
+    const goBack = () => {
+        if (currentPage <= 0) {
+            return;
+        }
+        horizontalListRef.current?.scrollToIndex({index: Math.max(currentPage - 1, 0), animated: true});
+    };
+
+    const handleConfirmPress = () => {
+        if (currentPage < pages.length - 1) {
+            advanceCarousel();
+            return;
+        }
+        onConfirm();
+    };
+
+    const carouselPaginationDots = pages.map((_page, index) => (
+        <View
+            // The array is static for the modal's lifetime, so the index is a stable key.
+            // eslint-disable-next-line react/no-array-index-key
+            key={`carousel-dot-${index}`}
+            style={StyleUtils.getFeatureTrainingCarouselDotStyle(CAROUSEL_DOT_SIZE, theme.buttonSuccessText, index === currentPage)}
+        />
+    ));
+
+    const currentPageData = pages.at(currentPage);
+
+    return (
+        <View
+            style={[
+                onboardingIsMediumOrLargerScreenWidth && StyleUtils.getWidthStyle(width),
+                wrapperStyles,
+                isInLandscapeMode ? {maxHeight: windowHeight * CONST.MODAL_MAX_HEIGHT_TO_WINDOW_HEIGHT_RATIO_LANDSCAPE_MODE} : styles.mh100,
+            ]}
+            onLayout={(e: LayoutChangeEvent) => {
+                const newWidth = e.nativeEvent.layout.width;
+                if (newWidth === carouselViewportWidth || newWidth <= 0) {
+                    return;
+                }
+                setCarouselViewportWidth(newWidth);
+            }}
+        >
+            {carouselViewportWidth > 0 && contentMinHeight === undefined && (
+                // Probe layer is used to measure the tallest page to lock the modal height
+                // when moving between pages with different content lengths.
+                <View
+                    pointerEvents="none"
+                    accessibilityElementsHidden
+                    importantForAccessibility="no-hide-descendants"
+                    style={[styles.pAbsolute, styles.l0, styles.t0, {width: carouselViewportWidth, opacity: 0}]}
+                >
+                    {pages.map((page, index) => (
+                        <View
+                            // The pages array is static for the modal's lifetime, so the index is a stable key.
+                            // eslint-disable-next-line react/no-array-index-key
+                            key={`FeatureTrainingModalCarousel-probe-${index}`}
+                            style={styles.mh5}
+                        >
+                            <FeatureTrainingModalTextContent
+                                title={page.title}
+                                subtitle={page.subtitle}
+                                description={page.description}
+                                secondaryDescription={page.secondaryDescription}
+                                titleStyles={titleStyles}
+                                contentInnerContainerStyles={contentInnerContainerStyles}
+                                shouldRenderHTMLDescription={shouldRenderHTMLDescription}
+                                onLayout={handleProbeLayout(index)}
+                            />
+                        </View>
+                    ))}
+                </View>
+            )}
+            {carouselViewportWidth > 0 && (
+                <>
+                    <View>
+                        <FlatList
+                            ref={horizontalListRef}
+                            data={pages}
+                            keyExtractor={(_page, index) => `FeatureTrainingModalIllustration-${index}`}
+                            horizontal
+                            pagingEnabled
+                            showsHorizontalScrollIndicator={false}
+                            keyboardShouldPersistTaps="handled"
+                            viewabilityConfig={CAROUSEL_VIEWABILITY_CONFIG}
+                            onViewableItemsChanged={onViewableItemsChanged}
+                            getItemLayout={(_data, index) => ({length: carouselViewportWidth, offset: index * carouselViewportWidth, index})}
+                            renderItem={({item: page, index}) => (
+                                <View style={{width: carouselViewportWidth}}>
+                                    <FeatureTrainingModalIllustration
+                                        illustrationAspectRatio={illustrationAspectRatio}
+                                        illustrationInnerContainerStyle={illustrationInnerContainerStyle}
+                                        illustrationOuterContainerStyle={illustrationOuterContainerStyle}
+                                        shouldRenderSVG={shouldRenderSVG}
+                                        modalPadding={modalPadding}
+                                        isCarousel
+                                        isFocused={index === currentPage}
+                                        {...page}
+                                    />
+                                </View>
+                            )}
+                        />
+                        <View
+                            pointerEvents="none"
+                            style={[styles.pAbsolute, styles.flexRow, styles.justifyContentCenter, styles.w100, styles.l0, styles.r0, {bottom: PAGINATION_DOTS_BOTTOM_OFFSET + modalPadding}]}
+                        >
+                            {carouselPaginationDots}
+                        </View>
+                    </View>
+                    <FeatureTrainingModalContent
+                        title={currentPageData?.title}
+                        subtitle={currentPageData?.subtitle}
+                        description={currentPageData?.description}
+                        secondaryDescription={currentPageData?.secondaryDescription}
+                        confirmText={currentPageData?.confirmText ?? ''}
+                        helpText={helpText}
+                        onHelp={onHelp}
+                        shouldCallOnHelpWhenModalHidden={shouldCallOnHelpWhenModalHidden}
+                        helpSentryLabel={helpSentryLabel}
+                        confirmSentryLabel={confirmSentryLabel}
+                        shouldShowDismissModalOption={shouldShowDismissModalOption}
+                        willShowAgain={willShowAgain}
+                        toggleWillShowAgain={toggleWillShowAgain}
+                        closeModal={closeModal}
+                        confirmModal={handleConfirmPress}
+                        shouldShowBackButton={currentPage > 0}
+                        onBack={goBack}
+                        shouldShowConfirmationLoader={shouldShowConfirmationLoader}
+                        canConfirmWhileOffline={canConfirmWhileOffline}
+                        titleStyles={titleStyles}
+                        contentInnerContainerStyles={[contentInnerContainerStyles, contentMinHeight !== undefined && {minHeight: contentMinHeight}]}
+                        contentOuterContainerStyles={contentOuterContainerStyles}
+                        shouldRenderHTMLDescription={shouldRenderHTMLDescription}
+                    />
+                    <View style={[styles.pAbsolute, {top: modalPadding, right: modalPadding, zIndex: 1}]}>
+                        <Tooltip text={translate('common.close')}>
+                            <PressableWithFeedback
+                                onPress={() => closeModal()}
+                                role={CONST.ROLE.BUTTON}
+                                accessibilityLabel={translate('common.close')}
+                                sentryLabel="FeatureTrainingModal-Carousel-Close"
+                                style={[styles.p2, styles.opacitySemiTransparent]}
+                            >
+                                <Icon
+                                    src={expensifyIcons.Close}
+                                    fill={theme.buttonSuccessText}
+                                />
+                            </PressableWithFeedback>
+                        </Tooltip>
+                    </View>
+                </>
+            )}
+        </View>
+    );
+}
+
+export default FeatureTrainingModalCarouselBody;
diff --git a/src/components/FeatureTrainingModal/FeatureTrainingModalContent.tsx b/src/components/FeatureTrainingModal/FeatureTrainingModalContent.tsx
new file mode 100644
index 000000000000..57d4f258a4b4
--- /dev/null
+++ b/src/components/FeatureTrainingModal/FeatureTrainingModalContent.tsx
@@ -0,0 +1,138 @@
+import React from 'react';
+import {View} from 'react-native';
+import Button from '@components/Button';
+import CheckboxWithLabel from '@components/CheckboxWithLabel';
+import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton';
+import OfflineIndicator from '@components/OfflineIndicator';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import FeatureTrainingModalTextContent from './FeatureTrainingModalTextContent';
+import type {FeatureTrainingModalContentProps as BaseFeatureTrainingModalContentProps, BaseFeatureTrainingModalProps} from './index';
+
+type FeatureTrainingModalContentProps = Pick<
+    BaseFeatureTrainingModalProps,
+    | 'helpText'
+    | 'onHelp'
+    | 'shouldCallOnHelpWhenModalHidden'
+    | 'helpSentryLabel'
+    | 'confirmSentryLabel'
+    | 'shouldShowDismissModalOption'
+    | 'shouldShowConfirmationLoader'
+    | 'canConfirmWhileOffline'
+    | 'titleStyles'
+    | 'contentInnerContainerStyles'
+    | 'contentOuterContainerStyles'
+    | 'shouldRenderHTMLDescription'
+    | 'children'
+> &
+    BaseFeatureTrainingModalContentProps & {
+        /** Whether the modal should be shown again (drives the dismiss checkbox state) */
+        willShowAgain: boolean;
+
+        /** Callback when the "Don't show me this again" option is toggled */
+        toggleWillShowAgain: () => void;
+
+        /** Callback to close the modal */
+        closeModal: (didPressHelpButton?: boolean) => void;
+
+        /** Callback when the user presses the confirm button */
+        confirmModal: () => void;
+
+        /** Whether to render a Back button (carousel mode, non-first pages) */
+        shouldShowBackButton?: boolean;
+
+        /** Callback when the Back button is pressed */
+        onBack?: () => void;
+    };
+
+function FeatureTrainingModalContent({
+    title = '',
+    subtitle = '',
+    description = '',
+    secondaryDescription = '',
+    confirmText,
+    helpText = '',
+    onHelp = () => {},
+    shouldCallOnHelpWhenModalHidden = false,
+    helpSentryLabel,
+    confirmSentryLabel,
+    shouldShowDismissModalOption = false,
+    willShowAgain,
+    toggleWillShowAgain,
+    closeModal,
+    confirmModal,
+    shouldShowBackButton = false,
+    onBack,
+    shouldShowConfirmationLoader = false,
+    canConfirmWhileOffline = true,
+    titleStyles,
+    contentInnerContainerStyles,
+    contentOuterContainerStyles,
+    shouldRenderHTMLDescription = false,
+    children,
+}: FeatureTrainingModalContentProps) {
+    const styles = useThemeStyles();
+    const {translate} = useLocalize();
+
+    return (
+        <View style={[styles.mt5, styles.mh5, contentOuterContainerStyles]}>
+            <FeatureTrainingModalTextContent
+                title={title}
+                subtitle={subtitle}
+                description={description}
+                secondaryDescription={secondaryDescription}
+                titleStyles={titleStyles}
+                contentInnerContainerStyles={contentInnerContainerStyles}
+                shouldRenderHTMLDescription={shouldRenderHTMLDescription}
+            >
+                {children}
+            </FeatureTrainingModalTextContent>
+            {shouldShowDismissModalOption && (
+                <CheckboxWithLabel
+                    label={translate('featureTraining.doNotShowAgain')}
+                    accessibilityLabel={translate('featureTraining.doNotShowAgain')}
+                    style={[styles.mb5]}
+                    isChecked={!willShowAgain}
+                    onInputChange={toggleWillShowAgain}
+                />
+            )}
+            {!!helpText && (
+                <Button
+                    large
+                    style={[styles.mb3]}
+                    onPress={() => {
+                        if (shouldCallOnHelpWhenModalHidden) {
+                            closeModal(true);
+                            return;
+                        }
+                        onHelp();
+                    }}
+                    text={helpText}
+                    sentryLabel={helpSentryLabel}
+                />
+            )}
+            <View style={styles.featureTrainingModalNavButtons}>
+                {shouldShowBackButton && (
+                    <Button
+                        large
+                        onPress={onBack}
+                        text={translate('common.back')}
+                        sentryLabel={confirmSentryLabel}
+                        style={styles.flex1}
+                    />
+                )}
+                <FormAlertWithSubmitButton
+                    onSubmit={confirmModal}
+                    isLoading={shouldShowConfirmationLoader}
+                    buttonText={confirmText}
+                    enabledWhenOffline={canConfirmWhileOffline}
+                    sentryLabel={confirmSentryLabel}
+                    containerStyles={styles.flex1}
+                />
+            </View>
+            {!canConfirmWhileOffline && <OfflineIndicator />}
+        </View>
+    );
+}
+
+export default FeatureTrainingModalContent;
diff --git a/src/components/FeatureTrainingModal/FeatureTrainingModalIllustration.tsx b/src/components/FeatureTrainingModal/FeatureTrainingModalIllustration.tsx
new file mode 100644
index 000000000000..52aa025ea005
--- /dev/null
+++ b/src/components/FeatureTrainingModal/FeatureTrainingModalIllustration.tsx
@@ -0,0 +1,179 @@
+import type {SourceLoadEventPayload} from 'expo-video';
+import type LottieView from 'lottie-react-native';
+import React, {useEffect, useRef, useState} from 'react';
+import {Image, View} from 'react-native';
+import type {ImageResizeMode, ImageSourcePropType} from 'react-native';
+import {GestureHandlerRootView} from 'react-native-gesture-handler';
+import ImageSVG from '@components/ImageSVG';
+import Lottie from '@components/Lottie';
+import LottieAnimations from '@components/LottieAnimations';
+import VideoPlayer from '@components/VideoPlayer';
+import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset';
+import useNetwork from '@hooks/useNetwork';
+import useResponsiveLayout from '@hooks/useResponsiveLayout';
+import useThemeStyles from '@hooks/useThemeStyles';
+import useWindowDimensions from '@hooks/useWindowDimensions';
+import Accessibility from '@libs/Accessibility';
+import isInLandscapeModeUtil from '@libs/isInLandscapeMode';
+import CONST from '@src/CONST';
+import type {FeatureTrainingModalIllustrationProps as BaseFeatureTrainingModalIllustrationProps, BaseFeatureTrainingModalProps} from './index';
+
+// Aspect ratio and height of the video.
+// Useful before video loads to reserve space.
+const VIDEO_ASPECT_RATIO = 1280 / 960;
+
+const LANDSCAPE_ILLUSTRATION_MAX_HEIGHT_TO_WINDOW_HEIGHT_RATIO = 0.7;
+
+type VideoStatus = 'video' | 'animation';
+
+type FeatureTrainingModalIllustrationProps = Pick<
+    BaseFeatureTrainingModalProps,
+    'shouldRenderSVG' | 'illustrationAspectRatio' | 'illustrationInnerContainerStyle' | 'illustrationOuterContainerStyle'
+> &
+    BaseFeatureTrainingModalIllustrationProps & {
+        /** Padding for the modal */
+        modalPadding: number;
+
+        /** Whether this illustration belongs to the currently-visible carousel page */
+        isFocused?: boolean;
+
+        /** Whether this illustration is part of a carousel */
+        isCarousel?: boolean;
+    };
+
+function FeatureTrainingModalIllustration({
+    animation,
+    animationStyle,
+    videoURL,
+    image,
+    contentFitImage,
+    imageWidth,
+    imageHeight,
+    illustrationAspectRatio: illustrationAspectRatioProp,
+    illustrationInnerContainerStyle,
+    illustrationOuterContainerStyle,
+    shouldRenderSVG = true,
+    modalPadding,
+    isFocused = true,
+    isCarousel = false,
+}: FeatureTrainingModalIllustrationProps) {
+    const styles = useThemeStyles();
+    const isReduceMotionEnabled = Accessibility.useReducedMotion();
+    const illustrations = useMemoizedLazyIllustrations(['Hands']);
+    const {onboardingIsMediumOrLargerScreenWidth, shouldUseNarrowLayout} = useResponsiveLayout();
+    const {windowHeight, windowWidth} = useWindowDimensions();
+    const [illustrationAspectRatio, setIllustrationAspectRatio] = useState(illustrationAspectRatioProp ?? VIDEO_ASPECT_RATIO);
+    const {isOffline} = useNetwork();
+    const isInLandscapeMode = isInLandscapeModeUtil(windowWidth, windowHeight);
+
+    const animationRef = useRef<LottieView | null>(null);
+    useEffect(() => {
+        if (!isCarousel || !animationRef.current || isReduceMotionEnabled) {
+            return;
+        }
+        if (isFocused) {
+            animationRef.current.play(0);
+        } else {
+            animationRef.current.reset();
+        }
+    }, [isFocused, isCarousel, isReduceMotionEnabled]);
+
+    const [videoStatus, setVideoStatus] = useState<VideoStatus>('video');
+    const [isVideoStatusLocked, setIsVideoStatusLocked] = useState(false);
+
+    useEffect(() => {
+        if (isVideoStatusLocked) {
+            return;
+        }
+
+        if (isOffline) {
+            setVideoStatus('animation');
+        } else if (!isOffline) {
+            setVideoStatus('video');
+            setIsVideoStatusLocked(true);
+        }
+    }, [isOffline, isVideoStatusLocked]);
+
+    const setAspectRatio = (event: SourceLoadEventPayload) => {
+        const track = event.availableVideoTracks.at(0);
+        if (!track) {
+            return;
+        }
+        setIllustrationAspectRatio(track.size.width / track.size.height);
+    };
+
+    const aspectRatio = illustrationAspectRatio || VIDEO_ASPECT_RATIO;
+
+    return (
+        <View
+            style={[
+                onboardingIsMediumOrLargerScreenWidth ? {padding: modalPadding} : {paddingHorizontal: modalPadding},
+                illustrationOuterContainerStyle,
+                isInLandscapeMode ? [{maxHeight: windowHeight * LANDSCAPE_ILLUSTRATION_MAX_HEIGHT_TO_WINDOW_HEIGHT_RATIO}, styles.alignSelfCenter] : undefined,
+            ]}
+        >
+            <View
+                style={[
+                    isInLandscapeMode ? styles.h100 : styles.w100,
+                    // Prevent layout jumps by reserving height for the video until it loads.
+                    // When videoStatus === 'animation' it preserves the same aspect ratio.
+                    illustrationInnerContainerStyle,
+                    (!!videoURL || !!image || !!animation) && {aspectRatio},
+                ]}
+            >
+                {!!image &&
+                    (shouldRenderSVG ? (
+                        <ImageSVG
+                            src={image}
+                            contentFit={contentFitImage}
+                            width={imageWidth}
+                            height={imageHeight}
+                            testID={CONST.IMAGE_SVG_TEST_ID}
+                        />
+                    ) : (
+                        <Image
+                            accessibilityIgnoresInvertColors
+                            source={image as ImageSourcePropType}
+                            resizeMode={contentFitImage as ImageResizeMode}
+                            style={styles.featureTrainingModalImage}
+                            testID={CONST.IMAGE_TEST_ID}
+                        />
+                    ))}
+                {!!videoURL && videoStatus === 'video' && (
+                    <GestureHandlerRootView>
+                        <VideoPlayer
+                            url={videoURL}
+                            videoPlayerStyle={[styles.onboardingVideoPlayer, {aspectRatio}]}
+                            onSourceLoaded={setAspectRatio}
+                            controlsStatus={CONST.VIDEO_PLAYER.CONTROLS_STATUS.HIDE}
+                            shouldUseControlsBottomMargin={false}
+                            shouldPlay
+                            isLooping
+                        />
+                    </GestureHandlerRootView>
+                )}
+                {((!videoURL && !image) || (!!videoURL && videoStatus === 'animation')) && (
+                    <View style={[styles.flex1, styles.alignItemsCenter, styles.justifyContentCenter, !!videoURL && {aspectRatio}, animationStyle]}>
+                        {isReduceMotionEnabled && (animation ?? LottieAnimations.Hands) === LottieAnimations.Hands ? (
+                            <ImageSVG
+                                src={illustrations.Hands}
+                                style={styles.h100}
+                            />
+                        ) : (
+                            <Lottie
+                                ref={animationRef}
+                                source={animation ?? LottieAnimations.Hands}
+                                style={styles.h100}
+                                webStyle={shouldUseNarrowLayout ? styles.h100 : undefined}
+                                autoPlay={isFocused}
+                                loop
+                            />
+                        )}
+                    </View>
+                )}
+            </View>
+        </View>
+    );
+}
+
+export default FeatureTrainingModalIllustration;
diff --git a/src/components/FeatureTrainingModal/FeatureTrainingModalTextContent.tsx b/src/components/FeatureTrainingModal/FeatureTrainingModalTextContent.tsx
new file mode 100644
index 000000000000..512e3e3800d3
--- /dev/null
+++ b/src/components/FeatureTrainingModal/FeatureTrainingModalTextContent.tsx
@@ -0,0 +1,76 @@
+import React from 'react';
+import {View} from 'react-native';
+import type {LayoutChangeEvent, StyleProp, TextStyle, ViewStyle} from 'react-native';
+import RenderHTML from '@components/RenderHTML';
+import Text from '@components/Text';
+import useResponsiveLayout from '@hooks/useResponsiveLayout';
+import useThemeStyles from '@hooks/useThemeStyles';
+
+type FeatureTrainingModalTextContentProps = {
+    /** Title for the modal */
+    title?: string | React.ReactNode;
+
+    /** Subtitle for the modal */
+    subtitle?: string;
+
+    /** Describe what is showing */
+    description?: string;
+
+    /** Secondary description rendered with additional space */
+    secondaryDescription?: string;
+
+    /** Style for the title */
+    titleStyles?: StyleProp<TextStyle>;
+
+    /** Styles applied to the inner text container */
+    contentInnerContainerStyles?: StyleProp<ViewStyle>;
+
+    /** Whether description is HTML markup */
+    shouldRenderHTMLDescription?: boolean;
+
+    /** Children rendered below the description (single-page mode only) */
+    children?: React.ReactNode;
+
+    /** onLayout hook — used by the carousel probe to measure the tallest page */
+    onLayout?: (event: LayoutChangeEvent) => void;
+};
+
+function FeatureTrainingModalTextContent({
+    title = '',
+    subtitle = '',
+    description = '',
+    secondaryDescription = '',
+    titleStyles,
+    contentInnerContainerStyles,
+    shouldRenderHTMLDescription = false,
+    children,
+    onLayout,
+}: FeatureTrainingModalTextContentProps) {
+    const styles = useThemeStyles();
+    const {onboardingIsMediumOrLargerScreenWidth} = useResponsiveLayout();
+
+    if (!title || !description) {
+        return null;
+    }
+
+    return (
+        <View
+            style={[onboardingIsMediumOrLargerScreenWidth ? [styles.gap1, styles.mb8] : [shouldRenderHTMLDescription ? styles.mb5 : styles.mb10], contentInnerContainerStyles]}
+            onLayout={onLayout}
+        >
+            {!!subtitle && <Text style={[styles.textLabel, styles.textBold, styles.textSuccess]}>{subtitle}</Text>}
+            {typeof title === 'string' ? <Text style={[styles.textHeadlineH1, titleStyles]}>{title}</Text> : title}
+            {shouldRenderHTMLDescription ? (
+                <View style={[styles.flexRow, styles.w100, styles.mb2, styles.renderHTML]}>
+                    <RenderHTML html={description} />
+                </View>
+            ) : (
+                <Text style={styles.textSupporting}>{description}</Text>
+            )}
+            {!!secondaryDescription && <Text style={[styles.textSupporting, styles.mt4]}>{secondaryDescription}</Text>}
+            {children}
+        </View>
+    );
+}
+
+export default FeatureTrainingModalTextContent;
diff --git a/src/components/FeatureTrainingModal.tsx b/src/components/FeatureTrainingModal/index.tsx
similarity index 51%
rename from src/components/FeatureTrainingModal.tsx
rename to src/components/FeatureTrainingModal/index.tsx
index 1e96566a5841..6529182a1619 100644
--- a/src/components/FeatureTrainingModal.tsx
+++ b/src/components/FeatureTrainingModal/index.tsx
@@ -1,21 +1,19 @@
 import type {ImageContentFit} from 'expo-image';
-import type {SourceLoadEventPayload} from 'expo-video';
 import React, {useEffect, useRef, useState} from 'react';
-import {Image, View} from 'react-native';
+import {View} from 'react-native';
 // eslint-disable-next-line no-restricted-imports
-import type {ImageResizeMode, ImageSourcePropType, LayoutChangeEvent, ScrollView as RNScrollView, StyleProp, TextStyle, ViewStyle} from 'react-native';
-import {GestureHandlerRootView} from 'react-native-gesture-handler';
+import type {LayoutChangeEvent, ScrollView as RNScrollView, StyleProp, TextStyle, ViewStyle} from 'react-native';
 import type {MergeExclusive} from 'type-fest';
+import type ImageSVGProps from '@components/ImageSVG/types';
+import type DotLottieAnimation from '@components/LottieAnimations/types';
+import Modal from '@components/Modal';
+import ScrollView from '@components/ScrollView';
 import useKeyboardState from '@hooks/useKeyboardState';
-import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset';
-import useLocalize from '@hooks/useLocalize';
-import useNetwork from '@hooks/useNetwork';
 import useResponsiveLayout from '@hooks/useResponsiveLayout';
 import useSafeAreaInsets from '@hooks/useSafeAreaInsets';
 import useStyleUtils from '@hooks/useStyleUtils';
 import useThemeStyles from '@hooks/useThemeStyles';
 import useWindowDimensions from '@hooks/useWindowDimensions';
-import Accessibility from '@libs/Accessibility';
 import isInLandscapeModeUtil from '@libs/isInLandscapeMode';
 import Log from '@libs/Log';
 import Navigation from '@libs/Navigation/Navigation';
@@ -25,29 +23,11 @@ import {setNameValuePair} from '@userActions/User';
 import CONST from '@src/CONST';
 import ONYXKEYS from '@src/ONYXKEYS';
 import type IconAsset from '@src/types/utils/IconAsset';
-import Button from './Button';
-import CheckboxWithLabel from './CheckboxWithLabel';
-import FormAlertWithSubmitButton from './FormAlertWithSubmitButton';
-import ImageSVG from './ImageSVG';
-import type ImageSVGProps from './ImageSVG/types';
-import Lottie from './Lottie';
-import LottieAnimations from './LottieAnimations';
-import type DotLottieAnimation from './LottieAnimations/types';
-import Modal from './Modal';
-import OfflineIndicator from './OfflineIndicator';
-import RenderHTML from './RenderHTML';
-import ScrollView from './ScrollView';
-import Text from './Text';
-import VideoPlayer from './VideoPlayer';
-
-// Aspect ratio and height of the video.
-// Useful before video loads to reserve space.
-const VIDEO_ASPECT_RATIO = 1280 / 960;
+import FeatureTrainingModalBody from './FeatureTrainingModalBody';
+import FeatureTrainingModalCarouselBody from './FeatureTrainingModalCarouselBody';
 
 const MODAL_PADDING = variables.spacing2;
 
-type VideoStatus = 'video' | 'animation';
-
 type BaseFeatureTrainingModalProps = {
     /** The aspect ratio to preserve for the icon, video or animation */
     illustrationAspectRatio?: number;
@@ -58,24 +38,12 @@ type BaseFeatureTrainingModalProps = {
     /** Style for the outer container of the animation */
     illustrationOuterContainerStyle?: StyleProp<ViewStyle>;
 
-    /** Title for the modal */
-    title?: string | React.ReactNode;
-
-    /** Describe what is showing */
-    description?: string;
-
-    /** Secondary description rendered with additional space */
-    secondaryDescription?: string;
-
     /** Style for the title */
     titleStyles?: StyleProp<TextStyle>;
 
     /** Whether to show `Don't show me this again` option */
     shouldShowDismissModalOption?: boolean;
 
-    /** Text to show on primary button */
-    confirmText: string;
-
     /** A callback to call when user confirms the tutorial */
     onConfirm?: (willShowAgain: boolean) => void;
 
@@ -97,7 +65,7 @@ type BaseFeatureTrainingModalProps = {
     /** Styles for the modal inner container */
     modalInnerContainerStyle?: ViewStyle;
 
-    /** Children to show below title and description and above buttons */
+    /** Children to show below title and description and above buttons (single-page mode only) */
     children?: React.ReactNode;
 
     /** Modal width */
@@ -140,6 +108,23 @@ type BaseFeatureTrainingModalProps = {
     confirmSentryLabel?: string;
 };
 
+type FeatureTrainingModalContentProps = {
+    /** Title for the modal */
+    title?: string | React.ReactNode;
+
+    /** Subtitle for the modal */
+    subtitle?: string;
+
+    /** Describe what is showing */
+    description?: string;
+
+    /** Secondary description rendered with additional space */
+    secondaryDescription?: string;
+
+    /** Text to show on primary button */
+    confirmText: string;
+};
+
 type FeatureTrainingModalVideoProps = {
     /** Animation to show when video is unavailable. Useful when app is offline */
     animation?: DotLottieAnimation;
@@ -165,22 +150,32 @@ type FeatureTrainingModalSVGProps = {
     imageHeight?: ImageSVGProps['height'];
 };
 
-// This page requires either an icon or a video/animation, but not both
-type FeatureTrainingModalProps = BaseFeatureTrainingModalProps & MergeExclusive<FeatureTrainingModalVideoProps, FeatureTrainingModalSVGProps>;
+// This page requires either an icon or a video/animation, but not both.
+type FeatureTrainingModalIllustrationProps = MergeExclusive<FeatureTrainingModalVideoProps, FeatureTrainingModalSVGProps>;
+
+type FeatureTrainingModalPageProps = FeatureTrainingModalIllustrationProps & FeatureTrainingModalContentProps;
+
+type FeatureTrainingModalCarouselProps = {
+    /**
+     * When provided (and length > 1), the modal renders a horizontal paging carousel.
+     * The primary button advances to the next page until the last page, where it fires `onConfirm`.
+     */
+    pages: FeatureTrainingModalPageProps[];
+
+    /** Called when the user swipes to a different page. */
+    onPageChange?: (index: number) => void;
+};
 
-const LANDSCAPE_ILLUSTRATION_MAX_HEIGHT_TO_WINDOW_HEIGHT_RATIO = 0.7;
+// Either single-page content fields OR carousel pages, but not both.
+type FeatureTrainingModalProps = BaseFeatureTrainingModalProps & MergeExclusive<FeatureTrainingModalPageProps, FeatureTrainingModalCarouselProps>;
 
 function FeatureTrainingModal({
-    animation,
-    animationStyle,
     illustrationInnerContainerStyle,
     illustrationOuterContainerStyle,
-    videoURL,
     illustrationAspectRatio: illustrationAspectRatioProp,
-    image,
-    contentFitImage,
     width = variables.featureTrainingModalWidth,
     title = '',
+    subtitle = '',
     description = '',
     secondaryDescription = '',
     titleStyles,
@@ -194,8 +189,6 @@ function FeatureTrainingModal({
     contentInnerContainerStyles,
     contentOuterContainerStyles,
     modalInnerContainerStyle,
-    imageWidth,
-    imageHeight,
     isModalDisabled = true,
     shouldRenderSVG = true,
     shouldRenderHTMLDescription = false,
@@ -208,21 +201,16 @@ function FeatureTrainingModal({
     shouldCallOnHelpWhenModalHidden = false,
     helpSentryLabel,
     confirmSentryLabel,
+    pages,
+    onPageChange,
+    ...props
 }: FeatureTrainingModalProps) {
     const styles = useThemeStyles();
     const StyleUtils = useStyleUtils();
-    const {translate} = useLocalize();
-    const isReduceMotionEnabled = Accessibility.useReducedMotion();
-    const illustrations = useMemoizedLazyIllustrations(['Hands']);
     const {onboardingIsMediumOrLargerScreenWidth} = useResponsiveLayout();
     const {windowHeight, windowWidth} = useWindowDimensions();
     const [isModalVisible, setIsModalVisible] = useState(false);
     const [willShowAgain, setWillShowAgain] = useState(true);
-    const [videoStatus, setVideoStatus] = useState<VideoStatus>('video');
-    const [isVideoStatusLocked, setIsVideoStatusLocked] = useState(false);
-    const [illustrationAspectRatio, setIllustrationAspectRatio] = useState(illustrationAspectRatioProp ?? VIDEO_ASPECT_RATIO);
-    const {shouldUseNarrowLayout} = useResponsiveLayout();
-    const {isOffline} = useNetwork();
     const hasHelpButtonBeenPressed = useRef(false);
     const pendingCloseRef = useRef(false);
     const scrollViewRef = useRef<RNScrollView>(null);
@@ -232,6 +220,8 @@ function FeatureTrainingModal({
     const {isKeyboardActive} = useKeyboardState();
     const isInLandscapeMode = isInLandscapeModeUtil(windowWidth, windowHeight);
 
+    const isCarousel = !!pages && pages.length > 1;
+
     const shouldUseScrollView = shouldUseScrollViewProp || isInLandscapeMode;
 
     useEffect(() => {
@@ -249,97 +239,6 @@ function FeatureTrainingModal({
         return () => handle.cancel();
     }, [isModalDisabled]);
 
-    useEffect(() => {
-        if (isVideoStatusLocked) {
-            return;
-        }
-
-        if (isOffline) {
-            setVideoStatus('animation');
-        } else if (!isOffline) {
-            setVideoStatus('video');
-            setIsVideoStatusLocked(true);
-        }
-    }, [isOffline, isVideoStatusLocked]);
-
-    const setAspectRatio = (event: SourceLoadEventPayload) => {
-        const track = event.availableVideoTracks.at(0);
-
-        if (!track) {
-            return;
-        }
-
-        setIllustrationAspectRatio(track.size.width / track.size.height);
-    };
-
-    const renderIllustration = () => {
-        const aspectRatio = illustrationAspectRatio || VIDEO_ASPECT_RATIO;
-
-        return (
-            <View
-                style={[
-                    isInLandscapeMode ? styles.h100 : styles.w100,
-                    // Prevent layout jumps by reserving height
-                    // for the video until it loads. Also, when
-                    // videoStatus === 'animation' it will
-                    // set the same aspect ratio as the video would.
-                    illustrationInnerContainerStyle,
-                    (!!videoURL || !!image) && {aspectRatio},
-                ]}
-            >
-                {!!image &&
-                    (shouldRenderSVG ? (
-                        <ImageSVG
-                            src={image}
-                            contentFit={contentFitImage}
-                            width={imageWidth}
-                            height={imageHeight}
-                            testID={CONST.IMAGE_SVG_TEST_ID}
-                        />
-                    ) : (
-                        <Image
-                            accessibilityIgnoresInvertColors
-                            source={image as ImageSourcePropType}
-                            resizeMode={contentFitImage as ImageResizeMode}
-                            style={styles.featureTrainingModalImage}
-                            testID={CONST.IMAGE_TEST_ID}
-                        />
-                    ))}
-                {!!videoURL && videoStatus === 'video' && (
-                    <GestureHandlerRootView>
-                        <VideoPlayer
-                            url={videoURL}
-                            videoPlayerStyle={[styles.onboardingVideoPlayer, {aspectRatio}]}
-                            onSourceLoaded={setAspectRatio}
-                            controlsStatus={CONST.VIDEO_PLAYER.CONTROLS_STATUS.HIDE}
-                            shouldUseControlsBottomMargin={false}
-                            shouldPlay
-                            isLooping
-                        />
-                    </GestureHandlerRootView>
-                )}
-                {((!videoURL && !image) || (!!videoURL && videoStatus === 'animation')) && (
-                    <View style={[styles.flex1, styles.alignItemsCenter, styles.justifyContentCenter, !!videoURL && {aspectRatio}, animationStyle]}>
-                        {isReduceMotionEnabled && (animation ?? LottieAnimations.Hands) === LottieAnimations.Hands ? (
-                            <ImageSVG
-                                src={illustrations.Hands}
-                                style={styles.h100}
-                            />
-                        ) : (
-                            <Lottie
-                                source={animation ?? LottieAnimations.Hands}
-                                style={styles.h100}
-                                webStyle={shouldUseNarrowLayout ? styles.h100 : undefined}
-                                autoPlay
-                                loop
-                            />
-                        )}
-                    </View>
-                )}
-            </View>
-        );
-    };
-
     const toggleWillShowAgain = () => setWillShowAgain((prevWillShowAgain) => !prevWillShowAgain);
 
     const pendingCloseModalAction = () => {
@@ -356,7 +255,10 @@ function FeatureTrainingModal({
         }
     };
 
-    const closeModal = () => {
+    const closeModal = (didPressHelpButton?: boolean) => {
+        if (didPressHelpButton) {
+            hasHelpButtonBeenPressed.current = true;
+        }
         Log.hmmm(`[FeatureTrainingModal] closeModal called - willShowAgain: ${willShowAgain}, shouldGoBack: ${shouldGoBack}, hasOnClose: ${!!onClose}`);
 
         if (!willShowAgain) {
@@ -387,11 +289,11 @@ function FeatureTrainingModal({
 
     // Scrolls modal to the bottom when keyboard appears so the action buttons are visible.
     useEffect(() => {
-        if (contentHeight <= containerHeight || onboardingIsMediumOrLargerScreenWidth || !shouldUseScrollView) {
+        if (contentHeight <= containerHeight || onboardingIsMediumOrLargerScreenWidth || !shouldUseScrollView || isCarousel) {
             return;
         }
         scrollViewRef.current?.scrollToEnd({animated: false});
-    }, [contentHeight, containerHeight, onboardingIsMediumOrLargerScreenWidth, shouldUseScrollView]);
+    }, [contentHeight, containerHeight, onboardingIsMediumOrLargerScreenWidth, shouldUseScrollView, isCarousel]);
 
     const Wrapper = shouldUseScrollView ? ScrollView : View;
 
@@ -430,90 +332,96 @@ function FeatureTrainingModal({
             shouldDisableBottomSafeAreaPadding={shouldUseScrollView}
             shouldWrapModalChildrenInScrollViewIfBottomDockedInLandscapeMode={!shouldUseScrollView}
         >
-            <Wrapper
-                scrollsToTop={false}
-                style={[
-                    onboardingIsMediumOrLargerScreenWidth && StyleUtils.getWidthStyle(width),
-                    wrapperStyles.style,
-                    isInLandscapeMode ? {maxHeight: windowHeight * CONST.MODAL_MAX_HEIGHT_TO_WINDOW_HEIGHT_RATIO_LANDSCAPE_MODE} : styles.mh100,
-                ]}
-                contentContainerStyle={wrapperStyles.containerStyle}
-                keyboardShouldPersistTaps={shouldUseScrollView ? 'handled' : undefined}
-                ref={shouldUseScrollView ? scrollViewRef : undefined}
-                onLayout={shouldUseScrollView ? (e: LayoutChangeEvent) => setContainerHeight(e.nativeEvent.layout.height) : undefined}
-                onContentSizeChange={shouldUseScrollView ? (_w: number, h: number) => setContentHeight(h) : undefined}
-                // Wrapper is either a View or ScrollView, which is also a View.
-                // eslint-disable-next-line react/forbid-component-props
-                fsClass={CONST.FULLSTORY.CLASS.UNMASK}
-            >
-                <View
+            {isCarousel ? (
+                <FeatureTrainingModalCarouselBody
+                    pages={pages}
+                    modalPadding={MODAL_PADDING}
+                    width={width}
+                    titleStyles={titleStyles}
+                    wrapperStyles={wrapperStyles.containerStyle}
+                    illustrationAspectRatio={illustrationAspectRatioProp}
+                    illustrationInnerContainerStyle={illustrationInnerContainerStyle}
+                    illustrationOuterContainerStyle={illustrationOuterContainerStyle}
+                    shouldRenderSVG={shouldRenderSVG}
+                    shouldRenderHTMLDescription={shouldRenderHTMLDescription}
+                    shouldShowDismissModalOption={shouldShowDismissModalOption}
+                    helpText={helpText}
+                    onHelp={onHelp}
+                    shouldCallOnHelpWhenModalHidden={shouldCallOnHelpWhenModalHidden}
+                    helpSentryLabel={helpSentryLabel}
+                    confirmSentryLabel={confirmSentryLabel}
+                    shouldShowConfirmationLoader={shouldShowConfirmationLoader}
+                    canConfirmWhileOffline={canConfirmWhileOffline}
+                    contentInnerContainerStyles={contentInnerContainerStyles}
+                    contentOuterContainerStyles={contentOuterContainerStyles}
+                    willShowAgain={willShowAgain}
+                    toggleWillShowAgain={toggleWillShowAgain}
+                    closeModal={closeModal}
+                    onConfirm={closeAndConfirmModal}
+                    onPageChange={onPageChange}
+                />
+            ) : (
+                <Wrapper
+                    scrollsToTop={false}
                     style={[
-                        onboardingIsMediumOrLargerScreenWidth ? {padding: MODAL_PADDING} : {paddingHorizontal: MODAL_PADDING},
-                        illustrationOuterContainerStyle,
-                        isInLandscapeMode ? [{maxHeight: windowHeight * LANDSCAPE_ILLUSTRATION_MAX_HEIGHT_TO_WINDOW_HEIGHT_RATIO}, styles.alignSelfCenter] : undefined,
+                        onboardingIsMediumOrLargerScreenWidth && StyleUtils.getWidthStyle(width),
+                        wrapperStyles.style,
+                        isInLandscapeMode ? {maxHeight: windowHeight * CONST.MODAL_MAX_HEIGHT_TO_WINDOW_HEIGHT_RATIO_LANDSCAPE_MODE} : styles.mh100,
                     ]}
+                    contentContainerStyle={wrapperStyles.containerStyle}
+                    keyboardShouldPersistTaps={shouldUseScrollView ? 'handled' : undefined}
+                    ref={shouldUseScrollView ? scrollViewRef : undefined}
+                    onLayout={shouldUseScrollView ? (e: LayoutChangeEvent) => setContainerHeight(e.nativeEvent.layout.height) : undefined}
+                    onContentSizeChange={shouldUseScrollView ? (_w: number, h: number) => setContentHeight(h) : undefined}
+                    // Wrapper is either a View or ScrollView, which is also a View.
+                    // eslint-disable-next-line react/forbid-component-props
+                    fsClass={CONST.FULLSTORY.CLASS.UNMASK}
                 >
-                    {renderIllustration()}
-                </View>
-                <View style={[styles.mt5, styles.mh5, contentOuterContainerStyles]}>
-                    {!!title && !!description && (
-                        <View
-                            style={[
-                                onboardingIsMediumOrLargerScreenWidth ? [styles.gap1, styles.mb8] : [shouldRenderHTMLDescription ? styles.mb5 : styles.mb10],
-                                contentInnerContainerStyles,
-                            ]}
-                        >
-                            {typeof title === 'string' ? <Text style={[styles.textHeadlineH1, titleStyles]}>{title}</Text> : title}
-                            {shouldRenderHTMLDescription ? (
-                                <View style={styles.mb2}>
-                                    <RenderHTML html={description} />
-                                </View>
-                            ) : (
-                                <Text style={styles.textSupporting}>{description}</Text>
-                            )}
-                            {secondaryDescription.length > 0 && <Text style={[styles.textSupporting, styles.mt4]}>{secondaryDescription}</Text>}
-                            {children}
-                        </View>
-                    )}
-                    {shouldShowDismissModalOption && (
-                        <CheckboxWithLabel
-                            label={translate('featureTraining.doNotShowAgain')}
-                            accessibilityLabel={translate('featureTraining.doNotShowAgain')}
-                            style={[styles.mb5]}
-                            isChecked={!willShowAgain}
-                            onInputChange={toggleWillShowAgain}
-                        />
-                    )}
-                    {!!helpText && (
-                        <Button
-                            large
-                            style={[styles.mb3]}
-                            onPress={() => {
-                                if (shouldCallOnHelpWhenModalHidden) {
-                                    setIsModalVisible(false);
-                                    hasHelpButtonBeenPressed.current = true;
-                                    return;
-                                }
-                                onHelp();
-                            }}
-                            text={helpText}
-                            sentryLabel={helpSentryLabel}
-                        />
-                    )}
-                    <FormAlertWithSubmitButton
-                        onSubmit={closeAndConfirmModal}
-                        isLoading={shouldShowConfirmationLoader}
-                        buttonText={confirmText}
-                        enabledWhenOffline={canConfirmWhileOffline}
-                        sentryLabel={confirmSentryLabel}
-                    />
-                    {!canConfirmWhileOffline && <OfflineIndicator />}
-                </View>
-            </Wrapper>
+                    <FeatureTrainingModalBody
+                        illustrationInnerContainerStyle={illustrationInnerContainerStyle}
+                        illustrationOuterContainerStyle={illustrationOuterContainerStyle}
+                        illustrationAspectRatio={illustrationAspectRatioProp}
+                        width={width}
+                        title={title}
+                        subtitle={subtitle}
+                        description={description}
+                        secondaryDescription={secondaryDescription}
+                        titleStyles={titleStyles}
+                        shouldShowDismissModalOption={shouldShowDismissModalOption}
+                        confirmText={confirmText}
+                        helpText={helpText}
+                        onHelp={onHelp}
+                        contentInnerContainerStyles={contentInnerContainerStyles}
+                        contentOuterContainerStyles={contentOuterContainerStyles}
+                        shouldRenderSVG={shouldRenderSVG}
+                        shouldRenderHTMLDescription={shouldRenderHTMLDescription}
+                        shouldShowConfirmationLoader={shouldShowConfirmationLoader}
+                        canConfirmWhileOffline={canConfirmWhileOffline}
+                        shouldCallOnHelpWhenModalHidden={shouldCallOnHelpWhenModalHidden}
+                        helpSentryLabel={helpSentryLabel}
+                        confirmSentryLabel={confirmSentryLabel}
+                        modalPadding={MODAL_PADDING}
+                        willShowAgain={willShowAgain}
+                        toggleWillShowAgain={toggleWillShowAgain}
+                        closeModal={closeModal}
+                        confirmModal={closeAndConfirmModal}
+                        {...props}
+                    >
+                        {children}
+                    </FeatureTrainingModalBody>
+                </Wrapper>
+            )}
         </Modal>
     );
 }
 
 export default FeatureTrainingModal;
 
-export type {FeatureTrainingModalProps};
+export type {
+    BaseFeatureTrainingModalProps,
+    FeatureTrainingModalProps,
+    FeatureTrainingModalCarouselProps,
+    FeatureTrainingModalContentProps,
+    FeatureTrainingModalPageProps,
+    FeatureTrainingModalIllustrationProps,
+};
diff --git a/src/components/Lottie/index.tsx b/src/components/Lottie/index.tsx
index dbdd4a65cf0c..500a7dc08442 100644
--- a/src/components/Lottie/index.tsx
+++ b/src/components/Lottie/index.tsx
@@ -1,6 +1,7 @@
 import {NavigationContainerRefContext, NavigationContext} from '@react-navigation/native';
 import type {AnimationObject, LottieViewProps} from 'lottie-react-native';
 import LottieView from 'lottie-react-native';
+import type {ForwardedRef} from 'react';
 import React, {useContext, useEffect, useRef, useState} from 'react';
 import {View} from 'react-native';
 import type DotLottieAnimation from '@components/LottieAnimations/types';
@@ -15,11 +16,12 @@ import CONST from '@src/CONST';
 import {useSplashScreenState} from '@src/SplashScreenStateContext';
 
 type Props = {
+    ref?: ForwardedRef<LottieView | null>;
     source: DotLottieAnimation;
     shouldLoadAfterInteractions?: boolean;
 } & Omit<LottieViewProps, 'source'>;
 
-function Lottie({source, webStyle, shouldLoadAfterInteractions, ...props}: Props) {
+function Lottie({ref, source, webStyle, shouldLoadAfterInteractions, ...props}: Props) {
     const animationRef = useRef<LottieView | null>(null);
     const appState = useAppState();
     const {splashScreenState} = useSplashScreenState();
@@ -121,8 +123,14 @@ function Lottie({source, webStyle, shouldLoadAfterInteractions, ...props}: Props
             key={`${hasNavigatedAway}`}
             ref={(newRef) => {
                 animationRef.current = newRef;
+                if (typeof ref === 'function') {
+                    ref(newRef);
+                } else if (ref && 'current' in ref) {
+                    // eslint-disable-next-line no-param-reassign
+                    ref.current = newRef;
+                }
             }}
-            autoPlay={!isReduceMotionEnabled}
+            autoPlay={props.autoPlay && !isReduceMotionEnabled}
             style={[aspectRatioStyle, props.style]}
             webStyle={{...aspectRatioStyle, ...webStyle}}
             onAnimationFailure={() => setIsError(true)}
diff --git a/src/components/LottieAnimations/index.tsx b/src/components/LottieAnimations/index.tsx
index b86a3f86ec13..a5cc9af24cd6 100644
--- a/src/components/LottieAnimations/index.tsx
+++ b/src/components/LottieAnimations/index.tsx
@@ -94,6 +94,22 @@ const DotLottieAnimations = {
         w: 204,
         h: 204,
     },
+    SpendAnalysis: {
+        file: require<LottieViewProps['source']>('@assets/animations/SpendAnalysis.lottie'),
+        w: 440,
+        h: 240,
+        backgroundColor: colors.pink700,
+    },
+    ExpenseAssistant: {
+        file: require<LottieViewProps['source']>('@assets/animations/ExpenseAssistant.lottie'),
+        w: 440,
+        h: 240,
+    },
+    CustomAgents: {
+        file: require<LottieViewProps['source']>('@assets/animations/CustomAgents.lottie'),
+        w: 440,
+        h: 240,
+    },
 } satisfies Record<string, DotLottieAnimation>;
 
 export default DotLottieAnimations;
diff --git a/src/components/Section/index.tsx b/src/components/Section/index.tsx
index b9d210735278..f951af2dc4d6 100644
--- a/src/components/Section/index.tsx
+++ b/src/components/Section/index.tsx
@@ -166,6 +166,7 @@ function Section({
                                 style={styles.h100}
                                 webStyle={styles.h100}
                                 loop
+                                autoPlay
                                 shouldLoadAfterInteractions={shouldUseNarrowLayout}
                             />
                         ) : (
diff --git a/src/languages/de.ts b/src/languages/de.ts
index adf0efaa4e16..37bf76d67d77 100644
--- a/src/languages/de.ts
+++ b/src/languages/de.ts
@@ -9889,5 +9889,21 @@ Hier ist ein *Testbeleg*, um dir zu zeigen, wie es funktioniert:`,
         negativeButton: 'Nicht wirklich',
     },
     monthPickerPage: {month: 'Monat', selectMonth: 'Bitte wählen Sie einen Monat aus'},
+    aiFeaturesPromoModal: {
+        subtitle: 'Neu bei Concierge AI',
+        confirmText: 'Los geht’s!',
+        spendAnalysis: {
+            title: 'Interaktive Ausgabenanalyse',
+            description: `<muted-text>Concierge zeigt monatliche Ausgabenanalysen an und ermöglicht es Ihnen, die Details hinter jeder Zahl genauer zu betrachten. <a href="${CONST.AI_FEATURES_PROMO_LEARN_MORE_URLS.SPEND_ANALYSIS}">Mehr erfahren</a>.</muted-text>`,
+        },
+        expenseAssistant: {
+            title: 'Lernen Sie Ihre neue Spesenassistenz kennen',
+            description: `<muted-text>Chatten Sie mit Concierge, um Ausgaben direkt in der App oder per E-Mail oder SMS zu erstellen und zu aktualisieren. <a href="${CONST.AI_FEATURES_PROMO_LEARN_MORE_URLS.EXPENSE_ASSISTANT}">Mehr erfahren</a>.</muted-text>`,
+        },
+        customAgents: {
+            title: 'Erstellen Sie Ihre eigenen Agenten',
+            description: `<muted-text>Erstellen Sie benutzerdefinierte Agenten, die Ausgaben anhand Ihrer Regeln prüfen, genehmigen und weiterleiten. <a href="${CONST.AI_FEATURES_PROMO_LEARN_MORE_URLS.BUILD_AGENTS}">Mehr erfahren</a>.</muted-text>`,
+        },
+    },
 };
 export default translations;
diff --git a/src/languages/en.ts b/src/languages/en.ts
index 3f62abc1c451..f2eb40b0e731 100644
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -9650,6 +9650,22 @@ const translations = {
             chat: 'Chat on any expense to resolve questions quickly',
         },
     },
+    aiFeaturesPromoModal: {
+        subtitle: 'New to Concierge AI',
+        confirmText: "Let's go!",
+        spendAnalysis: {
+            title: 'Interactive spend analysis',
+            description: `<muted-text>Concierge surfaces monthly spend insights and lets you drill into the details behind every number. <a href="${CONST.AI_FEATURES_PROMO_LEARN_MORE_URLS.SPEND_ANALYSIS}">Learn more</a>.</muted-text>`,
+        },
+        expenseAssistant: {
+            title: 'Meet your new expense assistant',
+            description: `<muted-text>Chat with Concierge to create and update expenses, right in the app or by email or text. <a href="${CONST.AI_FEATURES_PROMO_LEARN_MORE_URLS.EXPENSE_ASSISTANT}">Learn more</a>.</muted-text>`,
+        },
+        customAgents: {
+            title: 'Build your own agents',
+            description: `<muted-text>Create custom agents to review, approve, and route expenses based on rules you set. <a href="${CONST.AI_FEATURES_PROMO_LEARN_MORE_URLS.BUILD_AGENTS}">Learn more</a>.</muted-text>`,
+        },
+    },
     productTrainingTooltip: {
         // TODO: CONCIERGE_LHN_GBR tooltip will be replaced by a tooltip in the #admins room
         // https://github.com/Expensify/App/issues/57045#issuecomment-2701455668
diff --git a/src/languages/es.ts b/src/languages/es.ts
index b5e71c16e493..be4ceb4ee610 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -10130,5 +10130,21 @@ ${amount} para ${merchant} - ${date}`,
             lockScreenTrackingText: 'Siguiendo...',
         },
     },
+    aiFeaturesPromoModal: {
+        subtitle: 'Nuevo en Concierge AI',
+        confirmText: '¡Vamos!',
+        spendAnalysis: {
+            title: 'Análisis interactivo del gasto',
+            description: `<muted-text>Concierge muestra información mensual sobre gastos y te permite profundizar en los detalles detrás de cada cifra. <a href="${CONST.AI_FEATURES_PROMO_LEARN_MORE_URLS.SPEND_ANALYSIS}">Más información</a>.</muted-text>`,
+        },
+        expenseAssistant: {
+            title: 'Conoce a tu nuevo asistente de gastos',
+            description: `<muted-text>Chatea con Concierge para crear y actualizar gastos, directamente en la aplicación o por correo electrónico o mensaje de texto. <a href="${CONST.AI_FEATURES_PROMO_LEARN_MORE_URLS.EXPENSE_ASSISTANT}">Más información</a>.</muted-text>`,
+        },
+        customAgents: {
+            title: 'Crea tus propios agentes',
+            description: `<muted-text>Crea agentes personalizados para revisar, aprobar y asignar gastos según las reglas que configures. <a href="${CONST.AI_FEATURES_PROMO_LEARN_MORE_URLS.BUILD_AGENTS}">Más información</a>.</muted-text>`,
+        },
+    },
 };
 export default translations;
diff --git a/src/languages/fr.ts b/src/languages/fr.ts
index 9c72ab439b7b..e7aec90fcf27 100644
--- a/src/languages/fr.ts
+++ b/src/languages/fr.ts
@@ -9925,5 +9925,21 @@ Voici un *reçu test* pour vous montrer comment ça fonctionne :`,
         negativeButton: 'Pas vraiment',
     },
     monthPickerPage: {month: 'Mois', selectMonth: 'Veuillez sélectionner un mois'},
+    aiFeaturesPromoModal: {
+        subtitle: 'Nouveau dans Concierge IA',
+        confirmText: 'Allons-y !',
+        spendAnalysis: {
+            title: 'Analyse interactive des dépenses',
+            description: `<muted-text>Concierge met en avant des analyses mensuelles des dépenses et vous permet d’examiner en détail chaque chiffre. <a href="${CONST.AI_FEATURES_PROMO_LEARN_MORE_URLS.SPEND_ANALYSIS}">En savoir plus</a>.</muted-text>`,
+        },
+        expenseAssistant: {
+            title: 'Découvrez votre nouvel assistant de dépenses',
+            description: `<muted-text>Discutez avec Concierge pour créer et mettre à jour des dépenses, directement dans l’application ou par e-mail ou SMS. <a href="${CONST.AI_FEATURES_PROMO_LEARN_MORE_URLS.EXPENSE_ASSISTANT}">En savoir plus</a>.</muted-text>`,
+        },
+        customAgents: {
+            title: 'Créez vos propres agents',
+            description: `<muted-text>Créez des agents personnalisés pour examiner, approuver et acheminer les dépenses selon les règles que vous définissez. <a href="${CONST.AI_FEATURES_PROMO_LEARN_MORE_URLS.BUILD_AGENTS}">En savoir plus</a>.</muted-text>`,
+        },
+    },
 };
 export default translations;
diff --git a/src/languages/it.ts b/src/languages/it.ts
index 179bf66e10f4..32c03fa461f7 100644
--- a/src/languages/it.ts
+++ b/src/languages/it.ts
@@ -9879,5 +9879,21 @@ Ecco una *ricevuta di prova* per mostrarti come funziona:`,
         negativeButton: 'Non proprio',
     },
     monthPickerPage: {month: 'Mese', selectMonth: 'Seleziona un mese'},
+    aiFeaturesPromoModal: {
+        subtitle: 'Nuovo in Concierge AI',
+        confirmText: 'Andiamo!',
+        spendAnalysis: {
+            title: 'Analisi interattiva delle spese',
+            description: `<muted-text>Concierge mette in evidenza approfondimenti sulla spesa mensile e ti permette di approfondire i dettagli dietro ogni numero. <a href="${CONST.AI_FEATURES_PROMO_LEARN_MORE_URLS.SPEND_ANALYSIS}">Scopri di più</a>.</muted-text>`,
+        },
+        expenseAssistant: {
+            title: 'Incontra il tuo nuovo assistente per le spese',
+            description: `<muted-text>Chatta con Concierge per creare e aggiornare le spese direttamente nell’app o via email o SMS. <a href="${CONST.AI_FEATURES_PROMO_LEARN_MORE_URLS.EXPENSE_ASSISTANT}">Scopri di più</a>.</muted-text>`,
+        },
+        customAgents: {
+            title: 'Crea i tuoi agenti',
+            description: `<muted-text>Crea agenti personalizzati per verificare, approvare e instradare le spese in base alle regole che imposti. <a href="${CONST.AI_FEATURES_PROMO_LEARN_MORE_URLS.BUILD_AGENTS}">Scopri di più</a>.</muted-text>`,
+        },
+    },
 };
 export default translations;
diff --git a/src/languages/ja.ts b/src/languages/ja.ts
index 1fda90299a72..727eaa34d5b9 100644
--- a/src/languages/ja.ts
+++ b/src/languages/ja.ts
@@ -9749,5 +9749,21 @@ ${reportName}`,
         negativeButton: 'そうでもありません',
     },
     monthPickerPage: {month: '月', selectMonth: '月を選択してください'},
+    aiFeaturesPromoModal: {
+        subtitle: 'はじめての Concierge AI',
+        confirmText: '始めましょう！',
+        spendAnalysis: {
+            title: 'インタラクティブな支出分析',
+            description: `<muted-text>Concierge は毎月の支出インサイトを提示し、すべての数値の内訳を詳しく確認できるようにします。<a href="${CONST.AI_FEATURES_PROMO_LEARN_MORE_URLS.SPEND_ANALYSIS}">詳しく見る</a>。</muted-text>`,
+        },
+        expenseAssistant: {
+            title: '新しい経費アシスタントをご紹介します',
+            description: `<muted-text>アプリ内やメール、テキストメッセージでConciergeとチャットして、経費を作成・更新できます。<a href="${CONST.AI_FEATURES_PROMO_LEARN_MORE_URLS.EXPENSE_ASSISTANT}">詳しく見る</a>。</muted-text>`,
+        },
+        customAgents: {
+            title: '独自のエージェントを作成する',
+            description: `<muted-text>設定したルールに基づいて経費を確認、承認、振り分けるカスタムエージェントを作成できます。<a href="${CONST.AI_FEATURES_PROMO_LEARN_MORE_URLS.BUILD_AGENTS}">さらに詳しく</a>。</muted-text>`,
+        },
+    },
 };
 export default translations;
diff --git a/src/languages/nl.ts b/src/languages/nl.ts
index e87b17360733..7c1791147e79 100644
--- a/src/languages/nl.ts
+++ b/src/languages/nl.ts
@@ -9842,5 +9842,21 @@ Hier is een *proefbon* om je te laten zien hoe het werkt:`,
         negativeButton: 'Niet echt',
     },
     monthPickerPage: {month: 'Maand', selectMonth: 'Selecteer een maand'},
+    aiFeaturesPromoModal: {
+        subtitle: 'Nieuw bij Concierge AI',
+        confirmText: 'Laten we gaan!',
+        spendAnalysis: {
+            title: 'Interactieve uitgavenanalyse',
+            description: `<muted-text>Concierge toont maandelijkse uitgaveninzichten en laat je inzoomen op de details achter elk getal. <a href="${CONST.AI_FEATURES_PROMO_LEARN_MORE_URLS.SPEND_ANALYSIS}">Meer informatie</a>.</muted-text>`,
+        },
+        expenseAssistant: {
+            title: 'Maak kennis met je nieuwe declaratie-assistent',
+            description: `<muted-text>Chat met Concierge om uitgaven aan te maken en bij te werken, rechtstreeks in de app of via e-mail of sms. <a href="${CONST.AI_FEATURES_PROMO_LEARN_MORE_URLS.EXPENSE_ASSISTANT}">Meer informatie</a>.</muted-text>`,
+        },
+        customAgents: {
+            title: 'Bouw je eigen agents',
+            description: `<muted-text>Maak aangepaste agents om uitgaven te beoordelen, goed te keuren en door te sturen op basis van regels die jij instelt. <a href="${CONST.AI_FEATURES_PROMO_LEARN_MORE_URLS.BUILD_AGENTS}">Meer informatie</a>.</muted-text>`,
+        },
+    },
 };
 export default translations;
diff --git a/src/languages/pl.ts b/src/languages/pl.ts
index b236e88f4d70..fd40b3d31436 100644
--- a/src/languages/pl.ts
+++ b/src/languages/pl.ts
@@ -9825,5 +9825,21 @@ Oto *paragon testowy*, żeby pokazać Ci, jak to działa:`,
         negativeButton: 'Niekoniecznie',
     },
     monthPickerPage: {month: 'Miesiąc', selectMonth: 'Wybierz miesiąc'},
+    aiFeaturesPromoModal: {
+        subtitle: 'Nowość w Concierge AI',
+        confirmText: 'Jedziemy!',
+        spendAnalysis: {
+            title: 'Interaktywna analiza wydatków',
+            description: `<muted-text>Concierge przedstawia miesięczne informacje o wydatkach i pozwala zagłębić się w szczegóły stojące za każdą liczbą. <a href="${CONST.AI_FEATURES_PROMO_LEARN_MORE_URLS.SPEND_ANALYSIS}">Dowiedz się więcej</a>.</muted-text>`,
+        },
+        expenseAssistant: {
+            title: 'Poznaj swojego nowego asystenta wydatków',
+            description: `<muted-text>Rozmawiaj z Concierge, aby tworzyć i aktualizować wydatki bezpośrednio w aplikacji, e-mailem lub SMS-em. <a href="${CONST.AI_FEATURES_PROMO_LEARN_MORE_URLS.EXPENSE_ASSISTANT}">Dowiedz się więcej</a>.</muted-text>`,
+        },
+        customAgents: {
+            title: 'Zbuduj własne agentów',
+            description: `<muted-text>Twórz niestandardowych agentów do przeglądania, zatwierdzania i kierowania wydatków na podstawie ustalonych przez siebie zasad. <a href="${CONST.AI_FEATURES_PROMO_LEARN_MORE_URLS.BUILD_AGENTS}">Dowiedz się więcej</a>.</muted-text>`,
+        },
+    },
 };
 export default translations;
diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts
index 011d86edb647..2ec8e401a2ef 100644
--- a/src/languages/pt-BR.ts
+++ b/src/languages/pt-BR.ts
@@ -9831,5 +9831,21 @@ Aqui está um *comprovante de teste* para mostrar como funciona:`,
         negativeButton: 'Na verdade, não',
     },
     monthPickerPage: {month: 'Mês', selectMonth: 'Selecione um mês por favor'},
+    aiFeaturesPromoModal: {
+        subtitle: 'Novo no Concierge AI',
+        confirmText: 'Vamos lá!',
+        spendAnalysis: {
+            title: 'Análise interativa de gastos',
+            description: `<muted-text>O Concierge apresenta insights mensais de gastos e permite que você aprofunde nos detalhes por trás de cada número. <a href="${CONST.AI_FEATURES_PROMO_LEARN_MORE_URLS.SPEND_ANALYSIS}">Saiba mais</a>.</muted-text>`,
+        },
+        expenseAssistant: {
+            title: 'Conheça seu novo assistente de despesas',
+            description: `<muted-text>Converse com o Concierge para criar e atualizar despesas, direto no app ou por e-mail ou SMS. <a href="${CONST.AI_FEATURES_PROMO_LEARN_MORE_URLS.EXPENSE_ASSISTANT}">Saiba mais</a>.</muted-text>`,
+        },
+        customAgents: {
+            title: 'Crie seus próprios agentes',
+            description: `<muted-text>Crie agentes personalizados para revisar, aprovar e direcionar despesas com base nas regras que você definir. <a href="${CONST.AI_FEATURES_PROMO_LEARN_MORE_URLS.BUILD_AGENTS}">Saiba mais</a>.</muted-text>`,
+        },
+    },
 };
 export default translations;
diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts
index 378553929223..c2c6919bd921 100644
--- a/src/languages/zh-hans.ts
+++ b/src/languages/zh-hans.ts
@@ -9562,5 +9562,21 @@ ${reportName}`,
     },
     proactiveAppReview: {title: '喜欢全新的 Expensify 吗？', description: '请告诉我们，这样我们就能帮助您让报销体验变得更好。', positiveButton: '太棒了！', negativeButton: '不太是'},
     monthPickerPage: {month: '月份', selectMonth: '请选择月份'},
+    aiFeaturesPromoModal: {
+        subtitle: 'Concierge AI 新手指南',
+        confirmText: '出发吧！',
+        spendAnalysis: {
+            title: '交互式支出分析',
+            description: `<muted-text>Concierge 会提供每月支出洞察，并让你深入查看每个数字背后的详细信息。<a href="${CONST.AI_FEATURES_PROMO_LEARN_MORE_URLS.SPEND_ANALYSIS}">了解详情</a>。</muted-text>`,
+        },
+        expenseAssistant: {
+            title: '认识你的全新报销助手',
+            description: `<muted-text>在应用内或通过电子邮件、短信与 Concierge 聊天来创建和更新报销。<a href="${CONST.AI_FEATURES_PROMO_LEARN_MORE_URLS.EXPENSE_ASSISTANT}">了解更多</a>。</muted-text>`,
+        },
+        customAgents: {
+            title: '构建你自己的代理',
+            description: `<muted-text>创建自定义代理，根据你设置的规则审核、批准和分配报销。<a href="${CONST.AI_FEATURES_PROMO_LEARN_MORE_URLS.BUILD_AGENTS}">了解更多</a>。</muted-text>`,
+        },
+    },
 };
 export default translations;
diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.tsx b/src/libs/Navigation/AppNavigator/AuthScreens.tsx
index 1b4ac7b2c2aa..571ef4c9402f 100644
--- a/src/libs/Navigation/AppNavigator/AuthScreens.tsx
+++ b/src/libs/Navigation/AppNavigator/AuthScreens.tsx
@@ -56,6 +56,7 @@ import DelegatorConnectGuard from './DelegatorConnectGate';
 import hideKeyboardOnSwipe from './hideKeyboardOnSwipe';
 import KeyboardShortcutsHandler from './KeyboardShortcutsHandler';
 import {ShareModalStackNavigator} from './ModalStackNavigators';
+import AIFeaturesPromoModalNavigator from './Navigators/AIFeaturesPromoModalNavigator';
 import FeatureTrainingModalNavigator from './Navigators/FeatureTrainingModalNavigator';
 import MigratedUserWelcomeModalNavigator from './Navigators/MigratedUserWelcomeModalNavigator';
 import MultifactorAuthenticationModalNavigator from './Navigators/MultifactorAuthenticationModalNavigator';
@@ -306,6 +307,11 @@ function AuthScreens() {
                             options={rootNavigatorScreenOptions.basicModalNavigator}
                             component={MigratedUserWelcomeModalNavigator}
                         />
+                        <RootStack.Screen
+                            name={NAVIGATORS.AI_FEATURES_PROMO_MODAL_NAVIGATOR}
+                            options={rootNavigatorScreenOptions.basicModalNavigator}
+                            component={AIFeaturesPromoModalNavigator}
+                        />
                         <RootStack.Screen
                             name={NAVIGATORS.TEST_DRIVE_MODAL_NAVIGATOR}
                             options={rootNavigatorScreenOptions.basicModalNavigator}
diff --git a/src/libs/Navigation/AppNavigator/Navigators/AIFeaturesPromoModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/AIFeaturesPromoModalNavigator.tsx
new file mode 100644
index 000000000000..7ebec85bd9a8
--- /dev/null
+++ b/src/libs/Navigation/AppNavigator/Navigators/AIFeaturesPromoModalNavigator.tsx
@@ -0,0 +1,26 @@
+import React from 'react';
+import {View} from 'react-native';
+import AIFeaturesPromoModal from '@components/AIFeaturesPromoModal';
+import NoDropZone from '@components/DragAndDrop/NoDropZone';
+import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator';
+import type {AIFeaturesPromoModalNavigatorParamList} from '@libs/Navigation/types';
+import SCREENS from '@src/SCREENS';
+
+const Stack = createPlatformStackNavigator<AIFeaturesPromoModalNavigatorParamList>();
+
+function AIFeaturesPromoModalNavigator() {
+    return (
+        <NoDropZone>
+            <View>
+                <Stack.Navigator screenOptions={{headerShown: false}}>
+                    <Stack.Screen
+                        name={SCREENS.AI_FEATURES_PROMO_MODAL.ROOT}
+                        component={AIFeaturesPromoModal}
+                    />
+                </Stack.Navigator>
+            </View>
+        </NoDropZone>
+    );
+}
+
+export default AIFeaturesPromoModalNavigator;
diff --git a/src/libs/Navigation/guards/AIFeaturesPromoGuard.ts b/src/libs/Navigation/guards/AIFeaturesPromoGuard.ts
new file mode 100644
index 000000000000..23915fbf6ed6
--- /dev/null
+++ b/src/libs/Navigation/guards/AIFeaturesPromoGuard.ts
@@ -0,0 +1,226 @@
+import type {NavigationAction, NavigationState, PartialState} from '@react-navigation/native';
+import {findFocusedRoute} from '@react-navigation/native';
+import {hasCompletedGuidedSetupFlowSelector, tryNewDotOnyxSelector} from '@selectors/Onboarding';
+import Onyx from 'react-native-onyx';
+import type {OnyxEntry} from 'react-native-onyx';
+import Log from '@libs/Log';
+import Navigation from '@libs/Navigation/Navigation';
+import navigationRef from '@libs/Navigation/navigationRef';
+import TransitionTracker from '@libs/Navigation/TransitionTracker';
+import isProductTrainingElementDismissed from '@libs/TooltipUtils';
+import CONST from '@src/CONST';
+import NAVIGATORS from '@src/NAVIGATORS';
+import ONYXKEYS from '@src/ONYXKEYS';
+import ROUTES from '@src/ROUTES';
+import SCREENS from '@src/SCREENS';
+import type {DismissedProductTraining, Onboarding, Session} from '@src/types/onyx';
+import type {GuardResult, NavigationGuard} from './types';
+
+let session: OnyxEntry<Session>;
+let isLoadingApp = true;
+
+let dismissedProductTraining: OnyxEntry<DismissedProductTraining>;
+let isDismissedProductTrainingLoaded = false;
+
+let hasBeenAddedToNudgeMigration = false;
+let isTryNewDotLoaded = false;
+
+let onboarding: OnyxEntry<Onboarding>;
+let isOnboardingLoaded = false;
+
+let hasRedirectedToAIFeaturesPromoModal = false;
+let isWaitingForProtectedRoutes = false;
+
+/**
+ * This modal must not appear in the same session as the migration welcome modal, the onboarding flow,
+ * or the HybridApp explanation modal. These flags trip when we observe any of those navigators mounting
+ * during this process lifetime, and suppress the AI promo for the rest of the session.
+ */
+let observedActiveMigrationModalThisSession = false;
+let observedActiveOnboardingThisSession = false;
+
+function containsNavigator(state: NavigationState | PartialState<NavigationState> | undefined, navigatorName: string): boolean {
+    if (!state?.routes) {
+        return false;
+    }
+    return state.routes.some((route) => route.name === navigatorName || containsNavigator(route.state, navigatorName));
+}
+
+function snapshotActiveModalsFromNavigationState() {
+    if (!navigationRef.isReady?.()) {
+        return;
+    }
+    const rootState = navigationRef.getRootState?.();
+    if (!rootState) {
+        return;
+    }
+    if (containsNavigator(rootState, NAVIGATORS.ONBOARDING_MODAL_NAVIGATOR)) {
+        observedActiveOnboardingThisSession = true;
+    }
+    if (containsNavigator(rootState, NAVIGATORS.MIGRATED_USER_MODAL_NAVIGATOR)) {
+        observedActiveMigrationModalThisSession = true;
+    }
+}
+
+// Attach lazily — the listener can only be added once react-navigation's ref is ready,
+// which generally happens after this module is loaded.
+let isStateListenerAttached = false;
+function attachNavigationStateListener() {
+    if (isStateListenerAttached || !navigationRef.isReady?.()) {
+        return;
+    }
+    isStateListenerAttached = true;
+    snapshotActiveModalsFromNavigationState();
+    navigationRef.addListener('state', snapshotActiveModalsFromNavigationState);
+}
+
+function isEligibleToShowAIFeaturesPromoModal(): boolean {
+    return (
+        !!session?.authToken &&
+        !isLoadingApp &&
+        !hasRedirectedToAIFeaturesPromoModal &&
+        isDismissedProductTrainingLoaded &&
+        isTryNewDotLoaded &&
+        isOnboardingLoaded &&
+        !isProductTrainingElementDismissed(CONST.AI_FEATURES_PROMO_MODAL, dismissedProductTraining) &&
+        !observedActiveMigrationModalThisSession &&
+        !observedActiveOnboardingThisSession
+    );
+}
+
+/**
+ * Proactively navigate to the AI features promo modal when all conditions are met.
+ */
+function navigateToAIFeaturesPromoModalIfReady() {
+    // Sync the modal-active flags from the current navigation state (and attach a listener
+    // for future state changes) before checking, so a freshly-mounted onboarding navigator
+    // is reflected immediately.
+    attachNavigationStateListener();
+    snapshotActiveModalsFromNavigationState();
+
+    if (isWaitingForProtectedRoutes || !isEligibleToShowAIFeaturesPromoModal()) {
+        return;
+    }
+
+    isWaitingForProtectedRoutes = true;
+    // Defer until any in-flight navigation transition (splash → home, etc.)
+    // has fully settled, then wait for the protected stack to be in the nav tree.
+    TransitionTracker.runAfterTransitions({
+        callback: () => {
+            Navigation.waitForProtectedRoutes().then(() => {
+                isWaitingForProtectedRoutes = false;
+                snapshotActiveModalsFromNavigationState();
+                if (!isEligibleToShowAIFeaturesPromoModal()) {
+                    return;
+                }
+                Log.info('[AIFeaturesPromoGuard] Proactively navigating to AI features promo modal');
+                hasRedirectedToAIFeaturesPromoModal = true;
+                Navigation.navigate(ROUTES.AI_FEATURES_PROMO_MODAL);
+            });
+        },
+        waitForUpcomingTransition: true,
+    });
+}
+
+/**
+ * Called by guards/index.ts when session or loading app state changes.
+ * Reuses the shared Onyx subscriptions from guards/index.ts to avoid duplicate connections.
+ */
+function onSessionOrLoadingAppChanged(sessionValue: OnyxEntry<Session>, isLoadingAppValue: boolean) {
+    session = sessionValue;
+    isLoadingApp = isLoadingAppValue;
+    navigateToAIFeaturesPromoModalIfReady();
+}
+
+Onyx.connectWithoutView({
+    key: ONYXKEYS.NVP_DISMISSED_PRODUCT_TRAINING,
+    callback: (value) => {
+        dismissedProductTraining = value;
+        isDismissedProductTrainingLoaded = true;
+        if (isProductTrainingElementDismissed(CONST.AI_FEATURES_PROMO_MODAL, value)) {
+            hasRedirectedToAIFeaturesPromoModal = false;
+        }
+        // If the migration welcome modal is currently still pending, suppress AI promo this session.
+        if (hasBeenAddedToNudgeMigration && !isProductTrainingElementDismissed(CONST.MIGRATED_USER_WELCOME_MODAL, value)) {
+            observedActiveMigrationModalThisSession = true;
+        }
+        navigateToAIFeaturesPromoModalIfReady();
+    },
+});
+
+Onyx.connectWithoutView({
+    key: ONYXKEYS.NVP_ONBOARDING,
+    callback: (value) => {
+        onboarding = value;
+        isOnboardingLoaded = true;
+        if (hasCompletedGuidedSetupFlowSelector(onboarding) === false) {
+            observedActiveOnboardingThisSession = true;
+        }
+        navigateToAIFeaturesPromoModalIfReady();
+    },
+});
+
+Onyx.connectWithoutView({
+    key: ONYXKEYS.NVP_TRY_NEW_DOT,
+    callback: (value) => {
+        const result = value ? tryNewDotOnyxSelector(value) : undefined;
+        hasBeenAddedToNudgeMigration = result?.hasBeenAddedToNudgeMigration ?? false;
+        isTryNewDotLoaded = true;
+        if (hasBeenAddedToNudgeMigration && !isProductTrainingElementDismissed(CONST.MIGRATED_USER_WELCOME_MODAL, dismissedProductTraining)) {
+            observedActiveMigrationModalThisSession = true;
+        }
+        navigateToAIFeaturesPromoModalIfReady();
+    },
+});
+
+/**
+ * Block navigation while the AI features promo modal is active (on top of the stack).
+ * Mirrors the pattern from MigratedUserWelcomeModalGuard.
+ */
+function shouldBlockWhileModalActive(state: NavigationState, action: NavigationAction): boolean {
+    const isAllowedAction = action.type === CONST.NAVIGATION.ACTION_TYPE.DISMISS_MODAL || action.type === CONST.NAVIGATION.ACTION_TYPE.GO_BACK;
+    return (
+        hasRedirectedToAIFeaturesPromoModal &&
+        !isProductTrainingElementDismissed(CONST.AI_FEATURES_PROMO_MODAL, dismissedProductTraining) &&
+        state.routes.at(-1)?.name === NAVIGATORS.AI_FEATURES_PROMO_MODAL_NAVIGATOR &&
+        !isAllowedAction
+    );
+}
+
+/** Prevents redirect loops by detecting when we're already on or resetting to the modal. */
+function isNavigatingToAIFeaturesPromoModal(state: NavigationState, action: NavigationAction): boolean {
+    const isOnModal = findFocusedRoute(state)?.name === SCREENS.AI_FEATURES_PROMO_MODAL.ROOT;
+    const isResettingToModal = action.type === 'RESET' && !!action.payload && findFocusedRoute(action.payload as NavigationState)?.name === SCREENS.AI_FEATURES_PROMO_MODAL.ROOT;
+
+    return isOnModal || isResettingToModal;
+}
+
+/**
+ * AIFeaturesPromoGuard surfaces the one-time AI features promo modal.
+ *
+ * This guard relies on the proactive Onyx-driven path (navigateToAIFeaturesPromoModalIfReady)
+ * rather than redirecting from evaluate(), because it needs to wait for higher-priority guards
+ * (Onboarding, MigratedUserWelcomeModal) to settle before deciding whether to fire.
+ */
+const AIFeaturesPromoGuard: NavigationGuard = {
+    name: 'AIFeaturesPromoGuard',
+
+    evaluate: (state: NavigationState, action: NavigationAction, context): GuardResult => {
+        if (context.isLoading) {
+            return {type: 'ALLOW'};
+        }
+
+        if (shouldBlockWhileModalActive(state, action)) {
+            return {type: 'BLOCK', reason: '[AIFeaturesPromoGuard] Blocking navigation while AI features promo modal is active'};
+        }
+
+        if (isNavigatingToAIFeaturesPromoModal(state, action) || hasRedirectedToAIFeaturesPromoModal) {
+            return {type: 'ALLOW'};
+        }
+
+        return {type: 'ALLOW'};
+    },
+};
+
+export default AIFeaturesPromoGuard;
+export {onSessionOrLoadingAppChanged};
diff --git a/src/libs/Navigation/guards/index.ts b/src/libs/Navigation/guards/index.ts
index fdc43fd6440a..783b8be533b3 100644
--- a/src/libs/Navigation/guards/index.ts
+++ b/src/libs/Navigation/guards/index.ts
@@ -4,7 +4,8 @@ import type {OnyxEntry} from 'react-native-onyx';
 import getCurrentUrl from '@libs/Navigation/currentUrl';
 import ONYXKEYS from '@src/ONYXKEYS';
 import type {Session} from '@src/types/onyx';
-import MigratedUserWelcomeModalGuard, {onSessionOrLoadingAppChanged} from './MigratedUserWelcomeModalGuard';
+import AIFeaturesPromoGuard, {onSessionOrLoadingAppChanged as onAIFeaturesPromoSessionOrLoadingAppChanged} from './AIFeaturesPromoGuard';
+import MigratedUserWelcomeModalGuard, {onSessionOrLoadingAppChanged as onMigratedUserWelcomeModalSessionOrLoadingAppChanged} from './MigratedUserWelcomeModalGuard';
 import OnboardingGuard from './OnboardingGuard';
 import type {GuardContext, GuardResult, NavigationGuard} from './types';
 
@@ -19,7 +20,8 @@ Onyx.connectWithoutView({
     key: ONYXKEYS.SESSION,
     callback: (value) => {
         session = value;
-        onSessionOrLoadingAppChanged(session, isLoadingApp);
+        onMigratedUserWelcomeModalSessionOrLoadingAppChanged(session, isLoadingApp);
+        onAIFeaturesPromoSessionOrLoadingAppChanged(session, isLoadingApp);
     },
 });
 
@@ -27,7 +29,8 @@ Onyx.connectWithoutView({
     key: ONYXKEYS.IS_LOADING_APP,
     callback: (value) => {
         isLoadingApp = value ?? true;
-        onSessionOrLoadingAppChanged(session, isLoadingApp);
+        onMigratedUserWelcomeModalSessionOrLoadingAppChanged(session, isLoadingApp);
+        onAIFeaturesPromoSessionOrLoadingAppChanged(session, isLoadingApp);
     },
 });
 
@@ -103,5 +106,6 @@ function clearGuards(): void {
 
 registerGuard(OnboardingGuard);
 registerGuard(MigratedUserWelcomeModalGuard);
+registerGuard(AIFeaturesPromoGuard);
 
 export {registerGuard, createGuardContext, evaluateGuards, getRegisteredGuards, clearGuards};
diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts
index 962d7b5c59fb..ea703901268c 100644
--- a/src/libs/Navigation/linkingConfig/config.ts
+++ b/src/libs/Navigation/linkingConfig/config.ts
@@ -53,6 +53,15 @@ const config: LinkingOptions<RootNavigatorParamList>['config'] = {
             },
         },
 
+        [NAVIGATORS.AI_FEATURES_PROMO_MODAL_NAVIGATOR]: {
+            screens: {
+                [SCREENS.AI_FEATURES_PROMO_MODAL.ROOT]: {
+                    path: ROUTES.AI_FEATURES_PROMO_MODAL,
+                    exact: true,
+                },
+            },
+        },
+
         [NAVIGATORS.TEST_DRIVE_MODAL_NAVIGATOR]: {
             screens: {
                 [SCREENS.TEST_DRIVE_MODAL.ROOT]: {
diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts
index b69da1026616..4cfaa093c44f 100644
--- a/src/libs/Navigation/types.ts
+++ b/src/libs/Navigation/types.ts
@@ -2931,6 +2931,10 @@ type MigratedUserModalNavigatorParamList = {
     [SCREENS.MIGRATED_USER_WELCOME_MODAL.ROOT]: undefined;
 };
 
+type AIFeaturesPromoModalNavigatorParamList = {
+    [SCREENS.AI_FEATURES_PROMO_MODAL.ROOT]: undefined;
+};
+
 type TestDriveModalNavigatorParamList = {
     [SCREENS.TEST_DRIVE_MODAL.ROOT]: {
         bossEmail?: string;
@@ -3105,6 +3109,7 @@ type AuthScreensParamList = SharedScreensParamList &
         [NAVIGATORS.ONBOARDING_MODAL_NAVIGATOR]: NavigatorScreenParams<OnboardingModalNavigatorParamList>;
         [NAVIGATORS.FEATURE_TRAINING_MODAL_NAVIGATOR]: NavigatorScreenParams<FeatureTrainingNavigatorParamList>;
         [NAVIGATORS.MIGRATED_USER_MODAL_NAVIGATOR]: NavigatorScreenParams<MigratedUserModalNavigatorParamList>;
+        [NAVIGATORS.AI_FEATURES_PROMO_MODAL_NAVIGATOR]: NavigatorScreenParams<AIFeaturesPromoModalNavigatorParamList>;
         [NAVIGATORS.TEST_DRIVE_MODAL_NAVIGATOR]: NavigatorScreenParams<TestDriveModalNavigatorParamList>;
         [NAVIGATORS.TEST_DRIVE_DEMO_NAVIGATOR]: NavigatorScreenParams<TestDriveDemoNavigatorParamList>;
         [SCREENS.CONNECTION_COMPLETE]: undefined;
@@ -3386,6 +3391,7 @@ export type {
     WorkspaceSplitNavigatorParamList,
     WorkspaceNavigatorParamList,
     MigratedUserModalNavigatorParamList,
+    AIFeaturesPromoModalNavigatorParamList,
     WorkspaceConfirmationNavigatorParamList,
     WorkspaceDuplicateNavigatorParamList,
     PolicyCopySettingsNavigatorParamList,
diff --git a/src/styles/index.ts b/src/styles/index.ts
index d6ee11a23678..94b55443fb79 100644
--- a/src/styles/index.ts
+++ b/src/styles/index.ts
@@ -5890,6 +5890,12 @@ const staticStyles = (theme: ThemeColors) =>
             borderTopRightRadius: variables.componentBorderRadiusLarge,
         },
 
+        featureTrainingModalNavButtons: {
+            flexDirection: 'row',
+            justifyContent: 'space-between',
+            gap: variables.spacing2,
+        },
+
         twoColumnLayoutCol: {
             flexGrow: 1,
             flexShrink: 1,
diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts
index bf057c634fd8..cbcb7e399dde 100644
--- a/src/styles/utils/index.ts
+++ b/src/styles/utils/index.ts
@@ -2112,6 +2112,15 @@ const createStyleUtils = (theme: ThemeColors, styles: ThemeStyles) => ({
             styleObj[key] = null;
             return styleObj;
         }, {} as Nullable<K>) as K,
+    getFeatureTrainingCarouselDotStyle: (size: number, color: string, isActive: boolean): ViewStyle => ({
+        width: size,
+        height: size,
+        borderRadius: size / 2,
+        marginHorizontal: size,
+        backgroundColor: color,
+        opacity: isActive ? 1 : 0.3,
+    }),
+
     getScrollableFeatureTrainingModalStyles: (
         insets: EdgeInsets,
         isKeyboardOpen = false,
diff --git a/src/styles/variables.ts b/src/styles/variables.ts
index 762b49859251..dcd41e4662b6 100644
--- a/src/styles/variables.ts
+++ b/src/styles/variables.ts
@@ -278,6 +278,7 @@ export default {
     photoUploadPopoverWidth: 335,
     featureTrainingModalWidth: 500,
     onboardingModalWidth: 640,
+    aiFeaturesPromoModalWidth: 440,
     holdEducationModalWidth: 400,
     changePolicyEducationModalWidth: 400,
     wideConfirmModalWidth: 400,
diff --git a/src/types/onyx/DismissedProductTraining.ts b/src/types/onyx/DismissedProductTraining.ts
index 958cb35a1650..904da3d460a8 100644
--- a/src/types/onyx/DismissedProductTraining.ts
+++ b/src/types/onyx/DismissedProductTraining.ts
@@ -31,6 +31,11 @@ type DismissedProductTraining = {
      */
     [CONST.MIGRATED_USER_WELCOME_MODAL]: DismissedProductTrainingElement;
 
+    /**
+     * When user dismisses the AI features promo modal, we store the timestamp here.
+     */
+    [CONST.AI_FEATURES_PROMO_MODAL]: DismissedProductTrainingElement;
+
     // TODO: CONCIERGE_LHN_GBR tooltip will be replaced by a tooltip in the #admins room
     // https://github.com/Expensify/App/issues/57045#issuecomment-2701455668
     /**

From 6b39918c53062442edcb313b3d2ac5161f91afca Mon Sep 17 00:00:00 2001
From: gijoe0295 <153004152+gijoe0295@users.noreply.github.com>
Date: Fri, 19 Jun 2026 13:59:08 +0700
Subject: [PATCH 02/35] Fix search router opens/closes when AI promo modal is
 up

`openSearchRouter` was pushing the `{isSearchModalOpen: true}` history entry
synchronously, before `close()` finished dismissing any pre-existing modals.
When the AI promo modal closed it ran `Navigation.goBack()` (default
behavior of `FeatureTrainingModal` via `shouldGoBack=true`), which popped
the just-pushed history entry. The resulting `popstate` event fired before
the close callback finished, immediately marking the router as closed.

Move the `pushState` inside the `close()` callback so the history entry is
added only after pre-existing modals' close paths (including any history-
mutating `goBack`) have settled.

Fixes https://github.com/Expensify/App/issues/93964

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 .../Search/SearchRouter/SearchRouterContext.tsx       | 11 ++++++++---
 1 file changed, 8 insertions(+), 3 deletions(-)

diff --git a/src/components/Search/SearchRouter/SearchRouterContext.tsx b/src/components/Search/SearchRouter/SearchRouterContext.tsx
index 165c638a3475..3376618195fb 100644
--- a/src/components/Search/SearchRouter/SearchRouterContext.tsx
+++ b/src/components/Search/SearchRouter/SearchRouterContext.tsx
@@ -97,9 +97,6 @@ function SearchRouterContextProvider({children}: ChildrenProps) {
 
     const openSearchRouter = (query?: string) => {
         pendingRouterQuery = query ?? '';
-        if (isBrowserWithHistory) {
-            window.history.pushState({isSearchModalOpen: true} satisfies HistoryState, '');
-        }
         startSpan(CONST.TELEMETRY.SPAN_SEARCH_ROUTER_MODAL_CLOSE_WAIT, {
             name: CONST.TELEMETRY.SPAN_SEARCH_ROUTER_MODAL_CLOSE_WAIT,
             op: 'ui.modal.wait',
@@ -108,6 +105,14 @@ function SearchRouterContextProvider({children}: ChildrenProps) {
         close(
             () => {
                 endSpan(CONST.TELEMETRY.SPAN_SEARCH_ROUTER_MODAL_CLOSE_WAIT);
+                // Push the history entry only after pre-existing modals have finished closing —
+                // some modal close paths (e.g. FeatureTrainingModal with shouldGoBack=true) fire
+                // Navigation.goBack(), which pops history. If we pushState'd before close(),
+                // that pop would consume our entry and the resulting popstate listener would
+                // immediately mark the search router as closed.
+                if (isBrowserWithHistory) {
+                    window.history.pushState({isSearchModalOpen: true} satisfies HistoryState, '');
+                }
                 startListRenderSpan();
                 openSearch(setIsSearchRouterDisplayed);
                 searchRouterDisplayedRef.current = true;

From db3dc2a4ffd37cdee5d22ba96a9b149867c773af Mon Sep 17 00:00:00 2001
From: gijoe0295 <153004152+gijoe0295@users.noreply.github.com>
Date: Fri, 19 Jun 2026 14:02:49 +0700
Subject: [PATCH 03/35] Fix AI promo modal clipping action buttons in landscape

The carousel path bypassed the ScrollView Wrapper that the single-page
path of FeatureTrainingModal uses, so in landscape (where the modal is
hard-capped at 75% of window height via MODAL_MAX_HEIGHT_TO_WINDOW_HEIGHT_RATIO_LANDSCAPE_MODE)
there was no scroller to make the buttons reachable when the illustration
consumed most of the cap.

Lift the Wrapper (ScrollView when shouldUseScrollView, else View) out so
both branches share it, and remove the now-redundant landscape maxHeight
and wrapperStyles from FeatureTrainingModalCarouselBody. Also drop
`isCarousel` from the keyboard scroll-to-end early-return so the carousel
benefits from the same keyboard behavior.

Fixes https://github.com/Expensify/App/issues/93968

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 .../FeatureTrainingModalCarouselBody.tsx      | 16 +--
 src/components/FeatureTrainingModal/index.tsx | 97 +++++++++----------
 2 files changed, 50 insertions(+), 63 deletions(-)

diff --git a/src/components/FeatureTrainingModal/FeatureTrainingModalCarouselBody.tsx b/src/components/FeatureTrainingModal/FeatureTrainingModalCarouselBody.tsx
index 5f2d0c0b0e1e..8e49ab64d720 100644
--- a/src/components/FeatureTrainingModal/FeatureTrainingModalCarouselBody.tsx
+++ b/src/components/FeatureTrainingModal/FeatureTrainingModalCarouselBody.tsx
@@ -1,6 +1,6 @@
 import React, {useEffect, useRef, useState} from 'react';
 import {FlatList, View} from 'react-native';
-import type {LayoutChangeEvent, FlatList as RNFlatList, StyleProp, ViewabilityConfig, ViewStyle, ViewToken} from 'react-native';
+import type {LayoutChangeEvent, FlatList as RNFlatList, ViewabilityConfig, ViewToken} from 'react-native';
 import Icon from '@components/Icon';
 import PressableWithFeedback from '@components/Pressable/PressableWithFeedback';
 import Tooltip from '@components/Tooltip';
@@ -10,8 +10,6 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout';
 import useStyleUtils from '@hooks/useStyleUtils';
 import useTheme from '@hooks/useTheme';
 import useThemeStyles from '@hooks/useThemeStyles';
-import useWindowDimensions from '@hooks/useWindowDimensions';
-import isInLandscapeModeUtil from '@libs/isInLandscapeMode';
 import variables from '@styles/variables';
 import CONST from '@src/CONST';
 import FeatureTrainingModalContent from './FeatureTrainingModalContent';
@@ -50,9 +48,6 @@ type FeatureTrainingModalCarouselBodyProps = Pick<
         /** Padding for the modal */
         modalPadding: number;
 
-        /** Styles for the wrapper */
-        wrapperStyles?: StyleProp<ViewStyle>;
-
         /** Whether the modal should be shown again */
         willShowAgain: boolean;
 
@@ -74,7 +69,6 @@ function FeatureTrainingModalCarouselBody({
     modalPadding,
     width = variables.featureTrainingModalWidth,
     titleStyles,
-    wrapperStyles,
     illustrationAspectRatio,
     illustrationInnerContainerStyle,
     illustrationOuterContainerStyle,
@@ -102,8 +96,6 @@ function FeatureTrainingModalCarouselBody({
     const {translate} = useLocalize();
     const expensifyIcons = useMemoizedLazyExpensifyIcons(['Close']);
     const {onboardingIsMediumOrLargerScreenWidth} = useResponsiveLayout();
-    const {windowHeight, windowWidth} = useWindowDimensions();
-    const isInLandscapeMode = isInLandscapeModeUtil(windowWidth, windowHeight);
 
     const [currentPage, setCurrentPage] = useState(0);
     const [carouselViewportWidth, setCarouselViewportWidth] = useState(0);
@@ -173,11 +165,7 @@ function FeatureTrainingModalCarouselBody({
 
     return (
         <View
-            style={[
-                onboardingIsMediumOrLargerScreenWidth && StyleUtils.getWidthStyle(width),
-                wrapperStyles,
-                isInLandscapeMode ? {maxHeight: windowHeight * CONST.MODAL_MAX_HEIGHT_TO_WINDOW_HEIGHT_RATIO_LANDSCAPE_MODE} : styles.mh100,
-            ]}
+            style={[onboardingIsMediumOrLargerScreenWidth && StyleUtils.getWidthStyle(width)]}
             onLayout={(e: LayoutChangeEvent) => {
                 const newWidth = e.nativeEvent.layout.width;
                 if (newWidth === carouselViewportWidth || newWidth <= 0) {
diff --git a/src/components/FeatureTrainingModal/index.tsx b/src/components/FeatureTrainingModal/index.tsx
index 6529182a1619..ccfb43ddf846 100644
--- a/src/components/FeatureTrainingModal/index.tsx
+++ b/src/components/FeatureTrainingModal/index.tsx
@@ -289,11 +289,11 @@ function FeatureTrainingModal({
 
     // Scrolls modal to the bottom when keyboard appears so the action buttons are visible.
     useEffect(() => {
-        if (contentHeight <= containerHeight || onboardingIsMediumOrLargerScreenWidth || !shouldUseScrollView || isCarousel) {
+        if (contentHeight <= containerHeight || onboardingIsMediumOrLargerScreenWidth || !shouldUseScrollView) {
             return;
         }
         scrollViewRef.current?.scrollToEnd({animated: false});
-    }, [contentHeight, containerHeight, onboardingIsMediumOrLargerScreenWidth, shouldUseScrollView, isCarousel]);
+    }, [contentHeight, containerHeight, onboardingIsMediumOrLargerScreenWidth, shouldUseScrollView]);
 
     const Wrapper = shouldUseScrollView ? ScrollView : View;
 
@@ -332,51 +332,50 @@ function FeatureTrainingModal({
             shouldDisableBottomSafeAreaPadding={shouldUseScrollView}
             shouldWrapModalChildrenInScrollViewIfBottomDockedInLandscapeMode={!shouldUseScrollView}
         >
-            {isCarousel ? (
-                <FeatureTrainingModalCarouselBody
-                    pages={pages}
-                    modalPadding={MODAL_PADDING}
-                    width={width}
-                    titleStyles={titleStyles}
-                    wrapperStyles={wrapperStyles.containerStyle}
-                    illustrationAspectRatio={illustrationAspectRatioProp}
-                    illustrationInnerContainerStyle={illustrationInnerContainerStyle}
-                    illustrationOuterContainerStyle={illustrationOuterContainerStyle}
-                    shouldRenderSVG={shouldRenderSVG}
-                    shouldRenderHTMLDescription={shouldRenderHTMLDescription}
-                    shouldShowDismissModalOption={shouldShowDismissModalOption}
-                    helpText={helpText}
-                    onHelp={onHelp}
-                    shouldCallOnHelpWhenModalHidden={shouldCallOnHelpWhenModalHidden}
-                    helpSentryLabel={helpSentryLabel}
-                    confirmSentryLabel={confirmSentryLabel}
-                    shouldShowConfirmationLoader={shouldShowConfirmationLoader}
-                    canConfirmWhileOffline={canConfirmWhileOffline}
-                    contentInnerContainerStyles={contentInnerContainerStyles}
-                    contentOuterContainerStyles={contentOuterContainerStyles}
-                    willShowAgain={willShowAgain}
-                    toggleWillShowAgain={toggleWillShowAgain}
-                    closeModal={closeModal}
-                    onConfirm={closeAndConfirmModal}
-                    onPageChange={onPageChange}
-                />
-            ) : (
-                <Wrapper
-                    scrollsToTop={false}
-                    style={[
-                        onboardingIsMediumOrLargerScreenWidth && StyleUtils.getWidthStyle(width),
-                        wrapperStyles.style,
-                        isInLandscapeMode ? {maxHeight: windowHeight * CONST.MODAL_MAX_HEIGHT_TO_WINDOW_HEIGHT_RATIO_LANDSCAPE_MODE} : styles.mh100,
-                    ]}
-                    contentContainerStyle={wrapperStyles.containerStyle}
-                    keyboardShouldPersistTaps={shouldUseScrollView ? 'handled' : undefined}
-                    ref={shouldUseScrollView ? scrollViewRef : undefined}
-                    onLayout={shouldUseScrollView ? (e: LayoutChangeEvent) => setContainerHeight(e.nativeEvent.layout.height) : undefined}
-                    onContentSizeChange={shouldUseScrollView ? (_w: number, h: number) => setContentHeight(h) : undefined}
-                    // Wrapper is either a View or ScrollView, which is also a View.
-                    // eslint-disable-next-line react/forbid-component-props
-                    fsClass={CONST.FULLSTORY.CLASS.UNMASK}
-                >
+            <Wrapper
+                scrollsToTop={false}
+                style={[
+                    onboardingIsMediumOrLargerScreenWidth && StyleUtils.getWidthStyle(width),
+                    wrapperStyles.style,
+                    isInLandscapeMode ? {maxHeight: windowHeight * CONST.MODAL_MAX_HEIGHT_TO_WINDOW_HEIGHT_RATIO_LANDSCAPE_MODE} : styles.mh100,
+                ]}
+                contentContainerStyle={wrapperStyles.containerStyle}
+                keyboardShouldPersistTaps={shouldUseScrollView ? 'handled' : undefined}
+                ref={shouldUseScrollView ? scrollViewRef : undefined}
+                onLayout={shouldUseScrollView ? (e: LayoutChangeEvent) => setContainerHeight(e.nativeEvent.layout.height) : undefined}
+                onContentSizeChange={shouldUseScrollView ? (_w: number, h: number) => setContentHeight(h) : undefined}
+                // Wrapper is either a View or ScrollView, which is also a View.
+                // eslint-disable-next-line react/forbid-component-props
+                fsClass={CONST.FULLSTORY.CLASS.UNMASK}
+            >
+                {isCarousel ? (
+                    <FeatureTrainingModalCarouselBody
+                        pages={pages}
+                        modalPadding={MODAL_PADDING}
+                        width={width}
+                        titleStyles={titleStyles}
+                        illustrationAspectRatio={illustrationAspectRatioProp}
+                        illustrationInnerContainerStyle={illustrationInnerContainerStyle}
+                        illustrationOuterContainerStyle={illustrationOuterContainerStyle}
+                        shouldRenderSVG={shouldRenderSVG}
+                        shouldRenderHTMLDescription={shouldRenderHTMLDescription}
+                        shouldShowDismissModalOption={shouldShowDismissModalOption}
+                        helpText={helpText}
+                        onHelp={onHelp}
+                        shouldCallOnHelpWhenModalHidden={shouldCallOnHelpWhenModalHidden}
+                        helpSentryLabel={helpSentryLabel}
+                        confirmSentryLabel={confirmSentryLabel}
+                        shouldShowConfirmationLoader={shouldShowConfirmationLoader}
+                        canConfirmWhileOffline={canConfirmWhileOffline}
+                        contentInnerContainerStyles={contentInnerContainerStyles}
+                        contentOuterContainerStyles={contentOuterContainerStyles}
+                        willShowAgain={willShowAgain}
+                        toggleWillShowAgain={toggleWillShowAgain}
+                        closeModal={closeModal}
+                        onConfirm={closeAndConfirmModal}
+                        onPageChange={onPageChange}
+                    />
+                ) : (
                     <FeatureTrainingModalBody
                         illustrationInnerContainerStyle={illustrationInnerContainerStyle}
                         illustrationOuterContainerStyle={illustrationOuterContainerStyle}
@@ -409,8 +408,8 @@ function FeatureTrainingModal({
                     >
                         {children}
                     </FeatureTrainingModalBody>
-                </Wrapper>
-            )}
+                )}
+            </Wrapper>
         </Modal>
     );
 }

From 0d5e20caaadf9ce795ddeb3047c70d686478e314 Mon Sep 17 00:00:00 2001
From: gijoe0295 <153004152+gijoe0295@users.noreply.github.com>
Date: Fri, 19 Jun 2026 14:03:30 +0700
Subject: [PATCH 04/35] Fix AI promo carousel rendering blank on mWeb
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

On narrow viewports (mWeb BOTTOM_DOCKED) the carousel's outer View had
no intrinsic content (everything inside is gated on `carouselViewportWidth > 0`)
and no width style, so inside the modal sheet's `fit-content` container
it collapsed to 0×0. `onLayout` never fired with a positive width, the
carousel never rendered, and the user saw the modal backdrop only.

Add `styles.w100` to the outer View on narrow screens so it stretches
to the sheet's full width and `onLayout` resolves immediately. The
explicit `getWidthStyle(width)` continues to apply on medium+ screens.

Fixes https://github.com/Expensify/App/issues/93969

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 .../FeatureTrainingModalCarouselBody.tsx                 | 9 ++++++++-
 1 file changed, 8 insertions(+), 1 deletion(-)

diff --git a/src/components/FeatureTrainingModal/FeatureTrainingModalCarouselBody.tsx b/src/components/FeatureTrainingModal/FeatureTrainingModalCarouselBody.tsx
index 8e49ab64d720..7909cdbd8eca 100644
--- a/src/components/FeatureTrainingModal/FeatureTrainingModalCarouselBody.tsx
+++ b/src/components/FeatureTrainingModal/FeatureTrainingModalCarouselBody.tsx
@@ -165,7 +165,14 @@ function FeatureTrainingModalCarouselBody({
 
     return (
         <View
-            style={[onboardingIsMediumOrLargerScreenWidth && StyleUtils.getWidthStyle(width)]}
+            // On narrow viewports (mWeb BOTTOM_DOCKED) the outer View has no intrinsic content
+            // (everything below is gated by `carouselViewportWidth > 0`), so without `w100` it
+            // collapses to 0×0 inside the `fit-content` modal sheet — `onLayout` then never
+            // fires with a positive width and the carousel never renders, leaving the modal
+            // backdrop visible with no content. `w100` makes the View stretch to the sheet's
+            // known full width and lets `onLayout` resolve immediately. On medium+ screens the
+            // explicit `getWidthStyle(width)` continues to apply.
+            style={[onboardingIsMediumOrLargerScreenWidth ? StyleUtils.getWidthStyle(width) : styles.w100]}
             onLayout={(e: LayoutChangeEvent) => {
                 const newWidth = e.nativeEvent.layout.width;
                 if (newWidth === carouselViewportWidth || newWidth <= 0) {

From 4e05ef2ca42ad46ded53e07a9261f100d4994915 Mon Sep 17 00:00:00 2001
From: gijoe0295 <153004152+gijoe0295@users.noreply.github.com>
Date: Fri, 19 Jun 2026 14:05:02 +0700
Subject: [PATCH 05/35] Fix AI promo carousel skipping middle page on fast
 swipe
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

react-native-web translates FlatList's `pagingEnabled` to CSS scroll-snap,
but it only applies `scroll-snap-align: start` to the scroller's direct
children. Our per-page <View> rendered inside `renderItem` is not a direct
child of the FlatList's internal scroll container, so the carousel ended
up with a single snap point. A fast fling on mWeb/desktop Chrome could
coast past page 2 of 3 onto the last page.

Add `scrollSnapAlign: 'start'` to each page wrapper via a web-gated style
so every page becomes a snap point. Add `disableIntervalMomentum`,
`snapToInterval`, and `decelerationRate="fast"` to the FlatList as defense
in depth on native — no-ops on web, but they arrest momentum precisely at
each page boundary on iOS/Android.

Fixes https://github.com/Expensify/App/issues/93970

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 .../FeatureTrainingModalCarouselBody.tsx      | 21 ++++++++++++++++---
 1 file changed, 18 insertions(+), 3 deletions(-)

diff --git a/src/components/FeatureTrainingModal/FeatureTrainingModalCarouselBody.tsx b/src/components/FeatureTrainingModal/FeatureTrainingModalCarouselBody.tsx
index 7909cdbd8eca..e66c3e4d01c0 100644
--- a/src/components/FeatureTrainingModal/FeatureTrainingModalCarouselBody.tsx
+++ b/src/components/FeatureTrainingModal/FeatureTrainingModalCarouselBody.tsx
@@ -1,6 +1,6 @@
 import React, {useEffect, useRef, useState} from 'react';
-import {FlatList, View} from 'react-native';
-import type {LayoutChangeEvent, FlatList as RNFlatList, ViewabilityConfig, ViewToken} from 'react-native';
+import {FlatList, Platform, View} from 'react-native';
+import type {LayoutChangeEvent, FlatList as RNFlatList, ViewabilityConfig, ViewStyle, ViewToken} from 'react-native';
 import Icon from '@components/Icon';
 import PressableWithFeedback from '@components/Pressable/PressableWithFeedback';
 import Tooltip from '@components/Tooltip';
@@ -24,6 +24,13 @@ const CAROUSEL_VIEWABILITY_CONFIG: ViewabilityConfig = {itemVisiblePercentThresh
 const CAROUSEL_DOT_SIZE = 6;
 const PAGINATION_DOTS_BOTTOM_OFFSET = 5;
 
+// react-native-web translates `pagingEnabled` to CSS scroll-snap, but `scroll-snap-align` only
+// lands on the ScrollView's direct children — our per-page View (inside `renderItem`) isn't a
+// direct child, so the FlatList ends up with a single snap point and fast flings skip pages.
+// Applying `scrollSnapAlign: 'start'` on each page wrapper turns every page into a snap point.
+// Native ignores this CSS-only key, so we still gate it on Platform.OS === 'web' for clarity.
+const WEB_CAROUSEL_PAGE_SNAP_STYLE: ViewStyle = Platform.OS === 'web' ? ({scrollSnapAlign: 'start'} as ViewStyle) : {};
+
 type FeatureTrainingModalCarouselBodyProps = Pick<
     BaseFeatureTrainingModalProps,
     | 'illustrationAspectRatio'
@@ -220,13 +227,21 @@ function FeatureTrainingModalCarouselBody({
                             keyExtractor={(_page, index) => `FeatureTrainingModalIllustration-${index}`}
                             horizontal
                             pagingEnabled
+                            // Defense-in-depth on native: `pagingEnabled` is honored by the
+                            // platform, but these props arrest momentum precisely at each page
+                            // so fast flings never coast over a middle page. On web they're
+                            // no-ops; `WEB_CAROUSEL_PAGE_SNAP_STYLE` below does the equivalent
+                            // work via CSS scroll-snap.
+                            disableIntervalMomentum
+                            snapToInterval={carouselViewportWidth}
+                            decelerationRate="fast"
                             showsHorizontalScrollIndicator={false}
                             keyboardShouldPersistTaps="handled"
                             viewabilityConfig={CAROUSEL_VIEWABILITY_CONFIG}
                             onViewableItemsChanged={onViewableItemsChanged}
                             getItemLayout={(_data, index) => ({length: carouselViewportWidth, offset: index * carouselViewportWidth, index})}
                             renderItem={({item: page, index}) => (
-                                <View style={{width: carouselViewportWidth}}>
+                                <View style={[{width: carouselViewportWidth}, WEB_CAROUSEL_PAGE_SNAP_STYLE]}>
                                     <FeatureTrainingModalIllustration
                                         illustrationAspectRatio={illustrationAspectRatio}
                                         illustrationInnerContainerStyle={illustrationInnerContainerStyle}

From e0d7fd38cdefc6b42d007ba9e82e1e21d6bdf0d6 Mon Sep 17 00:00:00 2001
From: gijoe0295 <153004152+gijoe0295@users.noreply.github.com>
Date: Fri, 19 Jun 2026 14:06:09 +0700
Subject: [PATCH 06/35] Suppress AI promo modal while acting as a delegate
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

`AIFeaturesPromoGuard` had no copilot/delegate awareness. When the host
user switched into another account via Copilot,
`clearOnyxForDelegateTransition` wiped Onyx using
`KEYS_TO_PRESERVE_DELEGATE_ACCESS` — which does NOT preserve
`NVP_DISMISSED_PRODUCT_TRAINING`. The guard then saw the dismissed NVP
as empty and re-prompted the host user's one-time AI promo for every
copilot transition.

Subscribe to `ONYXKEYS.ACCOUNT`, track `isActingAsDelegate` via
`isActingAsDelegateSelector`, and gate `isEligibleToShowAIFeaturesPromoModal`
on `!isActingAsDelegate`. Mirrors the same short-circuit in
`ProductTrainingContext`.

Fixes https://github.com/Expensify/App/issues/93973

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 .../Navigation/guards/AIFeaturesPromoGuard.ts    | 16 ++++++++++++++++
 1 file changed, 16 insertions(+)

diff --git a/src/libs/Navigation/guards/AIFeaturesPromoGuard.ts b/src/libs/Navigation/guards/AIFeaturesPromoGuard.ts
index 23915fbf6ed6..f0dd4d100f6f 100644
--- a/src/libs/Navigation/guards/AIFeaturesPromoGuard.ts
+++ b/src/libs/Navigation/guards/AIFeaturesPromoGuard.ts
@@ -1,5 +1,6 @@
 import type {NavigationAction, NavigationState, PartialState} from '@react-navigation/native';
 import {findFocusedRoute} from '@react-navigation/native';
+import {isActingAsDelegateSelector} from '@selectors/Account';
 import {hasCompletedGuidedSetupFlowSelector, tryNewDotOnyxSelector} from '@selectors/Onboarding';
 import Onyx from 'react-native-onyx';
 import type {OnyxEntry} from 'react-native-onyx';
@@ -28,6 +29,12 @@ let isTryNewDotLoaded = false;
 let onboarding: OnyxEntry<Onboarding>;
 let isOnboardingLoaded = false;
 
+// A copilot (delegate) session must never re-prompt the host user's one-time AI promo —
+// `clearOnyxForDelegateTransition` wipes most NVPs when switching accounts (NVP_DISMISSED_PRODUCT_TRAINING
+// is not in `KEYS_TO_PRESERVE_DELEGATE_ACCESS`), so without this gate the modal would re-show
+// for every copilot switch. Mirrors `ProductTrainingContext`'s `isActingAsDelegate` short-circuit.
+let isActingAsDelegate = false;
+
 let hasRedirectedToAIFeaturesPromoModal = false;
 let isWaitingForProtectedRoutes = false;
 
@@ -78,6 +85,7 @@ function isEligibleToShowAIFeaturesPromoModal(): boolean {
     return (
         !!session?.authToken &&
         !isLoadingApp &&
+        !isActingAsDelegate &&
         !hasRedirectedToAIFeaturesPromoModal &&
         isDismissedProductTrainingLoaded &&
         isTryNewDotLoaded &&
@@ -173,6 +181,14 @@ Onyx.connectWithoutView({
     },
 });
 
+Onyx.connectWithoutView({
+    key: ONYXKEYS.ACCOUNT,
+    callback: (value) => {
+        isActingAsDelegate = isActingAsDelegateSelector(value);
+        navigateToAIFeaturesPromoModalIfReady();
+    },
+});
+
 /**
  * Block navigation while the AI features promo modal is active (on top of the stack).
  * Mirrors the pattern from MigratedUserWelcomeModalGuard.

From f29182eec20d9f6d9e6359555c4970944c658075 Mon Sep 17 00:00:00 2001
From: gijoe0295 <153004152+gijoe0295@users.noreply.github.com>
Date: Fri, 19 Jun 2026 14:07:03 +0700
Subject: [PATCH 07/35] Allow external intents to navigate past the AI promo
 modal
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

`shouldBlockWhileModalActive` previously blocked every navigation action
except DISMISS_MODAL and GO_BACK while the AI promo modal was on top.
External top-level intents — native share targeting SHARE_MODAL_NAVIGATOR,
report deep-links arriving as RESET via getAdaptedStateFromPath — were
silently dropped, stranding the user on the modal until they manually
closed it.

The block's intent is only to prevent an in-app tab swipe from racing
the modal overlay. Switch the logic accordingly:

- Always allow DISMISS_MODAL / GO_BACK.
- Always allow RESET (deep links / share intents).
- For NAVIGATE / PUSH / REPLACE, only block when `payload.name` targets
  the AI promo navigator itself; targets pointing at any sibling
  navigator/screen proceed and the new screen overlays or replaces the
  modal naturally.

Fixes https://github.com/Expensify/App/issues/93976

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 .../Navigation/guards/AIFeaturesPromoGuard.ts | 42 +++++++++++++++----
 1 file changed, 34 insertions(+), 8 deletions(-)

diff --git a/src/libs/Navigation/guards/AIFeaturesPromoGuard.ts b/src/libs/Navigation/guards/AIFeaturesPromoGuard.ts
index f0dd4d100f6f..cfdb72079d64 100644
--- a/src/libs/Navigation/guards/AIFeaturesPromoGuard.ts
+++ b/src/libs/Navigation/guards/AIFeaturesPromoGuard.ts
@@ -191,16 +191,42 @@ Onyx.connectWithoutView({
 
 /**
  * Block navigation while the AI features promo modal is active (on top of the stack).
- * Mirrors the pattern from MigratedUserWelcomeModalGuard.
+ *
+ * The block exists only to keep an underlying in-app tab swipe from racing the modal overlay
+ * (the original use case mirrored from MigratedUserWelcomeModalGuard). It must NOT swallow
+ * top-level intents that come from outside the app — share intents and report deep-links arrive
+ * as `RESET` actions (via `getAdaptedStateFromPath`) or as `NAVIGATE`/`PUSH` actions whose
+ * `payload.name` is a sibling root-stack navigator (e.g. `SHARE_MODAL_NAVIGATOR`). Blocking
+ * those silently drops the navigation and strands the user on the modal.
  */
 function shouldBlockWhileModalActive(state: NavigationState, action: NavigationAction): boolean {
-    const isAllowedAction = action.type === CONST.NAVIGATION.ACTION_TYPE.DISMISS_MODAL || action.type === CONST.NAVIGATION.ACTION_TYPE.GO_BACK;
-    return (
-        hasRedirectedToAIFeaturesPromoModal &&
-        !isProductTrainingElementDismissed(CONST.AI_FEATURES_PROMO_MODAL, dismissedProductTraining) &&
-        state.routes.at(-1)?.name === NAVIGATORS.AI_FEATURES_PROMO_MODAL_NAVIGATOR &&
-        !isAllowedAction
-    );
+    if (
+        !hasRedirectedToAIFeaturesPromoModal ||
+        isProductTrainingElementDismissed(CONST.AI_FEATURES_PROMO_MODAL, dismissedProductTraining) ||
+        state.routes.at(-1)?.name !== NAVIGATORS.AI_FEATURES_PROMO_MODAL_NAVIGATOR
+    ) {
+        return false;
+    }
+
+    // Internal modal-close actions always allowed.
+    if (action.type === CONST.NAVIGATION.ACTION_TYPE.DISMISS_MODAL || action.type === CONST.NAVIGATION.ACTION_TYPE.GO_BACK) {
+        return false;
+    }
+
+    // RESET actions are how deep links / share intents enter the app — never block them.
+    if (action.type === 'RESET') {
+        return false;
+    }
+
+    // For NAVIGATE/PUSH/REPLACE actions, only block if the target lives inside the AI promo
+    // navigator (i.e. an in-modal sub-route). If the target is any other navigator/screen the
+    // navigation should proceed — the new screen will overlay or replace our modal naturally.
+    const targetName = (action.payload as {name?: string} | undefined)?.name;
+    if (targetName && targetName !== NAVIGATORS.AI_FEATURES_PROMO_MODAL_NAVIGATOR) {
+        return false;
+    }
+
+    return true;
 }
 
 /** Prevents redirect loops by detecting when we're already on or resetting to the modal. */

From 377ae5c09f5ca35d5e06c1932912eba827ab9f7d Mon Sep 17 00:00:00 2001
From: gijoe0295 <153004152+gijoe0295@users.noreply.github.com>
Date: Thu, 25 Jun 2026 18:29:58 +0700
Subject: [PATCH 08/35] Migrate AI promo modal to dynamic routes

---
 src/CONST/index.ts                            |  2 +-
 src/ROUTES.ts                                 |  5 ++++-
 src/SCREENS.ts                                |  2 +-
 .../Navigation/AppNavigator/AuthScreens.tsx   |  2 +-
 .../AIFeaturesPromoModalNavigator.tsx         | 21 +++++++++++--------
 .../Navigation/guards/AIFeaturesPromoGuard.ts | 14 +++++++++----
 src/libs/Navigation/linkingConfig/config.ts   |  5 +----
 src/libs/Navigation/types.ts                  |  2 +-
 8 files changed, 31 insertions(+), 22 deletions(-)

diff --git a/src/CONST/index.ts b/src/CONST/index.ts
index 2141ae105b76..895576d6ec66 100644
--- a/src/CONST/index.ts
+++ b/src/CONST/index.ts
@@ -7309,7 +7309,7 @@ const CONST = {
         SCREENS.SAML_SIGN_IN,
         SCREENS.VALIDATE_LOGIN,
         SCREENS.MIGRATED_USER_WELCOME_MODAL.DYNAMIC_ROOT,
-        SCREENS.AI_FEATURES_PROMO_MODAL.ROOT,
+        SCREENS.AI_FEATURES_PROMO_MODAL.DYNAMIC_ROOT,
         SCREENS.MONEY_REQUEST.STEP_SCAN,
         SCREENS.DOMAIN.MEMBERS_MOVE_TO_GROUP,
         ...Object.values(SCREENS.MULTIFACTOR_AUTHENTICATION),
diff --git a/src/ROUTES.ts b/src/ROUTES.ts
index 174e31688175..4438cad476ee 100644
--- a/src/ROUTES.ts
+++ b/src/ROUTES.ts
@@ -135,6 +135,10 @@ const DYNAMIC_ROUTES = {
         path: 'migrated-user-welcome',
         entryScreens: [SCREENS.HOME, SCREENS.INBOX, SCREENS.REPORT, SCREENS.SEARCH.ROOT, SCREENS.WORKSPACES_LIST, SCREENS.WORKSPACE.PROFILE, SCREENS.SETTINGS.ROOT],
     },
+    AI_FEATURES_PROMO_MODAL: {
+        path: 'ai-features-promo',
+        entryScreens: [SCREENS.HOME, SCREENS.INBOX, SCREENS.REPORT, SCREENS.SEARCH.ROOT, SCREENS.WORKSPACES_LIST, SCREENS.WORKSPACE.PROFILE, SCREENS.SETTINGS.ROOT],
+    },
     EXPENSE_LIMIT_TYPE_SELECTOR: {
         path: 'expense-limit-type',
         entryScreens: [SCREENS.WORKSPACE.DYNAMIC_CATEGORY_FLAG_AMOUNTS_OVER],
@@ -3497,7 +3501,6 @@ const ROUTES = {
     TEST_DRIVE_DEMO_ROOT: 'onboarding/test-drive/demo',
     AUTO_SUBMIT_MODAL_ROOT: '/auto-submit',
     WORKSPACE_CONFIRMATION_SUCCESS: 'workspace/confirmation/success',
-    AI_FEATURES_PROMO_MODAL: 'ai-features-promo',
 
     TRANSACTION_RECEIPT: {
         route: 'r/:reportID/transaction/:transactionID/receipt/:action?/:iouType?',
diff --git a/src/SCREENS.ts b/src/SCREENS.ts
index 9a1395944faf..22d3ca2c3438 100644
--- a/src/SCREENS.ts
+++ b/src/SCREENS.ts
@@ -976,7 +976,7 @@ const SCREENS = {
     },
 
     AI_FEATURES_PROMO_MODAL: {
-        ROOT: 'AIFeaturesPromoModal_Root',
+        DYNAMIC_ROOT: 'Dynamic_AIFeaturesPromoModal_Root',
     },
 
     TEST_DRIVE_DEMO: {
diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.tsx b/src/libs/Navigation/AppNavigator/AuthScreens.tsx
index d54ceadacf7a..1afa8b547ac2 100644
--- a/src/libs/Navigation/AppNavigator/AuthScreens.tsx
+++ b/src/libs/Navigation/AppNavigator/AuthScreens.tsx
@@ -308,7 +308,7 @@ function AuthScreens() {
                         />
                         <RootStack.Screen
                             name={NAVIGATORS.AI_FEATURES_PROMO_MODAL_NAVIGATOR}
-                            options={rootNavigatorScreenOptions.basicModalNavigator}
+                            options={rootNavigatorScreenOptions.centeredModalNavigator}
                             component={AIFeaturesPromoModalNavigator}
                         />
                         <RootStack.Screen
diff --git a/src/libs/Navigation/AppNavigator/Navigators/AIFeaturesPromoModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/AIFeaturesPromoModalNavigator.tsx
index 7ebec85bd9a8..3992779bf7a5 100644
--- a/src/libs/Navigation/AppNavigator/Navigators/AIFeaturesPromoModalNavigator.tsx
+++ b/src/libs/Navigation/AppNavigator/Navigators/AIFeaturesPromoModalNavigator.tsx
@@ -1,5 +1,4 @@
 import React from 'react';
-import {View} from 'react-native';
 import AIFeaturesPromoModal from '@components/AIFeaturesPromoModal';
 import NoDropZone from '@components/DragAndDrop/NoDropZone';
 import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator';
@@ -11,14 +10,18 @@ const Stack = createPlatformStackNavigator<AIFeaturesPromoModalNavigatorParamLis
 function AIFeaturesPromoModalNavigator() {
     return (
         <NoDropZone>
-            <View>
-                <Stack.Navigator screenOptions={{headerShown: false}}>
-                    <Stack.Screen
-                        name={SCREENS.AI_FEATURES_PROMO_MODAL.ROOT}
-                        component={AIFeaturesPromoModal}
-                    />
-                </Stack.Navigator>
-            </View>
+            <Stack.Navigator
+                screenOptions={{
+                    headerShown: false,
+                    web: {cardStyle: {backgroundColor: 'transparent'}},
+                    native: {contentStyle: {backgroundColor: 'transparent'}},
+                }}
+            >
+                <Stack.Screen
+                    name={SCREENS.AI_FEATURES_PROMO_MODAL.DYNAMIC_ROOT}
+                    component={AIFeaturesPromoModal}
+                />
+            </Stack.Navigator>
         </NoDropZone>
     );
 }
diff --git a/src/libs/Navigation/guards/AIFeaturesPromoGuard.ts b/src/libs/Navigation/guards/AIFeaturesPromoGuard.ts
index cfdb72079d64..8f285fef76b2 100644
--- a/src/libs/Navigation/guards/AIFeaturesPromoGuard.ts
+++ b/src/libs/Navigation/guards/AIFeaturesPromoGuard.ts
@@ -12,9 +12,11 @@ import isProductTrainingElementDismissed from '@libs/TooltipUtils';
 import CONST from '@src/CONST';
 import NAVIGATORS from '@src/NAVIGATORS';
 import ONYXKEYS from '@src/ONYXKEYS';
-import ROUTES from '@src/ROUTES';
+import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES';
+import type {Route} from '@src/ROUTES';
 import SCREENS from '@src/SCREENS';
 import type {DismissedProductTraining, Onboarding, Session} from '@src/types/onyx';
+import createDynamicRoute from '../helpers/dynamicRoutesUtils/createDynamicRoute';
 import type {GuardResult, NavigationGuard} from './types';
 
 let session: OnyxEntry<Session>;
@@ -96,6 +98,10 @@ function isEligibleToShowAIFeaturesPromoModal(): boolean {
     );
 }
 
+function getAIFeaturesPromoModalRoute(basePath?: string): Route {
+    return createDynamicRoute(DYNAMIC_ROUTES.AI_FEATURES_PROMO_MODAL.path, basePath ?? (Navigation.getActiveRoute() || ROUTES.HOME));
+}
+
 /**
  * Proactively navigate to the AI features promo modal when all conditions are met.
  */
@@ -123,7 +129,7 @@ function navigateToAIFeaturesPromoModalIfReady() {
                 }
                 Log.info('[AIFeaturesPromoGuard] Proactively navigating to AI features promo modal');
                 hasRedirectedToAIFeaturesPromoModal = true;
-                Navigation.navigate(ROUTES.AI_FEATURES_PROMO_MODAL);
+                Navigation.navigate(getAIFeaturesPromoModalRoute());
             });
         },
         waitForUpcomingTransition: true,
@@ -231,8 +237,8 @@ function shouldBlockWhileModalActive(state: NavigationState, action: NavigationA
 
 /** Prevents redirect loops by detecting when we're already on or resetting to the modal. */
 function isNavigatingToAIFeaturesPromoModal(state: NavigationState, action: NavigationAction): boolean {
-    const isOnModal = findFocusedRoute(state)?.name === SCREENS.AI_FEATURES_PROMO_MODAL.ROOT;
-    const isResettingToModal = action.type === 'RESET' && !!action.payload && findFocusedRoute(action.payload as NavigationState)?.name === SCREENS.AI_FEATURES_PROMO_MODAL.ROOT;
+    const isOnModal = findFocusedRoute(state)?.name === SCREENS.AI_FEATURES_PROMO_MODAL.DYNAMIC_ROOT;
+    const isResettingToModal = action.type === 'RESET' && !!action.payload && findFocusedRoute(action.payload as NavigationState)?.name === SCREENS.AI_FEATURES_PROMO_MODAL.DYNAMIC_ROOT;
 
     return isOnModal || isResettingToModal;
 }
diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts
index 03e33d1d214b..14e6524a7fd1 100644
--- a/src/libs/Navigation/linkingConfig/config.ts
+++ b/src/libs/Navigation/linkingConfig/config.ts
@@ -51,10 +51,7 @@ const config: LinkingOptions<RootNavigatorParamList>['config'] = {
         },
         [NAVIGATORS.AI_FEATURES_PROMO_MODAL_NAVIGATOR]: {
             screens: {
-                [SCREENS.AI_FEATURES_PROMO_MODAL.ROOT]: {
-                    path: ROUTES.AI_FEATURES_PROMO_MODAL,
-                    exact: true,
-                },
+                [SCREENS.AI_FEATURES_PROMO_MODAL.DYNAMIC_ROOT]: DYNAMIC_ROUTES.AI_FEATURES_PROMO_MODAL.path,
             },
         },
 
diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts
index d97f033325bb..b017d56ac6e5 100644
--- a/src/libs/Navigation/types.ts
+++ b/src/libs/Navigation/types.ts
@@ -2940,7 +2940,7 @@ type MigratedUserModalNavigatorParamList = {
 };
 
 type AIFeaturesPromoModalNavigatorParamList = {
-    [SCREENS.AI_FEATURES_PROMO_MODAL.ROOT]: undefined;
+    [SCREENS.AI_FEATURES_PROMO_MODAL.DYNAMIC_ROOT]: undefined;
 };
 
 type TestDriveDemoNavigatorParamList = {

From 6155a824eab65365d102a5ac3e26380b57522727 Mon Sep 17 00:00:00 2001
From: gijoe0295 <153004152+gijoe0295@users.noreply.github.com>
Date: Thu, 25 Jun 2026 23:19:07 +0700
Subject: [PATCH 09/35] extract types and refactor FeatureTrainingContent by
 breaking into smaller components

---
 src/components/AutoSubmitModal.tsx            |   2 +-
 src/components/FeatureTrainingContent.tsx     | 387 ------------------
 .../FeatureTrainingContent.tsx                | 141 +++++++
 ...ent.tsx => FeatureTrainingContentBody.tsx} |  34 +-
 ...=> FeatureTrainingContentIllustration.tsx} |  14 +-
 .../FeatureTrainingModalBody.tsx              |   4 +-
 .../FeatureTrainingModalCarouselBody.tsx      |   4 +-
 src/components/FeatureTrainingModal/index.tsx |   4 +-
 src/components/FeatureTrainingModal/types.ts  | 158 +++++++
 src/components/MigratedUserWelcomeModal.tsx   |   2 +-
 src/pages/ChangePolicyEducationalModal.tsx    |   2 +-
 src/pages/TrackTrainingPage.tsx               |   2 +-
 12 files changed, 326 insertions(+), 428 deletions(-)
 delete mode 100644 src/components/FeatureTrainingContent.tsx
 create mode 100644 src/components/FeatureTrainingModal/FeatureTrainingContent.tsx
 rename src/components/FeatureTrainingModal/{FeatureTrainingModalContent.tsx => FeatureTrainingContentBody.tsx} (80%)
 rename src/components/FeatureTrainingModal/{FeatureTrainingModalIllustration.tsx => FeatureTrainingContentIllustration.tsx} (94%)
 create mode 100644 src/components/FeatureTrainingModal/types.ts

diff --git a/src/components/AutoSubmitModal.tsx b/src/components/AutoSubmitModal.tsx
index 998b8f21bea9..c243878a14be 100644
--- a/src/components/AutoSubmitModal.tsx
+++ b/src/components/AutoSubmitModal.tsx
@@ -15,7 +15,7 @@ import CONST from '@src/CONST';
 import type {TranslationPaths} from '@src/languages/types';
 import ONYXKEYS from '@src/ONYXKEYS';
 import CenteredModalLayout from './CenteredModalLayout';
-import FeatureTrainingContent from './FeatureTrainingContent';
+import FeatureTrainingContent from './FeatureTrainingModal/FeatureTrainingContent';
 import Icon from './Icon';
 import Text from './Text';
 
diff --git a/src/components/FeatureTrainingContent.tsx b/src/components/FeatureTrainingContent.tsx
deleted file mode 100644
index aa11f5299517..000000000000
--- a/src/components/FeatureTrainingContent.tsx
+++ /dev/null
@@ -1,387 +0,0 @@
-import type {ImageContentFit} from 'expo-image';
-import type {SourceLoadEventPayload} from 'expo-video';
-import React, {useEffect, useRef, useState} from 'react';
-import {Image, View} from 'react-native';
-// eslint-disable-next-line no-restricted-imports -- type-only import from react-native
-import type {ImageResizeMode, ImageSourcePropType, LayoutChangeEvent, ScrollView as RNScrollView, StyleProp, TextStyle, ViewStyle} from 'react-native';
-import {GestureHandlerRootView} from 'react-native-gesture-handler';
-import type {MergeExclusive} from 'type-fest';
-import useKeyboardState from '@hooks/useKeyboardState';
-import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset';
-import useLocalize from '@hooks/useLocalize';
-import useNetwork from '@hooks/useNetwork';
-import useResponsiveLayout from '@hooks/useResponsiveLayout';
-import useSafeAreaInsets from '@hooks/useSafeAreaInsets';
-import useStyleUtils from '@hooks/useStyleUtils';
-import useThemeStyles from '@hooks/useThemeStyles';
-import useWindowDimensions from '@hooks/useWindowDimensions';
-import Accessibility from '@libs/Accessibility';
-import isInLandscapeModeUtil from '@libs/isInLandscapeMode';
-import {getIsOffline} from '@libs/NetworkState';
-import variables from '@styles/variables';
-import CONST from '@src/CONST';
-import type IconAsset from '@src/types/utils/IconAsset';
-import Button from './Button';
-import CheckboxWithLabel from './CheckboxWithLabel';
-import FormAlertWithSubmitButton from './FormAlertWithSubmitButton';
-import ImageSVG from './ImageSVG';
-import type ImageSVGProps from './ImageSVG/types';
-import Lottie from './Lottie';
-import LottieAnimations from './LottieAnimations';
-import type DotLottieAnimation from './LottieAnimations/types';
-import OfflineIndicator from './OfflineIndicator';
-import RenderHTML from './RenderHTML';
-import ScrollView from './ScrollView';
-import Text from './Text';
-import VideoPlayer from './VideoPlayer';
-
-const VIDEO_ASPECT_RATIO = 1280 / 960;
-
-const CONTENT_PADDING = variables.spacing2;
-
-type VideoStatus = 'video' | 'animation';
-
-type BaseFeatureTrainingContentProps = {
-    /** The aspect ratio to preserve for the icon, video or animation */
-    illustrationAspectRatio?: number;
-
-    /** Style for the inner container of the animation */
-    illustrationInnerContainerStyle?: StyleProp<ViewStyle>;
-
-    /** Style for the outer container of the animation */
-    illustrationOuterContainerStyle?: StyleProp<ViewStyle>;
-
-    /** Title for the modal */
-    title?: string | React.ReactNode;
-
-    /** Describe what is showing */
-    description?: string;
-
-    /** Secondary description rendered with additional space */
-    secondaryDescription?: string;
-
-    /** Style for the title */
-    titleStyles?: StyleProp<TextStyle>;
-
-    /** Whether to show `Don't show me this again` option */
-    shouldShowDismissModalOption?: boolean;
-
-    /** Text to show on primary button */
-    confirmText: string;
-
-    /** A callback to call when user confirms */
-    onConfirm?: (willShowAgain: boolean) => void;
-
-    /** A callback to call when content wants to close */
-    onClose?: () => void;
-
-    /** Called whenever the "don't show again" checkbox value changes */
-    onWillShowAgainChange?: (willShowAgain: boolean) => void;
-
-    /** Text to show on secondary button */
-    helpText?: string;
-
-    /** Link to navigate to when user wants to learn more */
-    onHelp?: () => void;
-
-    /** Styles for the content container */
-    contentInnerContainerStyles?: StyleProp<ViewStyle>;
-
-    /** Styles for the content outer container */
-    contentOuterContainerStyles?: StyleProp<ViewStyle>;
-
-    /** Children to show below title and description and above buttons */
-    children?: React.ReactNode;
-
-    /** Content width for wide layouts */
-    width?: number;
-
-    /** Whether the image is a SVG */
-    shouldRenderSVG?: boolean;
-
-    /** Whether the description is written in HTML */
-    shouldRenderHTMLDescription?: boolean;
-
-    /** Whether closing should happen on confirm */
-    shouldCloseOnConfirm?: boolean;
-
-    /** Whether the content is scrollable */
-    shouldUseScrollView?: boolean;
-
-    /** Whether to show a confirmation loading spinner */
-    shouldShowConfirmationLoader?: boolean;
-
-    /** Whether the user can confirm while offline */
-    canConfirmWhileOffline?: boolean;
-
-    /** Sentry label for the help/skip button */
-    helpSentryLabel?: string;
-
-    /** Sentry label for the confirm/submit button */
-    confirmSentryLabel?: string;
-};
-
-type FeatureTrainingContentVideoProps = {
-    /** Animation to show when video is unavailable */
-    animation?: DotLottieAnimation;
-
-    /** Additional styles for the animation */
-    animationStyle?: StyleProp<ViewStyle>;
-
-    /** URL for the video */
-    videoURL?: string;
-};
-
-type FeatureTrainingContentSVGProps = {
-    /** Expensicon for the page */
-    image: IconAsset;
-
-    /** Determines how the image should be resized to fit its container */
-    contentFitImage?: ImageContentFit;
-
-    /** The width of the image */
-    imageWidth?: ImageSVGProps['width'];
-
-    /** The height of the image */
-    imageHeight?: ImageSVGProps['height'];
-};
-
-type FeatureTrainingContentProps = BaseFeatureTrainingContentProps & MergeExclusive<FeatureTrainingContentVideoProps, FeatureTrainingContentSVGProps>;
-
-const LANDSCAPE_ILLUSTRATION_MAX_HEIGHT_TO_WINDOW_HEIGHT_RATIO = 0.7;
-
-/**
- * Once the device has been online, lock to 'video' permanently.
- * While it has never been online, show 'animation' as a fallback.
- */
-function useVideoStatus(): VideoStatus {
-    const [isLockedToVideo, setIsLockedToVideo] = useState(() => !getIsOffline());
-    const {isOffline} = useNetwork({
-        onReconnect: () => setIsLockedToVideo(true),
-    });
-
-    return isLockedToVideo || !isOffline ? 'video' : 'animation';
-}
-
-function FeatureTrainingContent({
-    animation,
-    animationStyle,
-    illustrationInnerContainerStyle,
-    illustrationOuterContainerStyle,
-    videoURL,
-    illustrationAspectRatio: illustrationAspectRatioProp,
-    image,
-    contentFitImage,
-    width = variables.featureTrainingModalWidth,
-    title = '',
-    description = '',
-    secondaryDescription = '',
-    titleStyles,
-    shouldShowDismissModalOption = false,
-    confirmText = '',
-    onConfirm,
-    onClose,
-    onWillShowAgainChange,
-    helpText = '',
-    onHelp,
-    children,
-    contentInnerContainerStyles,
-    contentOuterContainerStyles,
-    imageWidth,
-    imageHeight,
-    shouldRenderSVG = true,
-    shouldRenderHTMLDescription = false,
-    shouldCloseOnConfirm = true,
-    shouldUseScrollView: shouldUseScrollViewProp = false,
-    shouldShowConfirmationLoader = false,
-    canConfirmWhileOffline = true,
-    helpSentryLabel,
-    confirmSentryLabel,
-}: FeatureTrainingContentProps) {
-    const styles = useThemeStyles();
-    const StyleUtils = useStyleUtils();
-    const {translate} = useLocalize();
-    const isReduceMotionEnabled = Accessibility.useReducedMotion();
-    const illustrations = useMemoizedLazyIllustrations(['Hands']);
-    const {onboardingIsMediumOrLargerScreenWidth} = useResponsiveLayout();
-    const {windowHeight, windowWidth} = useWindowDimensions();
-    const [willShowAgain, setWillShowAgain] = useState(true);
-    const [illustrationAspectRatio, setIllustrationAspectRatio] = useState(illustrationAspectRatioProp ?? VIDEO_ASPECT_RATIO);
-    const {shouldUseNarrowLayout} = useResponsiveLayout();
-    const videoStatus = useVideoStatus();
-    const scrollViewRef = useRef<RNScrollView>(null);
-    const [containerHeight, setContainerHeight] = useState(0);
-    const [contentHeight, setContentHeight] = useState(0);
-    const insets = useSafeAreaInsets();
-    const {isKeyboardActive} = useKeyboardState();
-    const isInLandscapeMode = isInLandscapeModeUtil(windowWidth, windowHeight);
-
-    const shouldUseScrollView = shouldUseScrollViewProp || isInLandscapeMode;
-
-    const setAspectRatio = (event: SourceLoadEventPayload) => {
-        const track = event.availableVideoTracks.at(0);
-
-        if (!track) {
-            return;
-        }
-
-        setIllustrationAspectRatio(track.size.width / track.size.height);
-    };
-
-    const renderIllustration = () => {
-        const aspectRatio = illustrationAspectRatio || VIDEO_ASPECT_RATIO;
-
-        return (
-            <View style={[isInLandscapeMode ? styles.h100 : styles.w100, illustrationInnerContainerStyle, (!!videoURL || !!image) && {aspectRatio}]}>
-                {!!image &&
-                    (shouldRenderSVG ? (
-                        <ImageSVG
-                            src={image}
-                            contentFit={contentFitImage}
-                            width={imageWidth}
-                            height={imageHeight}
-                            testID={CONST.IMAGE_SVG_TEST_ID}
-                        />
-                    ) : (
-                        <Image
-                            accessibilityIgnoresInvertColors
-                            // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- IconAsset union narrowed by !shouldRenderSVG branch
-                            source={image as ImageSourcePropType}
-                            // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- ImageContentFit and ImageResizeMode are compatible
-                            resizeMode={contentFitImage as ImageResizeMode}
-                            style={styles.featureTrainingModalImage}
-                            testID={CONST.IMAGE_TEST_ID}
-                        />
-                    ))}
-                {!!videoURL && videoStatus === 'video' && (
-                    <GestureHandlerRootView>
-                        <VideoPlayer
-                            url={videoURL}
-                            videoPlayerStyle={[styles.onboardingVideoPlayer, {aspectRatio}]}
-                            onSourceLoaded={setAspectRatio}
-                            controlsStatus={CONST.VIDEO_PLAYER.CONTROLS_STATUS.HIDE}
-                            shouldUseControlsBottomMargin={false}
-                            shouldPlay
-                            isLooping
-                        />
-                    </GestureHandlerRootView>
-                )}
-                {((!videoURL && !image) || (!!videoURL && videoStatus === 'animation')) && (
-                    <View style={[styles.flex1, styles.alignItemsCenter, styles.justifyContentCenter, !!videoURL && {aspectRatio}, animationStyle]}>
-                        {isReduceMotionEnabled && (animation ?? LottieAnimations.Hands) === LottieAnimations.Hands ? (
-                            <ImageSVG
-                                src={illustrations.Hands}
-                                style={styles.h100}
-                            />
-                        ) : (
-                            <Lottie
-                                source={animation ?? LottieAnimations.Hands}
-                                style={styles.h100}
-                                webStyle={shouldUseNarrowLayout ? styles.h100 : undefined}
-                                autoPlay
-                                loop
-                            />
-                        )}
-                    </View>
-                )}
-            </View>
-        );
-    };
-
-    const toggleWillShowAgain = () => {
-        onWillShowAgainChange?.(!willShowAgain);
-        setWillShowAgain((prev) => !prev);
-    };
-
-    const handleConfirm = () => {
-        onConfirm?.(willShowAgain);
-        if (shouldCloseOnConfirm) {
-            onClose?.();
-        }
-    };
-
-    useEffect(() => {
-        if (contentHeight <= containerHeight || onboardingIsMediumOrLargerScreenWidth || !shouldUseScrollView) {
-            return;
-        }
-        scrollViewRef.current?.scrollToEnd({animated: false});
-    }, [contentHeight, containerHeight, onboardingIsMediumOrLargerScreenWidth, shouldUseScrollView]);
-
-    const Wrapper = shouldUseScrollView ? ScrollView : View;
-
-    const wrapperStyles = shouldUseScrollView ? StyleUtils.getScrollableFeatureTrainingModalStyles(insets, isKeyboardActive) : {};
-
-    return (
-        <Wrapper
-            scrollsToTop={false}
-            style={[
-                onboardingIsMediumOrLargerScreenWidth && StyleUtils.getWidthStyle(width),
-                wrapperStyles.style,
-                isInLandscapeMode ? {maxHeight: windowHeight * CONST.MODAL_MAX_HEIGHT_TO_WINDOW_HEIGHT_RATIO_LANDSCAPE_MODE} : styles.mh100,
-            ]}
-            contentContainerStyle={wrapperStyles.containerStyle}
-            keyboardShouldPersistTaps={shouldUseScrollView ? 'handled' : undefined}
-            ref={shouldUseScrollView ? scrollViewRef : undefined}
-            onLayout={shouldUseScrollView ? (e: LayoutChangeEvent) => setContainerHeight(e.nativeEvent.layout.height) : undefined}
-            onContentSizeChange={shouldUseScrollView ? (_w: number, h: number) => setContentHeight(h) : undefined}
-            // eslint-disable-next-line react/forbid-component-props -- fsClass is required for FullStory session masking
-            fsClass={CONST.FULLSTORY.CLASS.UNMASK}
-        >
-            <View
-                style={[
-                    onboardingIsMediumOrLargerScreenWidth ? {padding: CONTENT_PADDING} : {paddingHorizontal: CONTENT_PADDING},
-                    illustrationOuterContainerStyle,
-                    isInLandscapeMode ? [{maxHeight: windowHeight * LANDSCAPE_ILLUSTRATION_MAX_HEIGHT_TO_WINDOW_HEIGHT_RATIO}, styles.alignSelfCenter] : undefined,
-                ]}
-            >
-                {renderIllustration()}
-            </View>
-            <View style={[styles.mt5, styles.mh5, contentOuterContainerStyles]}>
-                {!!title && !!description && (
-                    <View style={[onboardingIsMediumOrLargerScreenWidth ? [styles.gap1, styles.mb8] : [shouldRenderHTMLDescription ? styles.mb5 : styles.mb10], contentInnerContainerStyles]}>
-                        {typeof title === 'string' ? <Text style={[styles.textHeadlineH1, titleStyles]}>{title}</Text> : title}
-                        {shouldRenderHTMLDescription ? (
-                            <View style={styles.mb2}>
-                                <RenderHTML html={description} />
-                            </View>
-                        ) : (
-                            <Text style={styles.textSupporting}>{description}</Text>
-                        )}
-                        {secondaryDescription.length > 0 && <Text style={[styles.textSupporting, styles.mt4]}>{secondaryDescription}</Text>}
-                        {children}
-                    </View>
-                )}
-                {shouldShowDismissModalOption && (
-                    <CheckboxWithLabel
-                        label={translate('featureTraining.doNotShowAgain')}
-                        accessibilityLabel={translate('featureTraining.doNotShowAgain')}
-                        style={[styles.mb5]}
-                        isChecked={!willShowAgain}
-                        onInputChange={toggleWillShowAgain}
-                    />
-                )}
-                {!!helpText && (
-                    <Button
-                        large
-                        style={[styles.mb3]}
-                        onPress={() => onHelp?.()}
-                        text={helpText}
-                        sentryLabel={helpSentryLabel}
-                    />
-                )}
-                <FormAlertWithSubmitButton
-                    onSubmit={handleConfirm}
-                    isLoading={shouldShowConfirmationLoader}
-                    buttonText={confirmText}
-                    enabledWhenOffline={canConfirmWhileOffline}
-                    sentryLabel={confirmSentryLabel}
-                />
-                {!canConfirmWhileOffline && <OfflineIndicator />}
-            </View>
-        </Wrapper>
-    );
-}
-
-export default FeatureTrainingContent;
-
-export type {FeatureTrainingContentProps};
diff --git a/src/components/FeatureTrainingModal/FeatureTrainingContent.tsx b/src/components/FeatureTrainingModal/FeatureTrainingContent.tsx
new file mode 100644
index 000000000000..d23d5d325d47
--- /dev/null
+++ b/src/components/FeatureTrainingModal/FeatureTrainingContent.tsx
@@ -0,0 +1,141 @@
+import React, {useEffect, useRef, useState} from 'react';
+import {View} from 'react-native';
+// eslint-disable-next-line no-restricted-imports -- type-only import from react-native
+import type {LayoutChangeEvent, ScrollView as RNScrollView} from 'react-native';
+import ScrollView from '@components/ScrollView';
+import useKeyboardState from '@hooks/useKeyboardState';
+import useResponsiveLayout from '@hooks/useResponsiveLayout';
+import useSafeAreaInsets from '@hooks/useSafeAreaInsets';
+import useStyleUtils from '@hooks/useStyleUtils';
+import useThemeStyles from '@hooks/useThemeStyles';
+import useWindowDimensions from '@hooks/useWindowDimensions';
+import isInLandscapeModeUtil from '@libs/isInLandscapeMode';
+import variables from '@styles/variables';
+import CONST from '@src/CONST';
+import FeatureTrainingContentBody from './FeatureTrainingContentBody';
+import FeatureTrainingContentIllustration from './FeatureTrainingContentIllustration';
+import type {FeatureTrainingContentProps} from './types';
+
+const CONTENT_PADDING = variables.spacing2;
+
+function FeatureTrainingContent({
+    illustrationInnerContainerStyle,
+    illustrationOuterContainerStyle,
+    illustrationAspectRatio: illustrationAspectRatioProp,
+    width = variables.featureTrainingModalWidth,
+    title = '',
+    subtitle = '',
+    description = '',
+    secondaryDescription = '',
+    titleStyles,
+    shouldShowDismissModalOption = false,
+    confirmText = '',
+    onConfirm,
+    onClose,
+    onWillShowAgainChange,
+    helpText = '',
+    onHelp,
+    children,
+    contentInnerContainerStyles,
+    contentOuterContainerStyles,
+    shouldRenderSVG = true,
+    shouldRenderHTMLDescription = false,
+    shouldCloseOnConfirm = true,
+    shouldUseScrollView: shouldUseScrollViewProp = false,
+    shouldShowConfirmationLoader = false,
+    canConfirmWhileOffline = true,
+    helpSentryLabel,
+    confirmSentryLabel,
+    ...props
+}: FeatureTrainingContentProps) {
+    const styles = useThemeStyles();
+    const StyleUtils = useStyleUtils();
+    const {onboardingIsMediumOrLargerScreenWidth} = useResponsiveLayout();
+    const {windowHeight, windowWidth} = useWindowDimensions();
+    const [willShowAgain, setWillShowAgain] = useState(true);
+    const scrollViewRef = useRef<RNScrollView>(null);
+    const [containerHeight, setContainerHeight] = useState(0);
+    const [contentHeight, setContentHeight] = useState(0);
+    const insets = useSafeAreaInsets();
+    const {isKeyboardActive} = useKeyboardState();
+    const isInLandscapeMode = isInLandscapeModeUtil(windowWidth, windowHeight);
+
+    const shouldUseScrollView = shouldUseScrollViewProp || isInLandscapeMode;
+
+    const toggleWillShowAgain = () => {
+        onWillShowAgainChange?.(!willShowAgain);
+        setWillShowAgain((prev) => !prev);
+    };
+
+    const handleConfirm = () => {
+        onConfirm?.(willShowAgain);
+        if (shouldCloseOnConfirm) {
+            onClose?.();
+        }
+    };
+
+    useEffect(() => {
+        if (contentHeight <= containerHeight || onboardingIsMediumOrLargerScreenWidth || !shouldUseScrollView) {
+            return;
+        }
+        scrollViewRef.current?.scrollToEnd({animated: false});
+    }, [contentHeight, containerHeight, onboardingIsMediumOrLargerScreenWidth, shouldUseScrollView]);
+
+    const Wrapper = shouldUseScrollView ? ScrollView : View;
+
+    const wrapperStyles = shouldUseScrollView ? StyleUtils.getScrollableFeatureTrainingModalStyles(insets, isKeyboardActive) : {};
+
+    return (
+        <Wrapper
+            scrollsToTop={false}
+            style={[
+                onboardingIsMediumOrLargerScreenWidth && StyleUtils.getWidthStyle(width),
+                wrapperStyles.style,
+                isInLandscapeMode ? {maxHeight: windowHeight * CONST.MODAL_MAX_HEIGHT_TO_WINDOW_HEIGHT_RATIO_LANDSCAPE_MODE} : styles.mh100,
+            ]}
+            contentContainerStyle={wrapperStyles.containerStyle}
+            keyboardShouldPersistTaps={shouldUseScrollView ? 'handled' : undefined}
+            ref={shouldUseScrollView ? scrollViewRef : undefined}
+            onLayout={shouldUseScrollView ? (e: LayoutChangeEvent) => setContainerHeight(e.nativeEvent.layout.height) : undefined}
+            onContentSizeChange={shouldUseScrollView ? (_w: number, h: number) => setContentHeight(h) : undefined}
+            // eslint-disable-next-line react/forbid-component-props -- fsClass is required for FullStory session masking
+            fsClass={CONST.FULLSTORY.CLASS.UNMASK}
+        >
+            <FeatureTrainingContentIllustration
+                illustrationAspectRatio={illustrationAspectRatioProp}
+                illustrationInnerContainerStyle={illustrationInnerContainerStyle}
+                illustrationOuterContainerStyle={illustrationOuterContainerStyle}
+                shouldRenderSVG={shouldRenderSVG}
+                modalPadding={CONTENT_PADDING}
+                {...props}
+            />
+            <FeatureTrainingContentBody
+                title={title}
+                subtitle={subtitle}
+                description={description}
+                secondaryDescription={secondaryDescription}
+                confirmText={confirmText}
+                helpText={helpText}
+                onHelp={onHelp}
+                helpSentryLabel={helpSentryLabel}
+                confirmSentryLabel={confirmSentryLabel}
+                shouldShowDismissModalOption={shouldShowDismissModalOption}
+                willShowAgain={willShowAgain}
+                toggleWillShowAgain={toggleWillShowAgain}
+                onConfirm={handleConfirm}
+                shouldShowConfirmationLoader={shouldShowConfirmationLoader}
+                canConfirmWhileOffline={canConfirmWhileOffline}
+                titleStyles={titleStyles}
+                contentInnerContainerStyles={contentInnerContainerStyles}
+                contentOuterContainerStyles={contentOuterContainerStyles}
+                shouldRenderHTMLDescription={shouldRenderHTMLDescription}
+            >
+                {children}
+            </FeatureTrainingContentBody>
+        </Wrapper>
+    );
+}
+
+export default FeatureTrainingContent;
+
+export type {FeatureTrainingContentProps};
diff --git a/src/components/FeatureTrainingModal/FeatureTrainingModalContent.tsx b/src/components/FeatureTrainingModal/FeatureTrainingContentBody.tsx
similarity index 80%
rename from src/components/FeatureTrainingModal/FeatureTrainingModalContent.tsx
rename to src/components/FeatureTrainingModal/FeatureTrainingContentBody.tsx
index 57d4f258a4b4..d660ceaedfee 100644
--- a/src/components/FeatureTrainingModal/FeatureTrainingModalContent.tsx
+++ b/src/components/FeatureTrainingModal/FeatureTrainingContentBody.tsx
@@ -7,13 +7,13 @@ import OfflineIndicator from '@components/OfflineIndicator';
 import useLocalize from '@hooks/useLocalize';
 import useThemeStyles from '@hooks/useThemeStyles';
 import FeatureTrainingModalTextContent from './FeatureTrainingModalTextContent';
-import type {FeatureTrainingModalContentProps as BaseFeatureTrainingModalContentProps, BaseFeatureTrainingModalProps} from './index';
+import type {FeatureTrainingContentBodyProps as BaseFeatureTrainingContentBodyProps, BaseFeatureTrainingContentProps} from './types';
 
 type FeatureTrainingModalContentProps = Pick<
-    BaseFeatureTrainingModalProps,
+    BaseFeatureTrainingContentProps,
     | 'helpText'
+    | 'onConfirm'
     | 'onHelp'
-    | 'shouldCallOnHelpWhenModalHidden'
     | 'helpSentryLabel'
     | 'confirmSentryLabel'
     | 'shouldShowDismissModalOption'
@@ -25,19 +25,13 @@ type FeatureTrainingModalContentProps = Pick<
     | 'shouldRenderHTMLDescription'
     | 'children'
 > &
-    BaseFeatureTrainingModalContentProps & {
+    BaseFeatureTrainingContentBodyProps & {
         /** Whether the modal should be shown again (drives the dismiss checkbox state) */
         willShowAgain: boolean;
 
         /** Callback when the "Don't show me this again" option is toggled */
         toggleWillShowAgain: () => void;
 
-        /** Callback to close the modal */
-        closeModal: (didPressHelpButton?: boolean) => void;
-
-        /** Callback when the user presses the confirm button */
-        confirmModal: () => void;
-
         /** Whether to render a Back button (carousel mode, non-first pages) */
         shouldShowBackButton?: boolean;
 
@@ -45,22 +39,20 @@ type FeatureTrainingModalContentProps = Pick<
         onBack?: () => void;
     };
 
-function FeatureTrainingModalContent({
+function FeatureTrainingContentBody({
     title = '',
     subtitle = '',
     description = '',
     secondaryDescription = '',
     confirmText,
+    onConfirm,
     helpText = '',
-    onHelp = () => {},
-    shouldCallOnHelpWhenModalHidden = false,
+    onHelp,
     helpSentryLabel,
     confirmSentryLabel,
     shouldShowDismissModalOption = false,
     willShowAgain,
     toggleWillShowAgain,
-    closeModal,
-    confirmModal,
     shouldShowBackButton = false,
     onBack,
     shouldShowConfirmationLoader = false,
@@ -100,13 +92,7 @@ function FeatureTrainingModalContent({
                 <Button
                     large
                     style={[styles.mb3]}
-                    onPress={() => {
-                        if (shouldCallOnHelpWhenModalHidden) {
-                            closeModal(true);
-                            return;
-                        }
-                        onHelp();
-                    }}
+                    onPress={onHelp}
                     text={helpText}
                     sentryLabel={helpSentryLabel}
                 />
@@ -122,7 +108,7 @@ function FeatureTrainingModalContent({
                     />
                 )}
                 <FormAlertWithSubmitButton
-                    onSubmit={confirmModal}
+                    onSubmit={() => onConfirm?.(willShowAgain)}
                     isLoading={shouldShowConfirmationLoader}
                     buttonText={confirmText}
                     enabledWhenOffline={canConfirmWhileOffline}
@@ -135,4 +121,4 @@ function FeatureTrainingModalContent({
     );
 }
 
-export default FeatureTrainingModalContent;
+export default FeatureTrainingContentBody;
diff --git a/src/components/FeatureTrainingModal/FeatureTrainingModalIllustration.tsx b/src/components/FeatureTrainingModal/FeatureTrainingContentIllustration.tsx
similarity index 94%
rename from src/components/FeatureTrainingModal/FeatureTrainingModalIllustration.tsx
rename to src/components/FeatureTrainingModal/FeatureTrainingContentIllustration.tsx
index 52aa025ea005..89f8dd10fa20 100644
--- a/src/components/FeatureTrainingModal/FeatureTrainingModalIllustration.tsx
+++ b/src/components/FeatureTrainingModal/FeatureTrainingContentIllustration.tsx
@@ -16,7 +16,7 @@ import useWindowDimensions from '@hooks/useWindowDimensions';
 import Accessibility from '@libs/Accessibility';
 import isInLandscapeModeUtil from '@libs/isInLandscapeMode';
 import CONST from '@src/CONST';
-import type {FeatureTrainingModalIllustrationProps as BaseFeatureTrainingModalIllustrationProps, BaseFeatureTrainingModalProps} from './index';
+import type {FeatureTrainingContentIllustrationProps as BaseFeatureTrainingContentIllustrationProps, BaseFeatureTrainingContentProps} from './types';
 
 // Aspect ratio and height of the video.
 // Useful before video loads to reserve space.
@@ -26,11 +26,11 @@ const LANDSCAPE_ILLUSTRATION_MAX_HEIGHT_TO_WINDOW_HEIGHT_RATIO = 0.7;
 
 type VideoStatus = 'video' | 'animation';
 
-type FeatureTrainingModalIllustrationProps = Pick<
-    BaseFeatureTrainingModalProps,
+type FeatureTrainingContentIllustrationProps = Pick<
+    BaseFeatureTrainingContentProps,
     'shouldRenderSVG' | 'illustrationAspectRatio' | 'illustrationInnerContainerStyle' | 'illustrationOuterContainerStyle'
 > &
-    BaseFeatureTrainingModalIllustrationProps & {
+    BaseFeatureTrainingContentIllustrationProps & {
         /** Padding for the modal */
         modalPadding: number;
 
@@ -41,7 +41,7 @@ type FeatureTrainingModalIllustrationProps = Pick<
         isCarousel?: boolean;
     };
 
-function FeatureTrainingModalIllustration({
+function FeatureTrainingContentIllustration({
     animation,
     animationStyle,
     videoURL,
@@ -56,7 +56,7 @@ function FeatureTrainingModalIllustration({
     modalPadding,
     isFocused = true,
     isCarousel = false,
-}: FeatureTrainingModalIllustrationProps) {
+}: FeatureTrainingContentIllustrationProps) {
     const styles = useThemeStyles();
     const isReduceMotionEnabled = Accessibility.useReducedMotion();
     const illustrations = useMemoizedLazyIllustrations(['Hands']);
@@ -176,4 +176,4 @@ function FeatureTrainingModalIllustration({
     );
 }
 
-export default FeatureTrainingModalIllustration;
+export default FeatureTrainingContentIllustration;
diff --git a/src/components/FeatureTrainingModal/FeatureTrainingModalBody.tsx b/src/components/FeatureTrainingModal/FeatureTrainingModalBody.tsx
index 8bbcc63c56d1..64a9d3de0896 100644
--- a/src/components/FeatureTrainingModal/FeatureTrainingModalBody.tsx
+++ b/src/components/FeatureTrainingModal/FeatureTrainingModalBody.tsx
@@ -2,8 +2,8 @@ import React from 'react';
 import {View} from 'react-native';
 import useResponsiveLayout from '@hooks/useResponsiveLayout';
 import useStyleUtils from '@hooks/useStyleUtils';
-import FeatureTrainingModalContent from './FeatureTrainingModalContent';
-import FeatureTrainingModalIllustration from './FeatureTrainingModalIllustration';
+import FeatureTrainingModalContent from './FeatureTrainingContentBody';
+import FeatureTrainingModalIllustration from './FeatureTrainingContentIllustration';
 import type {BaseFeatureTrainingModalProps, FeatureTrainingModalPageProps} from './index';
 
 type FeatureTrainingModalBodyProps = BaseFeatureTrainingModalProps &
diff --git a/src/components/FeatureTrainingModal/FeatureTrainingModalCarouselBody.tsx b/src/components/FeatureTrainingModal/FeatureTrainingModalCarouselBody.tsx
index e66c3e4d01c0..0a8e2b603dd2 100644
--- a/src/components/FeatureTrainingModal/FeatureTrainingModalCarouselBody.tsx
+++ b/src/components/FeatureTrainingModal/FeatureTrainingModalCarouselBody.tsx
@@ -12,8 +12,8 @@ import useTheme from '@hooks/useTheme';
 import useThemeStyles from '@hooks/useThemeStyles';
 import variables from '@styles/variables';
 import CONST from '@src/CONST';
-import FeatureTrainingModalContent from './FeatureTrainingModalContent';
-import FeatureTrainingModalIllustration from './FeatureTrainingModalIllustration';
+import FeatureTrainingModalContent from './FeatureTrainingContentBody';
+import FeatureTrainingModalIllustration from './FeatureTrainingContentIllustration';
 import FeatureTrainingModalTextContent from './FeatureTrainingModalTextContent';
 import type {BaseFeatureTrainingModalProps, FeatureTrainingModalCarouselProps, FeatureTrainingModalPageProps} from './index';
 
diff --git a/src/components/FeatureTrainingModal/index.tsx b/src/components/FeatureTrainingModal/index.tsx
index e5f058eda17a..2b159cba3d71 100644
--- a/src/components/FeatureTrainingModal/index.tsx
+++ b/src/components/FeatureTrainingModal/index.tsx
@@ -1,5 +1,6 @@
 import React, {useEffect, useRef, useState} from 'react';
 import type {ViewStyle} from 'react-native';
+import Modal from '@components/Modal';
 import useResponsiveLayout from '@hooks/useResponsiveLayout';
 import useThemeStyles from '@hooks/useThemeStyles';
 import useWindowDimensions from '@hooks/useWindowDimensions';
@@ -9,9 +10,8 @@ import Navigation from '@libs/Navigation/Navigation';
 import TransitionTracker from '@libs/Navigation/TransitionTracker';
 import variables from '@styles/variables';
 import CONST from '@src/CONST';
-import type {FeatureTrainingContentProps} from './FeatureTrainingContent';
 import FeatureTrainingContent from './FeatureTrainingContent';
-import Modal from './Modal';
+import type {FeatureTrainingContentProps} from './types';
 
 const MODAL_PADDING = variables.spacing2;
 
diff --git a/src/components/FeatureTrainingModal/types.ts b/src/components/FeatureTrainingModal/types.ts
new file mode 100644
index 000000000000..f7c46a421475
--- /dev/null
+++ b/src/components/FeatureTrainingModal/types.ts
@@ -0,0 +1,158 @@
+import type {ImageContentFit} from 'expo-image';
+import React from 'react';
+import type {StyleProp, TextStyle, ViewStyle} from 'react-native';
+import type {MergeExclusive} from 'type-fest';
+import type ImageSVGProps from '@components/ImageSVG/types';
+import type DotLottieAnimation from '@components/LottieAnimations/types';
+import type IconAsset from '@src/types/utils/IconAsset';
+
+type BaseFeatureTrainingContentProps = {
+    /** The aspect ratio to preserve for the icon, video or animation */
+    illustrationAspectRatio?: number;
+
+    /** Style for the inner container of the animation */
+    illustrationInnerContainerStyle?: StyleProp<ViewStyle>;
+
+    /** Style for the outer container of the animation */
+    illustrationOuterContainerStyle?: StyleProp<ViewStyle>;
+
+    /** Style for the title */
+    titleStyles?: StyleProp<TextStyle>;
+
+    /** Whether to show `Don't show me this again` option */
+    shouldShowDismissModalOption?: boolean;
+
+    /** A callback to call when user confirms the tutorial */
+    onConfirm?: (willShowAgain: boolean) => void;
+
+    /** A callback to call when modal closes */
+    onClose?: () => void;
+
+    /** Called whenever the "don't show again" checkbox value changes */
+    onWillShowAgainChange?: (willShowAgain: boolean) => void;
+
+    /** Text to show on secondary button */
+    helpText?: string;
+
+    /** Link to navigate to when user wants to learn more */
+    onHelp?: () => void;
+
+    /** Styles for the content container */
+    contentInnerContainerStyles?: StyleProp<ViewStyle>;
+
+    /** Styles for the content outer container */
+    contentOuterContainerStyles?: StyleProp<ViewStyle>;
+
+    /** Styles for the modal inner container */
+    modalInnerContainerStyle?: ViewStyle;
+
+    /** Children to show below title and description and above buttons (single-page mode only) */
+    children?: React.ReactNode;
+
+    /** Modal width */
+    width?: number;
+
+    /** Whether to disable the modal */
+    isModalDisabled?: boolean;
+
+    /** Whether the modal image is a SVG */
+    shouldRenderSVG?: boolean;
+
+    /** Whether the modal description is written in HTML */
+    shouldRenderHTMLDescription?: boolean;
+
+    /** Whether the modal will be closed on confirm */
+    shouldCloseOnConfirm?: boolean;
+
+    /** Whether the modal should avoid the keyboard */
+    avoidKeyboard?: boolean;
+
+    /** Whether the modal content is scrollable */
+    shouldUseScrollView?: boolean;
+
+    /** Whether the modal is displaying a confirmation loading spinner (useful when fetching data from API during confirmation) */
+    shouldShowConfirmationLoader?: boolean;
+
+    /** Whether the user can confirm the tutorial while offline */
+    canConfirmWhileOffline?: boolean;
+
+    /** Whether to navigate back when closing the modal */
+    shouldGoBack?: boolean;
+
+    /** Whether to call onHelp when modal is hidden completely */
+    shouldCallOnHelpWhenModalHidden?: boolean;
+
+    /** Sentry label for the help/skip button */
+    helpSentryLabel?: string;
+
+    /** Sentry label for the confirm/submit button */
+    confirmSentryLabel?: string;
+};
+
+type FeatureTrainingContentBodyProps = {
+    /** Title for the modal */
+    title?: string | React.ReactNode;
+
+    /** Subtitle for the modal */
+    subtitle?: string;
+
+    /** Describe what is showing */
+    description?: string;
+
+    /** Secondary description rendered with additional space */
+    secondaryDescription?: string;
+
+    /** Text to show on primary button */
+    confirmText: string;
+};
+
+type FeatureTrainingContentIllustrationVideoProps = {
+    /** Animation to show when video is unavailable. Useful when app is offline */
+    animation?: DotLottieAnimation;
+
+    /** Additional styles for the animation */
+    animationStyle?: StyleProp<ViewStyle>;
+
+    /** URL for the video */
+    videoURL?: string;
+};
+
+type FeatureTrainingContentIllustrationSVGProps = {
+    /** Expensicon for the page */
+    image: IconAsset;
+
+    /** Determines how the image should be resized to fit its container */
+    contentFitImage?: ImageContentFit;
+
+    /** The width of the image */
+    imageWidth?: ImageSVGProps['width'];
+
+    /** The height of the image */
+    imageHeight?: ImageSVGProps['height'];
+};
+
+// Illustration requires either an icon or a video/animation, but not both.
+type FeatureTrainingContentIllustrationProps = MergeExclusive<FeatureTrainingContentIllustrationVideoProps, FeatureTrainingContentIllustrationSVGProps>;
+
+type FeatureTrainingContentDataProps = FeatureTrainingContentIllustrationProps & FeatureTrainingContentBodyProps;
+
+// Single-page mode
+type FeatureTrainingContentProps = BaseFeatureTrainingContentProps & FeatureTrainingContentDataProps;
+
+// Carousel mode
+type FeatureTrainingCarouselProps = BaseFeatureTrainingContentProps & {
+    /** Renders a horizontal paging carousel */
+    pages: FeatureTrainingContentDataProps[];
+
+    /** Called when the user swipes to a different page. */
+    onPageChange?: (index: number) => void;
+};
+
+export type {
+    BaseFeatureTrainingContentProps,
+    FeatureTrainingContentBodyProps,
+    FeatureTrainingContentIllustrationProps,
+    FeatureTrainingContentDataProps,
+    FeatureTrainingContentProps,
+    FeatureTrainingCarouselProps,
+};
diff --git a/src/components/MigratedUserWelcomeModal.tsx b/src/components/MigratedUserWelcomeModal.tsx
index fd9c2d230dbe..2083d87c4686 100644
--- a/src/components/MigratedUserWelcomeModal.tsx
+++ b/src/components/MigratedUserWelcomeModal.tsx
@@ -19,7 +19,7 @@ import CONST from '@src/CONST';
 import ROUTES from '@src/ROUTES';
 import CenteredModalLayout from './CenteredModalLayout';
 import type {FeatureListItem} from './FeatureList';
-import FeatureTrainingContent from './FeatureTrainingContent';
+import FeatureTrainingContent from './FeatureTrainingModal/FeatureTrainingContent';
 import Icon from './Icon';
 import LottieAnimations from './LottieAnimations';
 import RenderHTML from './RenderHTML';
diff --git a/src/pages/ChangePolicyEducationalModal.tsx b/src/pages/ChangePolicyEducationalModal.tsx
index bc507a6c8136..6b8b1bdcd193 100644
--- a/src/pages/ChangePolicyEducationalModal.tsx
+++ b/src/pages/ChangePolicyEducationalModal.tsx
@@ -1,7 +1,7 @@
 import React from 'react';
 import CenteredModalLayout from '@components/CenteredModalLayout';
 import ChangeWorkspaceMenuSectionList from '@components/ChangeWorkspaceMenuSectionList';
-import FeatureTrainingContent from '@components/FeatureTrainingContent';
+import FeatureTrainingContent from '@components/FeatureTrainingModal/FeatureTrainingContent';
 import useBeforeRemove from '@hooks/useBeforeRemove';
 import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset';
 import useLocalize from '@hooks/useLocalize';
diff --git a/src/pages/TrackTrainingPage.tsx b/src/pages/TrackTrainingPage.tsx
index f91af8185dfd..ba6738143f76 100644
--- a/src/pages/TrackTrainingPage.tsx
+++ b/src/pages/TrackTrainingPage.tsx
@@ -1,6 +1,6 @@
 import React, {useRef} from 'react';
 import CenteredModalLayout from '@components/CenteredModalLayout';
-import FeatureTrainingContent from '@components/FeatureTrainingContent';
+import FeatureTrainingContent from '@components/FeatureTrainingModal/FeatureTrainingContent';
 import useBeforeRemove from '@hooks/useBeforeRemove';
 import useLocalize from '@hooks/useLocalize';
 import Navigation from '@libs/Navigation/Navigation';

From c9866c32d3860439c3758d1ecdced39a8b950a79 Mon Sep 17 00:00:00 2001
From: gijoe0295 <153004152+gijoe0295@users.noreply.github.com>
Date: Thu, 25 Jun 2026 23:36:58 +0700
Subject: [PATCH 10/35] refactor directories and rename files for clarity

---
 src/components/AIFeaturesPromoModal/index.tsx |   4 +-
 src/components/AutoSubmitModal.tsx            |   2 +-
 .../FeatureTrainingCarousel.tsx}              |   8 +-
 .../FeatureTrainingContent.tsx                |   0
 .../FeatureTrainingContentBody.tsx            |   6 +-
 .../FeatureTrainingContentBodyText.tsx}       |   4 +-
 .../FeatureTrainingContentIllustration.tsx    |   0
 .../FeatureTrainingContent/index.tsx          |   7 ++
 .../types.ts                                  |   0
 .../index.tsx => FeatureTrainingModal.tsx}    |   4 +-
 .../FeatureTrainingModalBody.tsx              | 110 ------------------
 src/components/MigratedUserWelcomeModal.tsx   |   2 +-
 src/pages/ChangePolicyEducationalModal.tsx    |   2 +-
 src/pages/TrackTrainingPage.tsx               |   2 +-
 14 files changed, 24 insertions(+), 127 deletions(-)
 rename src/components/{FeatureTrainingModal/FeatureTrainingModalCarouselBody.tsx => FeatureTrainingContent/FeatureTrainingCarousel.tsx} (98%)
 rename src/components/{FeatureTrainingModal => FeatureTrainingContent}/FeatureTrainingContent.tsx (100%)
 rename src/components/{FeatureTrainingModal => FeatureTrainingContent}/FeatureTrainingContentBody.tsx (96%)
 rename src/components/{FeatureTrainingModal/FeatureTrainingModalTextContent.tsx => FeatureTrainingContent/FeatureTrainingContentBodyText.tsx} (96%)
 rename src/components/{FeatureTrainingModal => FeatureTrainingContent}/FeatureTrainingContentIllustration.tsx (100%)
 create mode 100644 src/components/FeatureTrainingContent/index.tsx
 rename src/components/{FeatureTrainingModal => FeatureTrainingContent}/types.ts (100%)
 rename src/components/{FeatureTrainingModal/index.tsx => FeatureTrainingModal.tsx} (98%)
 delete mode 100644 src/components/FeatureTrainingModal/FeatureTrainingModalBody.tsx

diff --git a/src/components/AIFeaturesPromoModal/index.tsx b/src/components/AIFeaturesPromoModal/index.tsx
index a161b7688029..67dfe73aa637 100644
--- a/src/components/AIFeaturesPromoModal/index.tsx
+++ b/src/components/AIFeaturesPromoModal/index.tsx
@@ -1,8 +1,8 @@
 import React, {useRef} from 'react';
 import {View} from 'react-native';
 import Badge from '@components/Badge';
+import type {FeatureTrainingContentDataProps} from '@components/FeatureTrainingContent';
 import FeatureTrainingModal from '@components/FeatureTrainingModal';
-import type {FeatureTrainingModalPageProps} from '@components/FeatureTrainingModal';
 import LottieAnimations from '@components/LottieAnimations';
 import Text from '@components/Text';
 import useLocalize from '@hooks/useLocalize';
@@ -31,7 +31,7 @@ function AIFeaturesPromoModal() {
         </View>
     );
 
-    const pages: FeatureTrainingModalPageProps[] = [
+    const pages: FeatureTrainingContentDataProps[] = [
         {
             animation: LottieAnimations.SpendAnalysis,
             title: translate('aiFeaturesPromoModal.spendAnalysis.title'),
diff --git a/src/components/AutoSubmitModal.tsx b/src/components/AutoSubmitModal.tsx
index c243878a14be..998b8f21bea9 100644
--- a/src/components/AutoSubmitModal.tsx
+++ b/src/components/AutoSubmitModal.tsx
@@ -15,7 +15,7 @@ import CONST from '@src/CONST';
 import type {TranslationPaths} from '@src/languages/types';
 import ONYXKEYS from '@src/ONYXKEYS';
 import CenteredModalLayout from './CenteredModalLayout';
-import FeatureTrainingContent from './FeatureTrainingModal/FeatureTrainingContent';
+import FeatureTrainingContent from './FeatureTrainingContent';
 import Icon from './Icon';
 import Text from './Text';
 
diff --git a/src/components/FeatureTrainingModal/FeatureTrainingModalCarouselBody.tsx b/src/components/FeatureTrainingContent/FeatureTrainingCarousel.tsx
similarity index 98%
rename from src/components/FeatureTrainingModal/FeatureTrainingModalCarouselBody.tsx
rename to src/components/FeatureTrainingContent/FeatureTrainingCarousel.tsx
index 0a8e2b603dd2..fb94bc9dfb7b 100644
--- a/src/components/FeatureTrainingModal/FeatureTrainingModalCarouselBody.tsx
+++ b/src/components/FeatureTrainingContent/FeatureTrainingCarousel.tsx
@@ -13,8 +13,8 @@ import useThemeStyles from '@hooks/useThemeStyles';
 import variables from '@styles/variables';
 import CONST from '@src/CONST';
 import FeatureTrainingModalContent from './FeatureTrainingContentBody';
+import FeatureTrainingContentBodyText from './FeatureTrainingContentBodyText';
 import FeatureTrainingModalIllustration from './FeatureTrainingContentIllustration';
-import FeatureTrainingModalTextContent from './FeatureTrainingModalTextContent';
 import type {BaseFeatureTrainingModalProps, FeatureTrainingModalCarouselProps, FeatureTrainingModalPageProps} from './index';
 
 // A page is considered "viewable" — and `currentPage` updates — only once it occupies at least
@@ -71,7 +71,7 @@ type FeatureTrainingModalCarouselBodyProps = Pick<
         onPageChange?: (index: number) => void;
     };
 
-function FeatureTrainingModalCarouselBody({
+function FeatureTrainingCarousel({
     pages,
     modalPadding,
     width = variables.featureTrainingModalWidth,
@@ -204,7 +204,7 @@ function FeatureTrainingModalCarouselBody({
                             key={`FeatureTrainingModalCarousel-probe-${index}`}
                             style={styles.mh5}
                         >
-                            <FeatureTrainingModalTextContent
+                            <FeatureTrainingContentBodyText
                                 title={page.title}
                                 subtitle={page.subtitle}
                                 description={page.description}
@@ -309,4 +309,4 @@ function FeatureTrainingModalCarouselBody({
     );
 }
 
-export default FeatureTrainingModalCarouselBody;
+export default FeatureTrainingCarousel;
diff --git a/src/components/FeatureTrainingModal/FeatureTrainingContent.tsx b/src/components/FeatureTrainingContent/FeatureTrainingContent.tsx
similarity index 100%
rename from src/components/FeatureTrainingModal/FeatureTrainingContent.tsx
rename to src/components/FeatureTrainingContent/FeatureTrainingContent.tsx
diff --git a/src/components/FeatureTrainingModal/FeatureTrainingContentBody.tsx b/src/components/FeatureTrainingContent/FeatureTrainingContentBody.tsx
similarity index 96%
rename from src/components/FeatureTrainingModal/FeatureTrainingContentBody.tsx
rename to src/components/FeatureTrainingContent/FeatureTrainingContentBody.tsx
index d660ceaedfee..3dcb6367277d 100644
--- a/src/components/FeatureTrainingModal/FeatureTrainingContentBody.tsx
+++ b/src/components/FeatureTrainingContent/FeatureTrainingContentBody.tsx
@@ -6,7 +6,7 @@ import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton';
 import OfflineIndicator from '@components/OfflineIndicator';
 import useLocalize from '@hooks/useLocalize';
 import useThemeStyles from '@hooks/useThemeStyles';
-import FeatureTrainingModalTextContent from './FeatureTrainingModalTextContent';
+import FeatureTrainingContentBodyText from './FeatureTrainingContentBodyText';
 import type {FeatureTrainingContentBodyProps as BaseFeatureTrainingContentBodyProps, BaseFeatureTrainingContentProps} from './types';
 
 type FeatureTrainingModalContentProps = Pick<
@@ -68,7 +68,7 @@ function FeatureTrainingContentBody({
 
     return (
         <View style={[styles.mt5, styles.mh5, contentOuterContainerStyles]}>
-            <FeatureTrainingModalTextContent
+            <FeatureTrainingContentBodyText
                 title={title}
                 subtitle={subtitle}
                 description={description}
@@ -78,7 +78,7 @@ function FeatureTrainingContentBody({
                 shouldRenderHTMLDescription={shouldRenderHTMLDescription}
             >
                 {children}
-            </FeatureTrainingModalTextContent>
+            </FeatureTrainingContentBodyText>
             {shouldShowDismissModalOption && (
                 <CheckboxWithLabel
                     label={translate('featureTraining.doNotShowAgain')}
diff --git a/src/components/FeatureTrainingModal/FeatureTrainingModalTextContent.tsx b/src/components/FeatureTrainingContent/FeatureTrainingContentBodyText.tsx
similarity index 96%
rename from src/components/FeatureTrainingModal/FeatureTrainingModalTextContent.tsx
rename to src/components/FeatureTrainingContent/FeatureTrainingContentBodyText.tsx
index 512e3e3800d3..b0ad4d2c851c 100644
--- a/src/components/FeatureTrainingModal/FeatureTrainingModalTextContent.tsx
+++ b/src/components/FeatureTrainingContent/FeatureTrainingContentBodyText.tsx
@@ -35,7 +35,7 @@ type FeatureTrainingModalTextContentProps = {
     onLayout?: (event: LayoutChangeEvent) => void;
 };
 
-function FeatureTrainingModalTextContent({
+function FeatureTrainingContentBodyText({
     title = '',
     subtitle = '',
     description = '',
@@ -73,4 +73,4 @@ function FeatureTrainingModalTextContent({
     );
 }
 
-export default FeatureTrainingModalTextContent;
+export default FeatureTrainingContentBodyText;
diff --git a/src/components/FeatureTrainingModal/FeatureTrainingContentIllustration.tsx b/src/components/FeatureTrainingContent/FeatureTrainingContentIllustration.tsx
similarity index 100%
rename from src/components/FeatureTrainingModal/FeatureTrainingContentIllustration.tsx
rename to src/components/FeatureTrainingContent/FeatureTrainingContentIllustration.tsx
diff --git a/src/components/FeatureTrainingContent/index.tsx b/src/components/FeatureTrainingContent/index.tsx
new file mode 100644
index 000000000000..81b3f6a5649b
--- /dev/null
+++ b/src/components/FeatureTrainingContent/index.tsx
@@ -0,0 +1,7 @@
+import FeatureTrainingCarousel from './FeatureTrainingCarousel';
+import FeatureTrainingContent from './FeatureTrainingContent';
+import type {FeatureTrainingContentDataProps, FeatureTrainingContentProps} from './types';
+
+export default FeatureTrainingContent;
+export {FeatureTrainingCarousel};
+export type {FeatureTrainingContentProps, FeatureTrainingContentDataProps};
diff --git a/src/components/FeatureTrainingModal/types.ts b/src/components/FeatureTrainingContent/types.ts
similarity index 100%
rename from src/components/FeatureTrainingModal/types.ts
rename to src/components/FeatureTrainingContent/types.ts
diff --git a/src/components/FeatureTrainingModal/index.tsx b/src/components/FeatureTrainingModal.tsx
similarity index 98%
rename from src/components/FeatureTrainingModal/index.tsx
rename to src/components/FeatureTrainingModal.tsx
index 2b159cba3d71..e5f058eda17a 100644
--- a/src/components/FeatureTrainingModal/index.tsx
+++ b/src/components/FeatureTrainingModal.tsx
@@ -1,6 +1,5 @@
 import React, {useEffect, useRef, useState} from 'react';
 import type {ViewStyle} from 'react-native';
-import Modal from '@components/Modal';
 import useResponsiveLayout from '@hooks/useResponsiveLayout';
 import useThemeStyles from '@hooks/useThemeStyles';
 import useWindowDimensions from '@hooks/useWindowDimensions';
@@ -10,8 +9,9 @@ import Navigation from '@libs/Navigation/Navigation';
 import TransitionTracker from '@libs/Navigation/TransitionTracker';
 import variables from '@styles/variables';
 import CONST from '@src/CONST';
+import type {FeatureTrainingContentProps} from './FeatureTrainingContent';
 import FeatureTrainingContent from './FeatureTrainingContent';
-import type {FeatureTrainingContentProps} from './types';
+import Modal from './Modal';
 
 const MODAL_PADDING = variables.spacing2;
 
diff --git a/src/components/FeatureTrainingModal/FeatureTrainingModalBody.tsx b/src/components/FeatureTrainingModal/FeatureTrainingModalBody.tsx
deleted file mode 100644
index 64a9d3de0896..000000000000
--- a/src/components/FeatureTrainingModal/FeatureTrainingModalBody.tsx
+++ /dev/null
@@ -1,110 +0,0 @@
-import React from 'react';
-import {View} from 'react-native';
-import useResponsiveLayout from '@hooks/useResponsiveLayout';
-import useStyleUtils from '@hooks/useStyleUtils';
-import FeatureTrainingModalContent from './FeatureTrainingContentBody';
-import FeatureTrainingModalIllustration from './FeatureTrainingContentIllustration';
-import type {BaseFeatureTrainingModalProps, FeatureTrainingModalPageProps} from './index';
-
-type FeatureTrainingModalBodyProps = BaseFeatureTrainingModalProps &
-    FeatureTrainingModalPageProps & {
-        /** Padding for the modal */
-        modalPadding: number;
-
-        /** Whether the modal should be shown again */
-        willShowAgain: boolean;
-
-        /** A callback to call when the modal should be shown again */
-        toggleWillShowAgain: () => void;
-
-        /** A callback to call when we want to close the modal */
-        closeModal: (didPressHelpButton?: boolean) => void;
-
-        /** A callback to call when we want to close the modal and confirm */
-        confirmModal: () => void;
-
-        /** Whether to show the back button to navigate back to the previous page in carousel mode */
-        shouldShowBackButton?: boolean;
-
-        /** A callback to call when we want to navigate back to the previous page in carousel mode */
-        onBack?: () => void;
-    };
-
-function FeatureTrainingModalBody({
-    illustrationInnerContainerStyle,
-    illustrationOuterContainerStyle,
-    illustrationAspectRatio: illustrationAspectRatioProp,
-    width,
-    title = '',
-    subtitle = '',
-    description = '',
-    secondaryDescription = '',
-    titleStyles,
-    shouldShowDismissModalOption = false,
-    confirmText = '',
-    helpText = '',
-    onHelp = () => {},
-    children,
-    contentInnerContainerStyles,
-    contentOuterContainerStyles,
-    shouldRenderSVG = true,
-    shouldRenderHTMLDescription = false,
-    shouldShowConfirmationLoader = false,
-    canConfirmWhileOffline = true,
-    shouldCallOnHelpWhenModalHidden = false,
-    helpSentryLabel,
-    confirmSentryLabel,
-    modalPadding,
-    willShowAgain = true,
-    toggleWillShowAgain,
-    closeModal,
-    confirmModal,
-    shouldShowBackButton = false,
-    onBack,
-    ...props
-}: FeatureTrainingModalBodyProps) {
-    const StyleUtils = useStyleUtils();
-    const {onboardingIsMediumOrLargerScreenWidth} = useResponsiveLayout();
-
-    return (
-        <View style={width && onboardingIsMediumOrLargerScreenWidth ? StyleUtils.getWidthStyle(width) : undefined}>
-            <FeatureTrainingModalIllustration
-                illustrationAspectRatio={illustrationAspectRatioProp}
-                illustrationInnerContainerStyle={illustrationInnerContainerStyle}
-                illustrationOuterContainerStyle={illustrationOuterContainerStyle}
-                shouldRenderSVG={shouldRenderSVG}
-                modalPadding={modalPadding}
-                {...props}
-            />
-            <FeatureTrainingModalContent
-                title={title}
-                subtitle={subtitle}
-                description={description}
-                secondaryDescription={secondaryDescription}
-                confirmText={confirmText}
-                helpText={helpText}
-                onHelp={onHelp}
-                shouldCallOnHelpWhenModalHidden={shouldCallOnHelpWhenModalHidden}
-                helpSentryLabel={helpSentryLabel}
-                confirmSentryLabel={confirmSentryLabel}
-                shouldShowDismissModalOption={shouldShowDismissModalOption}
-                willShowAgain={willShowAgain}
-                toggleWillShowAgain={toggleWillShowAgain}
-                closeModal={closeModal}
-                confirmModal={confirmModal}
-                shouldShowBackButton={shouldShowBackButton}
-                onBack={onBack}
-                shouldShowConfirmationLoader={shouldShowConfirmationLoader}
-                canConfirmWhileOffline={canConfirmWhileOffline}
-                titleStyles={titleStyles}
-                contentInnerContainerStyles={contentInnerContainerStyles}
-                contentOuterContainerStyles={contentOuterContainerStyles}
-                shouldRenderHTMLDescription={shouldRenderHTMLDescription}
-            >
-                {children}
-            </FeatureTrainingModalContent>
-        </View>
-    );
-}
-
-export default FeatureTrainingModalBody;
diff --git a/src/components/MigratedUserWelcomeModal.tsx b/src/components/MigratedUserWelcomeModal.tsx
index 2083d87c4686..fd9c2d230dbe 100644
--- a/src/components/MigratedUserWelcomeModal.tsx
+++ b/src/components/MigratedUserWelcomeModal.tsx
@@ -19,7 +19,7 @@ import CONST from '@src/CONST';
 import ROUTES from '@src/ROUTES';
 import CenteredModalLayout from './CenteredModalLayout';
 import type {FeatureListItem} from './FeatureList';
-import FeatureTrainingContent from './FeatureTrainingModal/FeatureTrainingContent';
+import FeatureTrainingContent from './FeatureTrainingContent';
 import Icon from './Icon';
 import LottieAnimations from './LottieAnimations';
 import RenderHTML from './RenderHTML';
diff --git a/src/pages/ChangePolicyEducationalModal.tsx b/src/pages/ChangePolicyEducationalModal.tsx
index 6b8b1bdcd193..bc507a6c8136 100644
--- a/src/pages/ChangePolicyEducationalModal.tsx
+++ b/src/pages/ChangePolicyEducationalModal.tsx
@@ -1,7 +1,7 @@
 import React from 'react';
 import CenteredModalLayout from '@components/CenteredModalLayout';
 import ChangeWorkspaceMenuSectionList from '@components/ChangeWorkspaceMenuSectionList';
-import FeatureTrainingContent from '@components/FeatureTrainingModal/FeatureTrainingContent';
+import FeatureTrainingContent from '@components/FeatureTrainingContent';
 import useBeforeRemove from '@hooks/useBeforeRemove';
 import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset';
 import useLocalize from '@hooks/useLocalize';
diff --git a/src/pages/TrackTrainingPage.tsx b/src/pages/TrackTrainingPage.tsx
index ba6738143f76..f91af8185dfd 100644
--- a/src/pages/TrackTrainingPage.tsx
+++ b/src/pages/TrackTrainingPage.tsx
@@ -1,6 +1,6 @@
 import React, {useRef} from 'react';
 import CenteredModalLayout from '@components/CenteredModalLayout';
-import FeatureTrainingContent from '@components/FeatureTrainingModal/FeatureTrainingContent';
+import FeatureTrainingContent from '@components/FeatureTrainingContent';
 import useBeforeRemove from '@hooks/useBeforeRemove';
 import useLocalize from '@hooks/useLocalize';
 import Navigation from '@libs/Navigation/Navigation';

From b9922e019582b34cdec187e6290f8844d7e175e0 Mon Sep 17 00:00:00 2001
From: gijoe0295 <153004152+gijoe0295@users.noreply.github.com>
Date: Fri, 26 Jun 2026 00:01:33 +0700
Subject: [PATCH 11/35] rename and refactor FeatureTrainingCarousel

---
 src/CONST/index.ts                            |  3 +
 .../index.tsx => AIFeaturesPromoModal.tsx}    |  6 +-
 .../FeatureTrainingCarousel.tsx               | 89 ++++++-------------
 .../FeatureTrainingContent.tsx                |  3 -
 .../FeatureTrainingContentBody.tsx            |  6 +-
 .../FeatureTrainingContentIllustration.tsx    |  9 +-
 6 files changed, 38 insertions(+), 78 deletions(-)
 rename src/components/{AIFeaturesPromoModal/index.tsx => AIFeaturesPromoModal.tsx} (95%)

diff --git a/src/CONST/index.ts b/src/CONST/index.ts
index 895576d6ec66..607f3ba54aef 100644
--- a/src/CONST/index.ts
+++ b/src/CONST/index.ts
@@ -8870,6 +8870,9 @@ const CONST = {
                 ROW: 'DomainGroups-Row',
             },
         },
+        FEATURE_TRAINING: {
+            CLOSE_BUTTON: 'FeatureTraining-CloseButton',
+        },
     },
 
     DOMAIN: {
diff --git a/src/components/AIFeaturesPromoModal/index.tsx b/src/components/AIFeaturesPromoModal.tsx
similarity index 95%
rename from src/components/AIFeaturesPromoModal/index.tsx
rename to src/components/AIFeaturesPromoModal.tsx
index 67dfe73aa637..4f44b065b84b 100644
--- a/src/components/AIFeaturesPromoModal/index.tsx
+++ b/src/components/AIFeaturesPromoModal.tsx
@@ -1,8 +1,6 @@
 import React, {useRef} from 'react';
 import {View} from 'react-native';
 import Badge from '@components/Badge';
-import type {FeatureTrainingContentDataProps} from '@components/FeatureTrainingContent';
-import FeatureTrainingModal from '@components/FeatureTrainingModal';
 import LottieAnimations from '@components/LottieAnimations';
 import Text from '@components/Text';
 import useLocalize from '@hooks/useLocalize';
@@ -12,6 +10,8 @@ import {dismissProductTraining} from '@libs/actions/Welcome';
 import Log from '@libs/Log';
 import variables from '@styles/variables';
 import CONST from '@src/CONST';
+import {FeatureTrainingCarousel} from './FeatureTrainingContent';
+import type {FeatureTrainingContentDataProps} from './FeatureTrainingContent';
 
 function AIFeaturesPromoModal() {
     const {translate} = useLocalize();
@@ -73,7 +73,7 @@ function AIFeaturesPromoModal() {
     };
 
     return (
-        <FeatureTrainingModal
+        <FeatureTrainingCarousel
             pages={pages}
             onConfirm={onConfirm}
             onClose={onClose}
diff --git a/src/components/FeatureTrainingContent/FeatureTrainingCarousel.tsx b/src/components/FeatureTrainingContent/FeatureTrainingCarousel.tsx
index fb94bc9dfb7b..382cc0ec15a7 100644
--- a/src/components/FeatureTrainingContent/FeatureTrainingCarousel.tsx
+++ b/src/components/FeatureTrainingContent/FeatureTrainingCarousel.tsx
@@ -12,10 +12,12 @@ import useTheme from '@hooks/useTheme';
 import useThemeStyles from '@hooks/useThemeStyles';
 import variables from '@styles/variables';
 import CONST from '@src/CONST';
-import FeatureTrainingModalContent from './FeatureTrainingContentBody';
+import FeatureTrainingContentBody from './FeatureTrainingContentBody';
 import FeatureTrainingContentBodyText from './FeatureTrainingContentBodyText';
-import FeatureTrainingModalIllustration from './FeatureTrainingContentIllustration';
-import type {BaseFeatureTrainingModalProps, FeatureTrainingModalCarouselProps, FeatureTrainingModalPageProps} from './index';
+import FeatureTrainingContentIllustration from './FeatureTrainingContentIllustration';
+import type {FeatureTrainingCarouselProps, FeatureTrainingContentDataProps} from './types';
+
+const CONTENT_PADDING = variables.spacing2;
 
 // A page is considered "viewable" — and `currentPage` updates — only once it occupies at least
 // 95% of the viewport. The viewability event fires for both user swipes and programmatic
@@ -31,49 +33,8 @@ const PAGINATION_DOTS_BOTTOM_OFFSET = 5;
 // Native ignores this CSS-only key, so we still gate it on Platform.OS === 'web' for clarity.
 const WEB_CAROUSEL_PAGE_SNAP_STYLE: ViewStyle = Platform.OS === 'web' ? ({scrollSnapAlign: 'start'} as ViewStyle) : {};
 
-type FeatureTrainingModalCarouselBodyProps = Pick<
-    BaseFeatureTrainingModalProps,
-    | 'illustrationAspectRatio'
-    | 'illustrationInnerContainerStyle'
-    | 'illustrationOuterContainerStyle'
-    | 'titleStyles'
-    | 'shouldRenderSVG'
-    | 'shouldRenderHTMLDescription'
-    | 'shouldShowDismissModalOption'
-    | 'helpText'
-    | 'onHelp'
-    | 'shouldCallOnHelpWhenModalHidden'
-    | 'helpSentryLabel'
-    | 'confirmSentryLabel'
-    | 'shouldShowConfirmationLoader'
-    | 'canConfirmWhileOffline'
-    | 'contentInnerContainerStyles'
-    | 'contentOuterContainerStyles'
-    | 'width'
-> &
-    FeatureTrainingModalCarouselProps & {
-        /** Padding for the modal */
-        modalPadding: number;
-
-        /** Whether the modal should be shown again */
-        willShowAgain: boolean;
-
-        /** Callback when the "Don't show me this again" option is toggled */
-        toggleWillShowAgain: () => void;
-
-        /** Callback to close the modal */
-        closeModal: (didPressHelpButton?: boolean) => void;
-
-        /** Callback fired when the user presses the confirm button on the LAST page */
-        onConfirm: () => void;
-
-        /** Called when the user swipes to a different page */
-        onPageChange?: (index: number) => void;
-    };
-
 function FeatureTrainingCarousel({
     pages,
-    modalPadding,
     width = variables.featureTrainingModalWidth,
     titleStyles,
     illustrationAspectRatio,
@@ -91,12 +52,10 @@ function FeatureTrainingCarousel({
     canConfirmWhileOffline = true,
     contentInnerContainerStyles,
     contentOuterContainerStyles,
-    willShowAgain,
-    toggleWillShowAgain,
-    closeModal,
     onConfirm,
+    onClose,
     onPageChange,
-}: FeatureTrainingModalCarouselBodyProps) {
+}: FeatureTrainingCarouselProps) {
     const styles = useThemeStyles();
     const StyleUtils = useStyleUtils();
     const theme = useTheme();
@@ -106,7 +65,7 @@ function FeatureTrainingCarousel({
 
     const [currentPage, setCurrentPage] = useState(0);
     const [carouselViewportWidth, setCarouselViewportWidth] = useState(0);
-    const horizontalListRef = useRef<RNFlatList<FeatureTrainingModalPageProps>>(null);
+    const horizontalListRef = useRef<RNFlatList<FeatureTrainingContentDataProps>>(null);
     const lastReportedPage = useRef(0);
 
     const [contentMinHeight, setContentMinHeight] = useState<number | undefined>(undefined);
@@ -156,7 +115,7 @@ function FeatureTrainingCarousel({
             advanceCarousel();
             return;
         }
-        onConfirm();
+        onConfirm?.(false);
     };
 
     const carouselPaginationDots = pages.map((_page, index) => (
@@ -224,7 +183,7 @@ function FeatureTrainingCarousel({
                         <FlatList
                             ref={horizontalListRef}
                             data={pages}
-                            keyExtractor={(_page, index) => `FeatureTrainingModalIllustration-${index}`}
+                            keyExtractor={(_page, index) => `FeatureTrainingContentIllustration-${index}`}
                             horizontal
                             pagingEnabled
                             // Defense-in-depth on native: `pagingEnabled` is honored by the
@@ -242,12 +201,11 @@ function FeatureTrainingCarousel({
                             getItemLayout={(_data, index) => ({length: carouselViewportWidth, offset: index * carouselViewportWidth, index})}
                             renderItem={({item: page, index}) => (
                                 <View style={[{width: carouselViewportWidth}, WEB_CAROUSEL_PAGE_SNAP_STYLE]}>
-                                    <FeatureTrainingModalIllustration
+                                    <FeatureTrainingContentIllustration
                                         illustrationAspectRatio={illustrationAspectRatio}
                                         illustrationInnerContainerStyle={illustrationInnerContainerStyle}
                                         illustrationOuterContainerStyle={illustrationOuterContainerStyle}
                                         shouldRenderSVG={shouldRenderSVG}
-                                        modalPadding={modalPadding}
                                         isCarousel
                                         isFocused={index === currentPage}
                                         {...page}
@@ -257,12 +215,20 @@ function FeatureTrainingCarousel({
                         />
                         <View
                             pointerEvents="none"
-                            style={[styles.pAbsolute, styles.flexRow, styles.justifyContentCenter, styles.w100, styles.l0, styles.r0, {bottom: PAGINATION_DOTS_BOTTOM_OFFSET + modalPadding}]}
+                            style={[
+                                styles.pAbsolute,
+                                styles.flexRow,
+                                styles.justifyContentCenter,
+                                styles.w100,
+                                styles.l0,
+                                styles.r0,
+                                {bottom: PAGINATION_DOTS_BOTTOM_OFFSET + CONTENT_PADDING},
+                            ]}
                         >
                             {carouselPaginationDots}
                         </View>
                     </View>
-                    <FeatureTrainingModalContent
+                    <FeatureTrainingContentBody
                         title={currentPageData?.title}
                         subtitle={currentPageData?.subtitle}
                         description={currentPageData?.description}
@@ -270,14 +236,9 @@ function FeatureTrainingCarousel({
                         confirmText={currentPageData?.confirmText ?? ''}
                         helpText={helpText}
                         onHelp={onHelp}
-                        shouldCallOnHelpWhenModalHidden={shouldCallOnHelpWhenModalHidden}
                         helpSentryLabel={helpSentryLabel}
                         confirmSentryLabel={confirmSentryLabel}
-                        shouldShowDismissModalOption={shouldShowDismissModalOption}
-                        willShowAgain={willShowAgain}
-                        toggleWillShowAgain={toggleWillShowAgain}
-                        closeModal={closeModal}
-                        confirmModal={handleConfirmPress}
+                        onConfirm={handleConfirmPress}
                         shouldShowBackButton={currentPage > 0}
                         onBack={goBack}
                         shouldShowConfirmationLoader={shouldShowConfirmationLoader}
@@ -287,13 +248,13 @@ function FeatureTrainingCarousel({
                         contentOuterContainerStyles={contentOuterContainerStyles}
                         shouldRenderHTMLDescription={shouldRenderHTMLDescription}
                     />
-                    <View style={[styles.pAbsolute, {top: modalPadding, right: modalPadding, zIndex: 1}]}>
+                    <View style={[styles.pAbsolute, {top: CONTENT_PADDING, right: CONTENT_PADDING, zIndex: 1}]}>
                         <Tooltip text={translate('common.close')}>
                             <PressableWithFeedback
-                                onPress={() => closeModal()}
+                                onPress={onClose}
                                 role={CONST.ROLE.BUTTON}
                                 accessibilityLabel={translate('common.close')}
-                                sentryLabel="FeatureTrainingModal-Carousel-Close"
+                                sentryLabel={CONST.SENTRY_LABEL.FEATURE_TRAINING.CLOSE_BUTTON}
                                 style={[styles.p2, styles.opacitySemiTransparent]}
                             >
                                 <Icon
diff --git a/src/components/FeatureTrainingContent/FeatureTrainingContent.tsx b/src/components/FeatureTrainingContent/FeatureTrainingContent.tsx
index d23d5d325d47..18b00bfcc1de 100644
--- a/src/components/FeatureTrainingContent/FeatureTrainingContent.tsx
+++ b/src/components/FeatureTrainingContent/FeatureTrainingContent.tsx
@@ -16,8 +16,6 @@ import FeatureTrainingContentBody from './FeatureTrainingContentBody';
 import FeatureTrainingContentIllustration from './FeatureTrainingContentIllustration';
 import type {FeatureTrainingContentProps} from './types';
 
-const CONTENT_PADDING = variables.spacing2;
-
 function FeatureTrainingContent({
     illustrationInnerContainerStyle,
     illustrationOuterContainerStyle,
@@ -106,7 +104,6 @@ function FeatureTrainingContent({
                 illustrationInnerContainerStyle={illustrationInnerContainerStyle}
                 illustrationOuterContainerStyle={illustrationOuterContainerStyle}
                 shouldRenderSVG={shouldRenderSVG}
-                modalPadding={CONTENT_PADDING}
                 {...props}
             />
             <FeatureTrainingContentBody
diff --git a/src/components/FeatureTrainingContent/FeatureTrainingContentBody.tsx b/src/components/FeatureTrainingContent/FeatureTrainingContentBody.tsx
index 3dcb6367277d..c3730e2a6f0a 100644
--- a/src/components/FeatureTrainingContent/FeatureTrainingContentBody.tsx
+++ b/src/components/FeatureTrainingContent/FeatureTrainingContentBody.tsx
@@ -27,10 +27,10 @@ type FeatureTrainingModalContentProps = Pick<
 > &
     BaseFeatureTrainingContentBodyProps & {
         /** Whether the modal should be shown again (drives the dismiss checkbox state) */
-        willShowAgain: boolean;
+        willShowAgain?: boolean;
 
         /** Callback when the "Don't show me this again" option is toggled */
-        toggleWillShowAgain: () => void;
+        toggleWillShowAgain?: () => void;
 
         /** Whether to render a Back button (carousel mode, non-first pages) */
         shouldShowBackButton?: boolean;
@@ -51,7 +51,7 @@ function FeatureTrainingContentBody({
     helpSentryLabel,
     confirmSentryLabel,
     shouldShowDismissModalOption = false,
-    willShowAgain,
+    willShowAgain = false,
     toggleWillShowAgain,
     shouldShowBackButton = false,
     onBack,
diff --git a/src/components/FeatureTrainingContent/FeatureTrainingContentIllustration.tsx b/src/components/FeatureTrainingContent/FeatureTrainingContentIllustration.tsx
index 89f8dd10fa20..f96937072418 100644
--- a/src/components/FeatureTrainingContent/FeatureTrainingContentIllustration.tsx
+++ b/src/components/FeatureTrainingContent/FeatureTrainingContentIllustration.tsx
@@ -15,6 +15,7 @@ import useThemeStyles from '@hooks/useThemeStyles';
 import useWindowDimensions from '@hooks/useWindowDimensions';
 import Accessibility from '@libs/Accessibility';
 import isInLandscapeModeUtil from '@libs/isInLandscapeMode';
+import variables from '@styles/variables';
 import CONST from '@src/CONST';
 import type {FeatureTrainingContentIllustrationProps as BaseFeatureTrainingContentIllustrationProps, BaseFeatureTrainingContentProps} from './types';
 
@@ -22,6 +23,8 @@ import type {FeatureTrainingContentIllustrationProps as BaseFeatureTrainingConte
 // Useful before video loads to reserve space.
 const VIDEO_ASPECT_RATIO = 1280 / 960;
 
+const CONTENT_PADDING = variables.spacing2;
+
 const LANDSCAPE_ILLUSTRATION_MAX_HEIGHT_TO_WINDOW_HEIGHT_RATIO = 0.7;
 
 type VideoStatus = 'video' | 'animation';
@@ -31,9 +34,6 @@ type FeatureTrainingContentIllustrationProps = Pick<
     'shouldRenderSVG' | 'illustrationAspectRatio' | 'illustrationInnerContainerStyle' | 'illustrationOuterContainerStyle'
 > &
     BaseFeatureTrainingContentIllustrationProps & {
-        /** Padding for the modal */
-        modalPadding: number;
-
         /** Whether this illustration belongs to the currently-visible carousel page */
         isFocused?: boolean;
 
@@ -53,7 +53,6 @@ function FeatureTrainingContentIllustration({
     illustrationInnerContainerStyle,
     illustrationOuterContainerStyle,
     shouldRenderSVG = true,
-    modalPadding,
     isFocused = true,
     isCarousel = false,
 }: FeatureTrainingContentIllustrationProps) {
@@ -107,7 +106,7 @@ function FeatureTrainingContentIllustration({
     return (
         <View
             style={[
-                onboardingIsMediumOrLargerScreenWidth ? {padding: modalPadding} : {paddingHorizontal: modalPadding},
+                onboardingIsMediumOrLargerScreenWidth ? {padding: CONTENT_PADDING} : {paddingHorizontal: CONTENT_PADDING},
                 illustrationOuterContainerStyle,
                 isInLandscapeMode ? [{maxHeight: windowHeight * LANDSCAPE_ILLUSTRATION_MAX_HEIGHT_TO_WINDOW_HEIGHT_RATIO}, styles.alignSelfCenter] : undefined,
             ]}

From c0ed02e66992b7d591fc42e5f0fa555d10e87cfd Mon Sep 17 00:00:00 2001
From: gijoe0295 <153004152+gijoe0295@users.noreply.github.com>
Date: Fri, 26 Jun 2026 00:48:42 +0700
Subject: [PATCH 12/35] fix lint an dknip

---
 config/eslint/eslint.seatbelt.tsv             |  2 +-
 src/ROUTES.ts                                 |  2 +-
 src/components/AIFeaturesPromoModal.tsx       | 27 ++++++++++------
 .../FeatureTrainingCarousel.tsx               |  2 --
 .../FeatureTrainingContent.tsx                |  2 --
 .../FeatureTrainingContentIllustration.tsx    | 31 +++++++++----------
 .../FeatureTrainingContent/types.ts           |  4 +--
 .../Navigation/guards/AIFeaturesPromoGuard.ts |  5 +--
 src/libs/Navigation/linkingConfig/config.ts   |  2 +-
 9 files changed, 41 insertions(+), 36 deletions(-)

diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv
index 1fb7d570a2eb..2af5dff4d257 100644
--- a/config/eslint/eslint.seatbelt.tsv
+++ b/config/eslint/eslint.seatbelt.tsv
@@ -170,7 +170,7 @@
 "../../src/components/EmptySelectionListContent.tsx"	"@typescript-eslint/no-unsafe-type-assertion"	1
 "../../src/components/EnvironmentBadge.tsx"	"no-restricted-syntax"	1
 "../../src/components/ExplanationModal.tsx"	"no-restricted-syntax"	1
-"../../src/components/FeatureTrainingModal/FeatureTrainingModalIllustration.tsx"	"@typescript-eslint/no-unsafe-type-assertion"	2
+"../../src/components/FeatureTrainingContent/FeatureTrainingContentIllustration.tsx"	"@typescript-eslint/no-unsafe-type-assertion"	2
 "../../src/components/FeatureTrainingModal/FeatureTrainingModalIllustration.tsx"	"react-hooks/set-state-in-effect"	1
 "../../src/components/FeedbackSurvey.tsx"	"react-hooks/set-state-in-effect"	1
 "../../src/components/FilePicker/index.native.tsx"	"react-hooks/refs"	1
diff --git a/src/ROUTES.ts b/src/ROUTES.ts
index 4438cad476ee..348bb70c2a46 100644
--- a/src/ROUTES.ts
+++ b/src/ROUTES.ts
@@ -135,7 +135,7 @@ const DYNAMIC_ROUTES = {
         path: 'migrated-user-welcome',
         entryScreens: [SCREENS.HOME, SCREENS.INBOX, SCREENS.REPORT, SCREENS.SEARCH.ROOT, SCREENS.WORKSPACES_LIST, SCREENS.WORKSPACE.PROFILE, SCREENS.SETTINGS.ROOT],
     },
-    AI_FEATURES_PROMO_MODAL: {
+    AI_FEATURES_PROMO: {
         path: 'ai-features-promo',
         entryScreens: [SCREENS.HOME, SCREENS.INBOX, SCREENS.REPORT, SCREENS.SEARCH.ROOT, SCREENS.WORKSPACES_LIST, SCREENS.WORKSPACE.PROFILE, SCREENS.SETTINGS.ROOT],
     },
diff --git a/src/components/AIFeaturesPromoModal.tsx b/src/components/AIFeaturesPromoModal.tsx
index 4f44b065b84b..1c79a0e766eb 100644
--- a/src/components/AIFeaturesPromoModal.tsx
+++ b/src/components/AIFeaturesPromoModal.tsx
@@ -1,17 +1,19 @@
 import React, {useRef} from 'react';
 import {View} from 'react-native';
-import Badge from '@components/Badge';
-import LottieAnimations from '@components/LottieAnimations';
-import Text from '@components/Text';
+import useBeforeRemove from '@hooks/useBeforeRemove';
 import useLocalize from '@hooks/useLocalize';
 import usePermissions from '@hooks/usePermissions';
 import useThemeStyles from '@hooks/useThemeStyles';
 import {dismissProductTraining} from '@libs/actions/Welcome';
 import Log from '@libs/Log';
+import Navigation from '@libs/Navigation/Navigation';
 import variables from '@styles/variables';
 import CONST from '@src/CONST';
+import Badge from './Badge';
 import {FeatureTrainingCarousel} from './FeatureTrainingContent';
 import type {FeatureTrainingContentDataProps} from './FeatureTrainingContent';
+import LottieAnimations from './LottieAnimations';
+import Text from './Text';
 
 function AIFeaturesPromoModal() {
     const {translate} = useLocalize();
@@ -59,17 +61,24 @@ function AIFeaturesPromoModal() {
             : []),
     ];
 
-    const wasDismissedViaConfirmRef = useRef(false);
+    const isCloseButtonDismissalRef = useRef(false);
+
+    const handleDismiss = () => {
+        Log.hmmm(`[AIFeaturesPromoModal] dismissing product training via ${isCloseButtonDismissalRef.current ? 'x' : 'click'}`);
+        dismissProductTraining(CONST.AI_FEATURES_PROMO_MODAL, isCloseButtonDismissalRef.current);
+    };
+
+    useBeforeRemove(handleDismiss);
 
     const onConfirm = () => {
-        Log.hmmm('[AIFeaturesPromoModal] onConfirm called, recording click dismissal');
-        wasDismissedViaConfirmRef.current = true;
+        Log.hmmm('[AIFeaturesPromoModal] onConfirm called');
+        Navigation.goBack();
     };
 
     const onClose = () => {
-        const isCloseButtonDismissal = !wasDismissedViaConfirmRef.current;
-        Log.hmmm(`[AIFeaturesPromoModal] onClose called, dismissing product training via ${isCloseButtonDismissal ? 'x' : 'click'}`);
-        dismissProductTraining(CONST.AI_FEATURES_PROMO_MODAL, isCloseButtonDismissal);
+        Log.hmmm(`[AIFeaturesPromoModal] onClose called, user closed modal without confirming`);
+        isCloseButtonDismissalRef.current = true;
+        Navigation.goBack();
     };
 
     return (
diff --git a/src/components/FeatureTrainingContent/FeatureTrainingCarousel.tsx b/src/components/FeatureTrainingContent/FeatureTrainingCarousel.tsx
index 382cc0ec15a7..a0e5c164b9ce 100644
--- a/src/components/FeatureTrainingContent/FeatureTrainingCarousel.tsx
+++ b/src/components/FeatureTrainingContent/FeatureTrainingCarousel.tsx
@@ -42,10 +42,8 @@ function FeatureTrainingCarousel({
     illustrationOuterContainerStyle,
     shouldRenderSVG = true,
     shouldRenderHTMLDescription = false,
-    shouldShowDismissModalOption = false,
     helpText = '',
     onHelp = () => {},
-    shouldCallOnHelpWhenModalHidden = false,
     helpSentryLabel,
     confirmSentryLabel,
     shouldShowConfirmationLoader = false,
diff --git a/src/components/FeatureTrainingContent/FeatureTrainingContent.tsx b/src/components/FeatureTrainingContent/FeatureTrainingContent.tsx
index 18b00bfcc1de..928038fd5ccc 100644
--- a/src/components/FeatureTrainingContent/FeatureTrainingContent.tsx
+++ b/src/components/FeatureTrainingContent/FeatureTrainingContent.tsx
@@ -134,5 +134,3 @@ function FeatureTrainingContent({
 }
 
 export default FeatureTrainingContent;
-
-export type {FeatureTrainingContentProps};
diff --git a/src/components/FeatureTrainingContent/FeatureTrainingContentIllustration.tsx b/src/components/FeatureTrainingContent/FeatureTrainingContentIllustration.tsx
index f96937072418..706bc76f801f 100644
--- a/src/components/FeatureTrainingContent/FeatureTrainingContentIllustration.tsx
+++ b/src/components/FeatureTrainingContent/FeatureTrainingContentIllustration.tsx
@@ -15,6 +15,7 @@ import useThemeStyles from '@hooks/useThemeStyles';
 import useWindowDimensions from '@hooks/useWindowDimensions';
 import Accessibility from '@libs/Accessibility';
 import isInLandscapeModeUtil from '@libs/isInLandscapeMode';
+import {getIsOffline} from '@libs/NetworkState';
 import variables from '@styles/variables';
 import CONST from '@src/CONST';
 import type {FeatureTrainingContentIllustrationProps as BaseFeatureTrainingContentIllustrationProps, BaseFeatureTrainingContentProps} from './types';
@@ -41,6 +42,19 @@ type FeatureTrainingContentIllustrationProps = Pick<
         isCarousel?: boolean;
     };
 
+/**
+ * Once the device has been online, lock to 'video' permanently.
+ * While it has never been online, show 'animation' as a fallback.
+ */
+function useVideoStatus(): VideoStatus {
+    const [isLockedToVideo, setIsLockedToVideo] = useState(() => !getIsOffline());
+    const {isOffline} = useNetwork({
+        onReconnect: () => setIsLockedToVideo(true),
+    });
+
+    return isLockedToVideo || !isOffline ? 'video' : 'animation';
+}
+
 function FeatureTrainingContentIllustration({
     animation,
     animationStyle,
@@ -60,6 +74,7 @@ function FeatureTrainingContentIllustration({
     const isReduceMotionEnabled = Accessibility.useReducedMotion();
     const illustrations = useMemoizedLazyIllustrations(['Hands']);
     const {onboardingIsMediumOrLargerScreenWidth, shouldUseNarrowLayout} = useResponsiveLayout();
+    const videoStatus = useVideoStatus();
     const {windowHeight, windowWidth} = useWindowDimensions();
     const [illustrationAspectRatio, setIllustrationAspectRatio] = useState(illustrationAspectRatioProp ?? VIDEO_ASPECT_RATIO);
     const {isOffline} = useNetwork();
@@ -77,22 +92,6 @@ function FeatureTrainingContentIllustration({
         }
     }, [isFocused, isCarousel, isReduceMotionEnabled]);
 
-    const [videoStatus, setVideoStatus] = useState<VideoStatus>('video');
-    const [isVideoStatusLocked, setIsVideoStatusLocked] = useState(false);
-
-    useEffect(() => {
-        if (isVideoStatusLocked) {
-            return;
-        }
-
-        if (isOffline) {
-            setVideoStatus('animation');
-        } else if (!isOffline) {
-            setVideoStatus('video');
-            setIsVideoStatusLocked(true);
-        }
-    }, [isOffline, isVideoStatusLocked]);
-
     const setAspectRatio = (event: SourceLoadEventPayload) => {
         const track = event.availableVideoTracks.at(0);
         if (!track) {
diff --git a/src/components/FeatureTrainingContent/types.ts b/src/components/FeatureTrainingContent/types.ts
index f7c46a421475..2a624e132d65 100644
--- a/src/components/FeatureTrainingContent/types.ts
+++ b/src/components/FeatureTrainingContent/types.ts
@@ -1,5 +1,5 @@
 import type {ImageContentFit} from 'expo-image';
-import React from 'react';
+import type {ReactNode} from 'react';
 import type {StyleProp, TextStyle, ViewStyle} from 'react-native';
 import type {MergeExclusive} from 'type-fest';
 import type ImageSVGProps from '@components/ImageSVG/types';
@@ -47,7 +47,7 @@ type BaseFeatureTrainingContentProps = {
     modalInnerContainerStyle?: ViewStyle;
 
     /** Children to show below title and description and above buttons (single-page mode only) */
-    children?: React.ReactNode;
+    children?: ReactNode;
 
     /** Modal width */
     width?: number;
diff --git a/src/libs/Navigation/guards/AIFeaturesPromoGuard.ts b/src/libs/Navigation/guards/AIFeaturesPromoGuard.ts
index 8f285fef76b2..90b482e587f9 100644
--- a/src/libs/Navigation/guards/AIFeaturesPromoGuard.ts
+++ b/src/libs/Navigation/guards/AIFeaturesPromoGuard.ts
@@ -5,6 +5,7 @@ import {hasCompletedGuidedSetupFlowSelector, tryNewDotOnyxSelector} from '@selec
 import Onyx from 'react-native-onyx';
 import type {OnyxEntry} from 'react-native-onyx';
 import Log from '@libs/Log';
+import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute';
 import Navigation from '@libs/Navigation/Navigation';
 import navigationRef from '@libs/Navigation/navigationRef';
 import TransitionTracker from '@libs/Navigation/TransitionTracker';
@@ -16,7 +17,6 @@ import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES';
 import type {Route} from '@src/ROUTES';
 import SCREENS from '@src/SCREENS';
 import type {DismissedProductTraining, Onboarding, Session} from '@src/types/onyx';
-import createDynamicRoute from '../helpers/dynamicRoutesUtils/createDynamicRoute';
 import type {GuardResult, NavigationGuard} from './types';
 
 let session: OnyxEntry<Session>;
@@ -99,7 +99,7 @@ function isEligibleToShowAIFeaturesPromoModal(): boolean {
 }
 
 function getAIFeaturesPromoModalRoute(basePath?: string): Route {
-    return createDynamicRoute(DYNAMIC_ROUTES.AI_FEATURES_PROMO_MODAL.path, basePath ?? (Navigation.getActiveRoute() || ROUTES.HOME));
+    return createDynamicRoute(DYNAMIC_ROUTES.AI_FEATURES_PROMO.path, basePath ?? (Navigation.getActiveRoute() || ROUTES.HOME));
 }
 
 /**
@@ -129,6 +129,7 @@ function navigateToAIFeaturesPromoModalIfReady() {
                 }
                 Log.info('[AIFeaturesPromoGuard] Proactively navigating to AI features promo modal');
                 hasRedirectedToAIFeaturesPromoModal = true;
+                console.log('navigating to AI features promo modal', getAIFeaturesPromoModalRoute());
                 Navigation.navigate(getAIFeaturesPromoModalRoute());
             });
         },
diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts
index 14e6524a7fd1..e358996f7940 100644
--- a/src/libs/Navigation/linkingConfig/config.ts
+++ b/src/libs/Navigation/linkingConfig/config.ts
@@ -51,7 +51,7 @@ const config: LinkingOptions<RootNavigatorParamList>['config'] = {
         },
         [NAVIGATORS.AI_FEATURES_PROMO_MODAL_NAVIGATOR]: {
             screens: {
-                [SCREENS.AI_FEATURES_PROMO_MODAL.DYNAMIC_ROOT]: DYNAMIC_ROUTES.AI_FEATURES_PROMO_MODAL.path,
+                [SCREENS.AI_FEATURES_PROMO_MODAL.DYNAMIC_ROOT]: DYNAMIC_ROUTES.AI_FEATURES_PROMO.path,
             },
         },
 

From 81e4b2b808aae4559e03b1c1855fdd4e08176c31 Mon Sep 17 00:00:00 2001
From: gijoe0295 <153004152+gijoe0295@users.noreply.github.com>
Date: Fri, 26 Jun 2026 02:25:04 +0700
Subject: [PATCH 13/35] fix: improve guard and fix modal style

---
 src/components/AIFeaturesPromoModal.tsx       | 30 +++++++++++--------
 .../FeatureTrainingContentIllustration.tsx    |  1 -
 .../Navigation/guards/AIFeaturesPromoGuard.ts | 15 +++++-----
 3 files changed, 25 insertions(+), 21 deletions(-)

diff --git a/src/components/AIFeaturesPromoModal.tsx b/src/components/AIFeaturesPromoModal.tsx
index 1c79a0e766eb..bf65245ad254 100644
--- a/src/components/AIFeaturesPromoModal.tsx
+++ b/src/components/AIFeaturesPromoModal.tsx
@@ -10,6 +10,7 @@ import Navigation from '@libs/Navigation/Navigation';
 import variables from '@styles/variables';
 import CONST from '@src/CONST';
 import Badge from './Badge';
+import CenteredModalLayout from './CenteredModalLayout';
 import {FeatureTrainingCarousel} from './FeatureTrainingContent';
 import type {FeatureTrainingContentDataProps} from './FeatureTrainingContent';
 import LottieAnimations from './LottieAnimations';
@@ -82,19 +83,24 @@ function AIFeaturesPromoModal() {
     };
 
     return (
-        <FeatureTrainingCarousel
-            pages={pages}
-            onConfirm={onConfirm}
-            onClose={onClose}
+        <CenteredModalLayout
+            onBackdropPress={onClose}
             width={variables.aiFeaturesPromoModalWidth}
-            shouldRenderHTMLDescription
-            shouldUseScrollView
-            illustrationOuterContainerStyle={styles.p0}
-            illustrationAspectRatio={LottieAnimations.SpendAnalysis.w / LottieAnimations.SpendAnalysis.h}
-            contentInnerContainerStyles={styles.mb4}
-            modalInnerContainerStyle={styles.pt0}
-            titleStyles={styles.mb2}
-        />
+        >
+            <FeatureTrainingCarousel
+                pages={pages}
+                onConfirm={onConfirm}
+                onClose={onClose}
+                width={variables.aiFeaturesPromoModalWidth}
+                shouldRenderHTMLDescription
+                shouldUseScrollView
+                illustrationOuterContainerStyle={styles.p0}
+                illustrationAspectRatio={LottieAnimations.SpendAnalysis.w / LottieAnimations.SpendAnalysis.h}
+                contentInnerContainerStyles={styles.mb4}
+                modalInnerContainerStyle={styles.pt0}
+                titleStyles={styles.mb2}
+            />
+        </CenteredModalLayout>
     );
 }
 
diff --git a/src/components/FeatureTrainingContent/FeatureTrainingContentIllustration.tsx b/src/components/FeatureTrainingContent/FeatureTrainingContentIllustration.tsx
index 706bc76f801f..50b99cedacb0 100644
--- a/src/components/FeatureTrainingContent/FeatureTrainingContentIllustration.tsx
+++ b/src/components/FeatureTrainingContent/FeatureTrainingContentIllustration.tsx
@@ -77,7 +77,6 @@ function FeatureTrainingContentIllustration({
     const videoStatus = useVideoStatus();
     const {windowHeight, windowWidth} = useWindowDimensions();
     const [illustrationAspectRatio, setIllustrationAspectRatio] = useState(illustrationAspectRatioProp ?? VIDEO_ASPECT_RATIO);
-    const {isOffline} = useNetwork();
     const isInLandscapeMode = isInLandscapeModeUtil(windowWidth, windowHeight);
 
     const animationRef = useRef<LottieView | null>(null);
diff --git a/src/libs/Navigation/guards/AIFeaturesPromoGuard.ts b/src/libs/Navigation/guards/AIFeaturesPromoGuard.ts
index 90b482e587f9..bf66a3931a44 100644
--- a/src/libs/Navigation/guards/AIFeaturesPromoGuard.ts
+++ b/src/libs/Navigation/guards/AIFeaturesPromoGuard.ts
@@ -98,8 +98,8 @@ function isEligibleToShowAIFeaturesPromoModal(): boolean {
     );
 }
 
-function getAIFeaturesPromoModalRoute(basePath?: string): Route {
-    return createDynamicRoute(DYNAMIC_ROUTES.AI_FEATURES_PROMO.path, basePath ?? (Navigation.getActiveRoute() || ROUTES.HOME));
+function getAIFeaturesPromoModalRoute(): Route {
+    return createDynamicRoute(DYNAMIC_ROUTES.AI_FEATURES_PROMO.path, ROUTES.HOME);
 }
 
 /**
@@ -110,7 +110,6 @@ function navigateToAIFeaturesPromoModalIfReady() {
     // for future state changes) before checking, so a freshly-mounted onboarding navigator
     // is reflected immediately.
     attachNavigationStateListener();
-    snapshotActiveModalsFromNavigationState();
 
     if (isWaitingForProtectedRoutes || !isEligibleToShowAIFeaturesPromoModal()) {
         return;
@@ -129,7 +128,6 @@ function navigateToAIFeaturesPromoModalIfReady() {
                 }
                 Log.info('[AIFeaturesPromoGuard] Proactively navigating to AI features promo modal');
                 hasRedirectedToAIFeaturesPromoModal = true;
-                console.log('navigating to AI features promo modal', getAIFeaturesPromoModalRoute());
                 Navigation.navigate(getAIFeaturesPromoModalRoute());
             });
         },
@@ -159,7 +157,6 @@ Onyx.connectWithoutView({
         if (hasBeenAddedToNudgeMigration && !isProductTrainingElementDismissed(CONST.MIGRATED_USER_WELCOME_MODAL, value)) {
             observedActiveMigrationModalThisSession = true;
         }
-        navigateToAIFeaturesPromoModalIfReady();
     },
 });
 
@@ -171,7 +168,6 @@ Onyx.connectWithoutView({
         if (hasCompletedGuidedSetupFlowSelector(onboarding) === false) {
             observedActiveOnboardingThisSession = true;
         }
-        navigateToAIFeaturesPromoModalIfReady();
     },
 });
 
@@ -184,7 +180,6 @@ Onyx.connectWithoutView({
         if (hasBeenAddedToNudgeMigration && !isProductTrainingElementDismissed(CONST.MIGRATED_USER_WELCOME_MODAL, dismissedProductTraining)) {
             observedActiveMigrationModalThisSession = true;
         }
-        navigateToAIFeaturesPromoModalIfReady();
     },
 });
 
@@ -192,7 +187,6 @@ Onyx.connectWithoutView({
     key: ONYXKEYS.ACCOUNT,
     callback: (value) => {
         isActingAsDelegate = isActingAsDelegateSelector(value);
-        navigateToAIFeaturesPromoModalIfReady();
     },
 });
 
@@ -267,6 +261,11 @@ const AIFeaturesPromoGuard: NavigationGuard = {
             return {type: 'ALLOW'};
         }
 
+        if (isEligibleToShowAIFeaturesPromoModal()) {
+            hasRedirectedToAIFeaturesPromoModal = true;
+            return {type: 'REDIRECT', route: getAIFeaturesPromoModalRoute()};
+        }
+
         return {type: 'ALLOW'};
     },
 };

From 70bd9574759fd3ee0ea5a6d062ff8c91f8d5941e Mon Sep 17 00:00:00 2001
From: gijoe0295 <153004152+gijoe0295@users.noreply.github.com>
Date: Fri, 26 Jun 2026 02:33:54 +0700
Subject: [PATCH 14/35] Revert "Fix search router opens/closes when AI promo
 modal is up"

This reverts commit 6b39918c53062442edcb313b3d2ac5161f91afca.
---
 .../Search/SearchRouter/SearchRouterContext.tsx       | 11 +++--------
 1 file changed, 3 insertions(+), 8 deletions(-)

diff --git a/src/components/Search/SearchRouter/SearchRouterContext.tsx b/src/components/Search/SearchRouter/SearchRouterContext.tsx
index 313aa46de259..1b1784ef9ba0 100644
--- a/src/components/Search/SearchRouter/SearchRouterContext.tsx
+++ b/src/components/Search/SearchRouter/SearchRouterContext.tsx
@@ -90,6 +90,9 @@ function SearchRouterContextProvider({children}: ChildrenProps) {
     const openSearchRouter = (query?: string, isFromSearchPageSearchButton?: boolean) => {
         pendingRouterQuery = query ?? '';
         pendingIsFromSearchPageSearchButton = isFromSearchPageSearchButton ?? false;
+        if (isBrowserWithHistory) {
+            window.history.pushState({isSearchModalOpen: true} satisfies HistoryState, '');
+        }
         startSpan(CONST.TELEMETRY.SPAN_SEARCH_ROUTER_MODAL_CLOSE_WAIT, {
             name: CONST.TELEMETRY.SPAN_SEARCH_ROUTER_MODAL_CLOSE_WAIT,
             op: 'ui.modal.wait',
@@ -98,14 +101,6 @@ function SearchRouterContextProvider({children}: ChildrenProps) {
         close(
             () => {
                 endSpan(CONST.TELEMETRY.SPAN_SEARCH_ROUTER_MODAL_CLOSE_WAIT);
-                // Push the history entry only after pre-existing modals have finished closing —
-                // some modal close paths (e.g. FeatureTrainingModal with shouldGoBack=true) fire
-                // Navigation.goBack(), which pops history. If we pushState'd before close(),
-                // that pop would consume our entry and the resulting popstate listener would
-                // immediately mark the search router as closed.
-                if (isBrowserWithHistory) {
-                    window.history.pushState({isSearchModalOpen: true} satisfies HistoryState, '');
-                }
                 startListRenderSpan();
                 openSearch(setIsSearchRouterDisplayed);
                 searchRouterDisplayedRef.current = true;

From 976d7b9d1bcb724395a2a788fd95d2155885a77d Mon Sep 17 00:00:00 2001
From: gijoe0295 <153004152+gijoe0295@users.noreply.github.com>
Date: Fri, 26 Jun 2026 04:32:50 +0700
Subject: [PATCH 15/35] Support landscape mode. Fixes
 https://github.com/Expensify/App/issues/93968

---
 src/components/AIFeaturesPromoModal.tsx       |  2 +-
 .../FeatureTrainingCarousel.tsx               | 58 +++++++++++++++----
 .../FeatureTrainingContentIllustration.tsx    |  2 +-
 .../Navigation/guards/AIFeaturesPromoGuard.ts |  1 +
 4 files changed, 50 insertions(+), 13 deletions(-)

diff --git a/src/components/AIFeaturesPromoModal.tsx b/src/components/AIFeaturesPromoModal.tsx
index bf65245ad254..3f646e0406e8 100644
--- a/src/components/AIFeaturesPromoModal.tsx
+++ b/src/components/AIFeaturesPromoModal.tsx
@@ -86,6 +86,7 @@ function AIFeaturesPromoModal() {
         <CenteredModalLayout
             onBackdropPress={onClose}
             width={variables.aiFeaturesPromoModalWidth}
+            contentStyle={styles.pt0}
         >
             <FeatureTrainingCarousel
                 pages={pages}
@@ -97,7 +98,6 @@ function AIFeaturesPromoModal() {
                 illustrationOuterContainerStyle={styles.p0}
                 illustrationAspectRatio={LottieAnimations.SpendAnalysis.w / LottieAnimations.SpendAnalysis.h}
                 contentInnerContainerStyles={styles.mb4}
-                modalInnerContainerStyle={styles.pt0}
                 titleStyles={styles.mb2}
             />
         </CenteredModalLayout>
diff --git a/src/components/FeatureTrainingContent/FeatureTrainingCarousel.tsx b/src/components/FeatureTrainingContent/FeatureTrainingCarousel.tsx
index a0e5c164b9ce..cfe65af019da 100644
--- a/src/components/FeatureTrainingContent/FeatureTrainingCarousel.tsx
+++ b/src/components/FeatureTrainingContent/FeatureTrainingCarousel.tsx
@@ -1,15 +1,21 @@
 import React, {useEffect, useRef, useState} from 'react';
 import {FlatList, Platform, View} from 'react-native';
-import type {LayoutChangeEvent, FlatList as RNFlatList, ViewabilityConfig, ViewStyle, ViewToken} from 'react-native';
+// eslint-disable-next-line no-restricted-imports -- Type import needed for ref typing; no wrapper available
+import type {LayoutChangeEvent, FlatList as RNFlatList, ScrollView as RNScrollView, ViewabilityConfig, ViewStyle, ViewToken} from 'react-native';
+import {useSafeAreaInsets} from 'react-native-safe-area-context';
 import Icon from '@components/Icon';
 import PressableWithFeedback from '@components/Pressable/PressableWithFeedback';
+import ScrollView from '@components/ScrollView';
 import Tooltip from '@components/Tooltip';
+import useKeyboardState from '@hooks/useKeyboardState';
 import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
 import useLocalize from '@hooks/useLocalize';
 import useResponsiveLayout from '@hooks/useResponsiveLayout';
 import useStyleUtils from '@hooks/useStyleUtils';
 import useTheme from '@hooks/useTheme';
 import useThemeStyles from '@hooks/useThemeStyles';
+import useWindowDimensions from '@hooks/useWindowDimensions';
+import isInLandscapeModeUtil from '@libs/isInLandscapeMode';
 import variables from '@styles/variables';
 import CONST from '@src/CONST';
 import FeatureTrainingContentBody from './FeatureTrainingContentBody';
@@ -42,6 +48,7 @@ function FeatureTrainingCarousel({
     illustrationOuterContainerStyle,
     shouldRenderSVG = true,
     shouldRenderHTMLDescription = false,
+    shouldUseScrollView: shouldUseScrollViewProp = false,
     helpText = '',
     onHelp = () => {},
     helpSentryLabel,
@@ -60,12 +67,19 @@ function FeatureTrainingCarousel({
     const {translate} = useLocalize();
     const expensifyIcons = useMemoizedLazyExpensifyIcons(['Close']);
     const {onboardingIsMediumOrLargerScreenWidth} = useResponsiveLayout();
+    const {windowHeight, windowWidth} = useWindowDimensions();
+    const insets = useSafeAreaInsets();
+    const {isKeyboardActive} = useKeyboardState();
+    const isInLandscapeMode = isInLandscapeModeUtil(windowWidth, windowHeight);
 
     const [currentPage, setCurrentPage] = useState(0);
     const [carouselViewportWidth, setCarouselViewportWidth] = useState(0);
     const horizontalListRef = useRef<RNFlatList<FeatureTrainingContentDataProps>>(null);
     const lastReportedPage = useRef(0);
 
+    const scrollViewRef = useRef<RNScrollView>(null);
+    const [containerHeight, setContainerHeight] = useState(0);
+    const [contentHeight, setContentHeight] = useState(0);
     const [contentMinHeight, setContentMinHeight] = useState<number | undefined>(undefined);
     const measuredHeightsRef = useRef<Record<number, number>>({});
     const handleProbeLayout = (index: number) => (event: LayoutChangeEvent) => {
@@ -80,6 +94,8 @@ function FeatureTrainingCarousel({
         setContentMinHeight(Math.max(...Object.values(measuredHeightsRef.current)));
     };
 
+    const shouldUseScrollView = shouldUseScrollViewProp || isInLandscapeMode;
+
     // FlatList's `onViewableItemsChanged` must keep a stable identity (it errors otherwise).
     // The handler reads the latest `onPageChange` via a ref so the callback identity never changes.
     const onPageChangeRef = useRef(onPageChange);
@@ -116,6 +132,17 @@ function FeatureTrainingCarousel({
         onConfirm?.(false);
     };
 
+    useEffect(() => {
+        if (contentHeight <= containerHeight || onboardingIsMediumOrLargerScreenWidth || !shouldUseScrollView) {
+            return;
+        }
+        scrollViewRef.current?.scrollToEnd({animated: false});
+    }, [contentHeight, containerHeight, onboardingIsMediumOrLargerScreenWidth, shouldUseScrollView]);
+
+    const Wrapper = shouldUseScrollView ? ScrollView : View;
+
+    const wrapperStyles = shouldUseScrollView ? StyleUtils.getScrollableFeatureTrainingModalStyles(insets, isKeyboardActive) : {};
+
     const carouselPaginationDots = pages.map((_page, index) => (
         <View
             // The array is static for the modal's lifetime, so the index is a stable key.
@@ -128,22 +155,30 @@ function FeatureTrainingCarousel({
     const currentPageData = pages.at(currentPage);
 
     return (
-        <View
-            // On narrow viewports (mWeb BOTTOM_DOCKED) the outer View has no intrinsic content
-            // (everything below is gated by `carouselViewportWidth > 0`), so without `w100` it
-            // collapses to 0×0 inside the `fit-content` modal sheet — `onLayout` then never
-            // fires with a positive width and the carousel never renders, leaving the modal
-            // backdrop visible with no content. `w100` makes the View stretch to the sheet's
-            // known full width and lets `onLayout` resolve immediately. On medium+ screens the
-            // explicit `getWidthStyle(width)` continues to apply.
-            style={[onboardingIsMediumOrLargerScreenWidth ? StyleUtils.getWidthStyle(width) : styles.w100]}
+        <Wrapper
+            scrollsToTop={false}
+            style={[
+                onboardingIsMediumOrLargerScreenWidth && StyleUtils.getWidthStyle(width),
+                isInLandscapeMode ? {maxHeight: windowHeight * CONST.MODAL_MAX_HEIGHT_TO_WINDOW_HEIGHT_RATIO_LANDSCAPE_MODE} : styles.mh100,
+            ]}
+            contentContainerStyle={isInLandscapeMode ? wrapperStyles.containerStyle : undefined}
+            keyboardShouldPersistTaps={shouldUseScrollView ? 'handled' : undefined}
+            ref={shouldUseScrollView ? scrollViewRef : undefined}
             onLayout={(e: LayoutChangeEvent) => {
                 const newWidth = e.nativeEvent.layout.width;
                 if (newWidth === carouselViewportWidth || newWidth <= 0) {
                     return;
                 }
+                console.log(newWidth, e.nativeEvent.layout.height);
                 setCarouselViewportWidth(newWidth);
+                if (!shouldUseScrollView) {
+                    return;
+                }
+                setContainerHeight(e.nativeEvent.layout.height);
             }}
+            onContentSizeChange={shouldUseScrollView ? (_w: number, h: number) => setContentHeight(h) : undefined}
+            // eslint-disable-next-line react/forbid-component-props -- fsClass is required for FullStory session masking
+            fsClass={CONST.FULLSTORY.CLASS.UNMASK}
         >
             {carouselViewportWidth > 0 && contentMinHeight === undefined && (
                 // Probe layer is used to measure the tallest page to lock the modal height
@@ -192,6 +227,7 @@ function FeatureTrainingCarousel({
                             disableIntervalMomentum
                             snapToInterval={carouselViewportWidth}
                             decelerationRate="fast"
+                            bounces={false}
                             showsHorizontalScrollIndicator={false}
                             keyboardShouldPersistTaps="handled"
                             viewabilityConfig={CAROUSEL_VIEWABILITY_CONFIG}
@@ -264,7 +300,7 @@ function FeatureTrainingCarousel({
                     </View>
                 </>
             )}
-        </View>
+        </Wrapper>
     );
 }
 
diff --git a/src/components/FeatureTrainingContent/FeatureTrainingContentIllustration.tsx b/src/components/FeatureTrainingContent/FeatureTrainingContentIllustration.tsx
index 50b99cedacb0..23c813ff537d 100644
--- a/src/components/FeatureTrainingContent/FeatureTrainingContentIllustration.tsx
+++ b/src/components/FeatureTrainingContent/FeatureTrainingContentIllustration.tsx
@@ -115,7 +115,7 @@ function FeatureTrainingContentIllustration({
                     // Prevent layout jumps by reserving height for the video until it loads.
                     // When videoStatus === 'animation' it preserves the same aspect ratio.
                     illustrationInnerContainerStyle,
-                    (!!videoURL || !!image || !!animation) && {aspectRatio},
+                    (!!videoURL || !!image || (!!animation && !!illustrationAspectRatioProp)) && {aspectRatio},
                 ]}
             >
                 {!!image &&
diff --git a/src/libs/Navigation/guards/AIFeaturesPromoGuard.ts b/src/libs/Navigation/guards/AIFeaturesPromoGuard.ts
index bf66a3931a44..36a8bfbe540f 100644
--- a/src/libs/Navigation/guards/AIFeaturesPromoGuard.ts
+++ b/src/libs/Navigation/guards/AIFeaturesPromoGuard.ts
@@ -157,6 +157,7 @@ Onyx.connectWithoutView({
         if (hasBeenAddedToNudgeMigration && !isProductTrainingElementDismissed(CONST.MIGRATED_USER_WELCOME_MODAL, value)) {
             observedActiveMigrationModalThisSession = true;
         }
+        navigateToAIFeaturesPromoModalIfReady();
     },
 });
 

From 82bdce5c29ff69ab58b45bf9997767b2b98d0223 Mon Sep 17 00:00:00 2001
From: gijoe0295 <153004152+gijoe0295@users.noreply.github.com>
Date: Fri, 26 Jun 2026 05:40:44 +0700
Subject: [PATCH 16/35] fix https://github.com/Expensify/App/issues/93970

---
 .../FeatureTrainingCarousel.tsx                  | 16 +++-------------
 1 file changed, 3 insertions(+), 13 deletions(-)

diff --git a/src/components/FeatureTrainingContent/FeatureTrainingCarousel.tsx b/src/components/FeatureTrainingContent/FeatureTrainingCarousel.tsx
index cfe65af019da..ad30f24958ad 100644
--- a/src/components/FeatureTrainingContent/FeatureTrainingCarousel.tsx
+++ b/src/components/FeatureTrainingContent/FeatureTrainingCarousel.tsx
@@ -2,7 +2,6 @@ import React, {useEffect, useRef, useState} from 'react';
 import {FlatList, Platform, View} from 'react-native';
 // eslint-disable-next-line no-restricted-imports -- Type import needed for ref typing; no wrapper available
 import type {LayoutChangeEvent, FlatList as RNFlatList, ScrollView as RNScrollView, ViewabilityConfig, ViewStyle, ViewToken} from 'react-native';
-import {useSafeAreaInsets} from 'react-native-safe-area-context';
 import Icon from '@components/Icon';
 import PressableWithFeedback from '@components/Pressable/PressableWithFeedback';
 import ScrollView from '@components/ScrollView';
@@ -11,6 +10,7 @@ import useKeyboardState from '@hooks/useKeyboardState';
 import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
 import useLocalize from '@hooks/useLocalize';
 import useResponsiveLayout from '@hooks/useResponsiveLayout';
+import useSafeAreaInsets from '@hooks/useSafeAreaInsets';
 import useStyleUtils from '@hooks/useStyleUtils';
 import useTheme from '@hooks/useTheme';
 import useThemeStyles from '@hooks/useThemeStyles';
@@ -32,12 +32,7 @@ const CAROUSEL_VIEWABILITY_CONFIG: ViewabilityConfig = {itemVisiblePercentThresh
 const CAROUSEL_DOT_SIZE = 6;
 const PAGINATION_DOTS_BOTTOM_OFFSET = 5;
 
-// react-native-web translates `pagingEnabled` to CSS scroll-snap, but `scroll-snap-align` only
-// lands on the ScrollView's direct children — our per-page View (inside `renderItem`) isn't a
-// direct child, so the FlatList ends up with a single snap point and fast flings skip pages.
-// Applying `scrollSnapAlign: 'start'` on each page wrapper turns every page into a snap point.
-// Native ignores this CSS-only key, so we still gate it on Platform.OS === 'web' for clarity.
-const WEB_CAROUSEL_PAGE_SNAP_STYLE: ViewStyle = Platform.OS === 'web' ? ({scrollSnapAlign: 'start'} as ViewStyle) : {};
+const WEB_CAROUSEL_PAGE_SNAP_STYLE: ViewStyle = Platform.OS === 'web' ? ({scrollSnapAlign: 'start', scrollSnapStop: 'always'} as ViewStyle) : {};
 
 function FeatureTrainingCarousel({
     pages,
@@ -159,6 +154,7 @@ function FeatureTrainingCarousel({
             scrollsToTop={false}
             style={[
                 onboardingIsMediumOrLargerScreenWidth && StyleUtils.getWidthStyle(width),
+                wrapperStyles.style,
                 isInLandscapeMode ? {maxHeight: windowHeight * CONST.MODAL_MAX_HEIGHT_TO_WINDOW_HEIGHT_RATIO_LANDSCAPE_MODE} : styles.mh100,
             ]}
             contentContainerStyle={isInLandscapeMode ? wrapperStyles.containerStyle : undefined}
@@ -169,7 +165,6 @@ function FeatureTrainingCarousel({
                 if (newWidth === carouselViewportWidth || newWidth <= 0) {
                     return;
                 }
-                console.log(newWidth, e.nativeEvent.layout.height);
                 setCarouselViewportWidth(newWidth);
                 if (!shouldUseScrollView) {
                     return;
@@ -219,11 +214,6 @@ function FeatureTrainingCarousel({
                             keyExtractor={(_page, index) => `FeatureTrainingContentIllustration-${index}`}
                             horizontal
                             pagingEnabled
-                            // Defense-in-depth on native: `pagingEnabled` is honored by the
-                            // platform, but these props arrest momentum precisely at each page
-                            // so fast flings never coast over a middle page. On web they're
-                            // no-ops; `WEB_CAROUSEL_PAGE_SNAP_STYLE` below does the equivalent
-                            // work via CSS scroll-snap.
                             disableIntervalMomentum
                             snapToInterval={carouselViewportWidth}
                             decelerationRate="fast"

From 4f745eabf6c6c58e6b3f6530be0536e324c3afd6 Mon Sep 17 00:00:00 2001
From: gijoe0295 <153004152+gijoe0295@users.noreply.github.com>
Date: Fri, 26 Jun 2026 06:17:18 +0700
Subject: [PATCH 17/35] remove redundant comments

---
 .../Navigation/guards/AIFeaturesPromoGuard.ts     | 15 +--------------
 1 file changed, 1 insertion(+), 14 deletions(-)

diff --git a/src/libs/Navigation/guards/AIFeaturesPromoGuard.ts b/src/libs/Navigation/guards/AIFeaturesPromoGuard.ts
index 36a8bfbe540f..004efb81afac 100644
--- a/src/libs/Navigation/guards/AIFeaturesPromoGuard.ts
+++ b/src/libs/Navigation/guards/AIFeaturesPromoGuard.ts
@@ -31,10 +31,6 @@ let isTryNewDotLoaded = false;
 let onboarding: OnyxEntry<Onboarding>;
 let isOnboardingLoaded = false;
 
-// A copilot (delegate) session must never re-prompt the host user's one-time AI promo —
-// `clearOnyxForDelegateTransition` wipes most NVPs when switching accounts (NVP_DISMISSED_PRODUCT_TRAINING
-// is not in `KEYS_TO_PRESERVE_DELEGATE_ACCESS`), so without this gate the modal would re-show
-// for every copilot switch. Mirrors `ProductTrainingContext`'s `isActingAsDelegate` short-circuit.
 let isActingAsDelegate = false;
 
 let hasRedirectedToAIFeaturesPromoModal = false;
@@ -193,13 +189,6 @@ Onyx.connectWithoutView({
 
 /**
  * Block navigation while the AI features promo modal is active (on top of the stack).
- *
- * The block exists only to keep an underlying in-app tab swipe from racing the modal overlay
- * (the original use case mirrored from MigratedUserWelcomeModalGuard). It must NOT swallow
- * top-level intents that come from outside the app — share intents and report deep-links arrive
- * as `RESET` actions (via `getAdaptedStateFromPath`) or as `NAVIGATE`/`PUSH` actions whose
- * `payload.name` is a sibling root-stack navigator (e.g. `SHARE_MODAL_NAVIGATOR`). Blocking
- * those silently drops the navigation and strands the user on the modal.
  */
 function shouldBlockWhileModalActive(state: NavigationState, action: NavigationAction): boolean {
     if (
@@ -220,9 +209,7 @@ function shouldBlockWhileModalActive(state: NavigationState, action: NavigationA
         return false;
     }
 
-    // For NAVIGATE/PUSH/REPLACE actions, only block if the target lives inside the AI promo
-    // navigator (i.e. an in-modal sub-route). If the target is any other navigator/screen the
-    // navigation should proceed — the new screen will overlay or replace our modal naturally.
+    // For NAVIGATE/PUSH/REPLACE actions, only block if the target lives inside the AI promo navigator.
     const targetName = (action.payload as {name?: string} | undefined)?.name;
     if (targetName && targetName !== NAVIGATORS.AI_FEATURES_PROMO_MODAL_NAVIGATOR) {
         return false;

From 2fec414e716bd3934f3ed2d5874eae463900e7bb Mon Sep 17 00:00:00 2001
From: gijoe0295 <153004152+gijoe0295@users.noreply.github.com>
Date: Fri, 26 Jun 2026 16:21:58 +0700
Subject: [PATCH 18/35] resolve feedbacks

---
 src/CONST/index.ts                                    |  5 +++++
 src/components/AIFeaturesPromoModal.tsx               |  8 +++++---
 .../FeatureTrainingCarousel.tsx                       |  4 ++--
 .../FeatureTrainingContentBody.tsx                    |  3 ++-
 src/components/Lottie/index.tsx                       |  1 +
 src/styles/utils/index.ts                             | 11 +++++++++++
 6 files changed, 26 insertions(+), 6 deletions(-)

diff --git a/src/CONST/index.ts b/src/CONST/index.ts
index a2f0c69834c2..06246175134d 100644
--- a/src/CONST/index.ts
+++ b/src/CONST/index.ts
@@ -8872,6 +8872,11 @@ const CONST = {
         },
         FEATURE_TRAINING: {
             CLOSE_BUTTON: 'FeatureTraining-CloseButton',
+            BACK_BUTTON: 'FeatureTraining-BackButton',
+        },
+        AI_FEATURES_PROMO_MODAL: {
+            CONFIRM_BUTTON: 'AIFeaturesPromoModal-ConfirmButton',
+            HELP_BUTTON: 'AIFeaturesPromoModal-HelpButton',
         },
     },
 
diff --git a/src/components/AIFeaturesPromoModal.tsx b/src/components/AIFeaturesPromoModal.tsx
index 3f646e0406e8..985b002eb062 100644
--- a/src/components/AIFeaturesPromoModal.tsx
+++ b/src/components/AIFeaturesPromoModal.tsx
@@ -65,19 +65,19 @@ function AIFeaturesPromoModal() {
     const isCloseButtonDismissalRef = useRef(false);
 
     const handleDismiss = () => {
-        Log.hmmm(`[AIFeaturesPromoModal] dismissing product training via ${isCloseButtonDismissalRef.current ? 'x' : 'click'}`);
+        Log.info(`[AIFeaturesPromoModal] dismissing product training via ${isCloseButtonDismissalRef.current ? 'x' : 'click'}`);
         dismissProductTraining(CONST.AI_FEATURES_PROMO_MODAL, isCloseButtonDismissalRef.current);
     };
 
     useBeforeRemove(handleDismiss);
 
     const onConfirm = () => {
-        Log.hmmm('[AIFeaturesPromoModal] onConfirm called');
+        Log.info('[AIFeaturesPromoModal] onConfirm called');
         Navigation.goBack();
     };
 
     const onClose = () => {
-        Log.hmmm(`[AIFeaturesPromoModal] onClose called, user closed modal without confirming`);
+        Log.info(`[AIFeaturesPromoModal] onClose called, user closed modal without confirming`);
         isCloseButtonDismissalRef.current = true;
         Navigation.goBack();
     };
@@ -99,6 +99,8 @@ function AIFeaturesPromoModal() {
                 illustrationAspectRatio={LottieAnimations.SpendAnalysis.w / LottieAnimations.SpendAnalysis.h}
                 contentInnerContainerStyles={styles.mb4}
                 titleStyles={styles.mb2}
+                confirmSentryLabel={CONST.SENTRY_LABEL.AI_FEATURES_PROMO_MODAL.CONFIRM_BUTTON}
+                helpSentryLabel={CONST.SENTRY_LABEL.AI_FEATURES_PROMO_MODAL.HELP_BUTTON}
             />
         </CenteredModalLayout>
     );
diff --git a/src/components/FeatureTrainingContent/FeatureTrainingCarousel.tsx b/src/components/FeatureTrainingContent/FeatureTrainingCarousel.tsx
index ad30f24958ad..6b4cc24a0131 100644
--- a/src/components/FeatureTrainingContent/FeatureTrainingCarousel.tsx
+++ b/src/components/FeatureTrainingContent/FeatureTrainingCarousel.tsx
@@ -246,7 +246,7 @@ function FeatureTrainingCarousel({
                                 styles.w100,
                                 styles.l0,
                                 styles.r0,
-                                {bottom: PAGINATION_DOTS_BOTTOM_OFFSET + CONTENT_PADDING},
+                                StyleUtils.getFeatureTrainingCarouselDotsContainerStyle(PAGINATION_DOTS_BOTTOM_OFFSET + CONTENT_PADDING),
                             ]}
                         >
                             {carouselPaginationDots}
@@ -272,7 +272,7 @@ function FeatureTrainingCarousel({
                         contentOuterContainerStyles={contentOuterContainerStyles}
                         shouldRenderHTMLDescription={shouldRenderHTMLDescription}
                     />
-                    <View style={[styles.pAbsolute, {top: CONTENT_PADDING, right: CONTENT_PADDING, zIndex: 1}]}>
+                    <View style={StyleUtils.getFeatureTrainingCarouselCloseButtonContainerStyle(CONTENT_PADDING)}>
                         <Tooltip text={translate('common.close')}>
                             <PressableWithFeedback
                                 onPress={onClose}
diff --git a/src/components/FeatureTrainingContent/FeatureTrainingContentBody.tsx b/src/components/FeatureTrainingContent/FeatureTrainingContentBody.tsx
index c3730e2a6f0a..4b05dac4ed2f 100644
--- a/src/components/FeatureTrainingContent/FeatureTrainingContentBody.tsx
+++ b/src/components/FeatureTrainingContent/FeatureTrainingContentBody.tsx
@@ -6,6 +6,7 @@ import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton';
 import OfflineIndicator from '@components/OfflineIndicator';
 import useLocalize from '@hooks/useLocalize';
 import useThemeStyles from '@hooks/useThemeStyles';
+import CONST from '@src/CONST';
 import FeatureTrainingContentBodyText from './FeatureTrainingContentBodyText';
 import type {FeatureTrainingContentBodyProps as BaseFeatureTrainingContentBodyProps, BaseFeatureTrainingContentProps} from './types';
 
@@ -103,7 +104,7 @@ function FeatureTrainingContentBody({
                         large
                         onPress={onBack}
                         text={translate('common.back')}
-                        sentryLabel={confirmSentryLabel}
+                        sentryLabel={CONST.SENTRY_LABEL.FEATURE_TRAINING.BACK_BUTTON}
                         style={styles.flex1}
                     />
                 )}
diff --git a/src/components/Lottie/index.tsx b/src/components/Lottie/index.tsx
index 500a7dc08442..9372bb36d94f 100644
--- a/src/components/Lottie/index.tsx
+++ b/src/components/Lottie/index.tsx
@@ -126,6 +126,7 @@ function Lottie({ref, source, webStyle, shouldLoadAfterInteractions, ...props}:
                 if (typeof ref === 'function') {
                     ref(newRef);
                 } else if (ref && 'current' in ref) {
+                    // Forwarding the LottieView ref onto the caller-provided RefObject requires mutating `.current`.
                     // eslint-disable-next-line no-param-reassign
                     ref.current = newRef;
                 }
diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts
index cbcb7e399dde..24093cb29974 100644
--- a/src/styles/utils/index.ts
+++ b/src/styles/utils/index.ts
@@ -2121,6 +2121,17 @@ const createStyleUtils = (theme: ThemeColors, styles: ThemeStyles) => ({
         opacity: isActive ? 1 : 0.3,
     }),
 
+    getFeatureTrainingCarouselCloseButtonContainerStyle: (padding: number): ViewStyle => ({
+        position: 'absolute',
+        top: padding,
+        right: padding,
+        zIndex: 1,
+    }),
+
+    getFeatureTrainingCarouselDotsContainerStyle: (bottomOffset: number): ViewStyle => ({
+        bottom: bottomOffset,
+    }),
+
     getScrollableFeatureTrainingModalStyles: (
         insets: EdgeInsets,
         isKeyboardOpen = false,

From 4a8e03554939b11906756b8a79a2ef4a89c68576 Mon Sep 17 00:00:00 2001
From: gijoe0295 <153004152+gijoe0295@users.noreply.github.com>
Date: Fri, 26 Jun 2026 17:10:14 +0700
Subject: [PATCH 19/35] Reapply https://github.com/Expensify/App/pull/94599

---
 .../FeatureTrainingCarousel.tsx               |  8 -----
 .../FeatureTrainingContent.tsx                |  8 -----
 .../FeatureTrainingContentBody.tsx            | 11 +-----
 .../FeatureTrainingContentBodyText.tsx        |  5 ---
 .../FeatureTrainingContentIllustration.tsx    | 36 ++++++-------------
 .../FeatureTrainingContent/types.ts           | 12 -------
 6 files changed, 12 insertions(+), 68 deletions(-)

diff --git a/src/components/FeatureTrainingContent/FeatureTrainingCarousel.tsx b/src/components/FeatureTrainingContent/FeatureTrainingCarousel.tsx
index 6b4cc24a0131..94641aa9f5b0 100644
--- a/src/components/FeatureTrainingContent/FeatureTrainingCarousel.tsx
+++ b/src/components/FeatureTrainingContent/FeatureTrainingCarousel.tsx
@@ -41,15 +41,12 @@ function FeatureTrainingCarousel({
     illustrationAspectRatio,
     illustrationInnerContainerStyle,
     illustrationOuterContainerStyle,
-    shouldRenderSVG = true,
     shouldRenderHTMLDescription = false,
     shouldUseScrollView: shouldUseScrollViewProp = false,
     helpText = '',
     onHelp = () => {},
     helpSentryLabel,
     confirmSentryLabel,
-    shouldShowConfirmationLoader = false,
-    canConfirmWhileOffline = true,
     contentInnerContainerStyles,
     contentOuterContainerStyles,
     onConfirm,
@@ -195,7 +192,6 @@ function FeatureTrainingCarousel({
                                 title={page.title}
                                 subtitle={page.subtitle}
                                 description={page.description}
-                                secondaryDescription={page.secondaryDescription}
                                 titleStyles={titleStyles}
                                 contentInnerContainerStyles={contentInnerContainerStyles}
                                 shouldRenderHTMLDescription={shouldRenderHTMLDescription}
@@ -229,7 +225,6 @@ function FeatureTrainingCarousel({
                                         illustrationAspectRatio={illustrationAspectRatio}
                                         illustrationInnerContainerStyle={illustrationInnerContainerStyle}
                                         illustrationOuterContainerStyle={illustrationOuterContainerStyle}
-                                        shouldRenderSVG={shouldRenderSVG}
                                         isCarousel
                                         isFocused={index === currentPage}
                                         {...page}
@@ -256,7 +251,6 @@ function FeatureTrainingCarousel({
                         title={currentPageData?.title}
                         subtitle={currentPageData?.subtitle}
                         description={currentPageData?.description}
-                        secondaryDescription={currentPageData?.secondaryDescription}
                         confirmText={currentPageData?.confirmText ?? ''}
                         helpText={helpText}
                         onHelp={onHelp}
@@ -265,8 +259,6 @@ function FeatureTrainingCarousel({
                         onConfirm={handleConfirmPress}
                         shouldShowBackButton={currentPage > 0}
                         onBack={goBack}
-                        shouldShowConfirmationLoader={shouldShowConfirmationLoader}
-                        canConfirmWhileOffline={canConfirmWhileOffline}
                         titleStyles={titleStyles}
                         contentInnerContainerStyles={[contentInnerContainerStyles, contentMinHeight !== undefined && {minHeight: contentMinHeight}]}
                         contentOuterContainerStyles={contentOuterContainerStyles}
diff --git a/src/components/FeatureTrainingContent/FeatureTrainingContent.tsx b/src/components/FeatureTrainingContent/FeatureTrainingContent.tsx
index 928038fd5ccc..2beaa2cad824 100644
--- a/src/components/FeatureTrainingContent/FeatureTrainingContent.tsx
+++ b/src/components/FeatureTrainingContent/FeatureTrainingContent.tsx
@@ -24,7 +24,6 @@ function FeatureTrainingContent({
     title = '',
     subtitle = '',
     description = '',
-    secondaryDescription = '',
     titleStyles,
     shouldShowDismissModalOption = false,
     confirmText = '',
@@ -36,12 +35,9 @@ function FeatureTrainingContent({
     children,
     contentInnerContainerStyles,
     contentOuterContainerStyles,
-    shouldRenderSVG = true,
     shouldRenderHTMLDescription = false,
     shouldCloseOnConfirm = true,
     shouldUseScrollView: shouldUseScrollViewProp = false,
-    shouldShowConfirmationLoader = false,
-    canConfirmWhileOffline = true,
     helpSentryLabel,
     confirmSentryLabel,
     ...props
@@ -103,14 +99,12 @@ function FeatureTrainingContent({
                 illustrationAspectRatio={illustrationAspectRatioProp}
                 illustrationInnerContainerStyle={illustrationInnerContainerStyle}
                 illustrationOuterContainerStyle={illustrationOuterContainerStyle}
-                shouldRenderSVG={shouldRenderSVG}
                 {...props}
             />
             <FeatureTrainingContentBody
                 title={title}
                 subtitle={subtitle}
                 description={description}
-                secondaryDescription={secondaryDescription}
                 confirmText={confirmText}
                 helpText={helpText}
                 onHelp={onHelp}
@@ -120,8 +114,6 @@ function FeatureTrainingContent({
                 willShowAgain={willShowAgain}
                 toggleWillShowAgain={toggleWillShowAgain}
                 onConfirm={handleConfirm}
-                shouldShowConfirmationLoader={shouldShowConfirmationLoader}
-                canConfirmWhileOffline={canConfirmWhileOffline}
                 titleStyles={titleStyles}
                 contentInnerContainerStyles={contentInnerContainerStyles}
                 contentOuterContainerStyles={contentOuterContainerStyles}
diff --git a/src/components/FeatureTrainingContent/FeatureTrainingContentBody.tsx b/src/components/FeatureTrainingContent/FeatureTrainingContentBody.tsx
index 4b05dac4ed2f..296d0980e6ab 100644
--- a/src/components/FeatureTrainingContent/FeatureTrainingContentBody.tsx
+++ b/src/components/FeatureTrainingContent/FeatureTrainingContentBody.tsx
@@ -3,7 +3,6 @@ import {View} from 'react-native';
 import Button from '@components/Button';
 import CheckboxWithLabel from '@components/CheckboxWithLabel';
 import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton';
-import OfflineIndicator from '@components/OfflineIndicator';
 import useLocalize from '@hooks/useLocalize';
 import useThemeStyles from '@hooks/useThemeStyles';
 import CONST from '@src/CONST';
@@ -18,8 +17,6 @@ type FeatureTrainingModalContentProps = Pick<
     | 'helpSentryLabel'
     | 'confirmSentryLabel'
     | 'shouldShowDismissModalOption'
-    | 'shouldShowConfirmationLoader'
-    | 'canConfirmWhileOffline'
     | 'titleStyles'
     | 'contentInnerContainerStyles'
     | 'contentOuterContainerStyles'
@@ -44,7 +41,6 @@ function FeatureTrainingContentBody({
     title = '',
     subtitle = '',
     description = '',
-    secondaryDescription = '',
     confirmText,
     onConfirm,
     helpText = '',
@@ -56,8 +52,6 @@ function FeatureTrainingContentBody({
     toggleWillShowAgain,
     shouldShowBackButton = false,
     onBack,
-    shouldShowConfirmationLoader = false,
-    canConfirmWhileOffline = true,
     titleStyles,
     contentInnerContainerStyles,
     contentOuterContainerStyles,
@@ -73,7 +67,6 @@ function FeatureTrainingContentBody({
                 title={title}
                 subtitle={subtitle}
                 description={description}
-                secondaryDescription={secondaryDescription}
                 titleStyles={titleStyles}
                 contentInnerContainerStyles={contentInnerContainerStyles}
                 shouldRenderHTMLDescription={shouldRenderHTMLDescription}
@@ -110,14 +103,12 @@ function FeatureTrainingContentBody({
                 )}
                 <FormAlertWithSubmitButton
                     onSubmit={() => onConfirm?.(willShowAgain)}
-                    isLoading={shouldShowConfirmationLoader}
                     buttonText={confirmText}
-                    enabledWhenOffline={canConfirmWhileOffline}
+                    enabledWhenOffline
                     sentryLabel={confirmSentryLabel}
                     containerStyles={styles.flex1}
                 />
             </View>
-            {!canConfirmWhileOffline && <OfflineIndicator />}
         </View>
     );
 }
diff --git a/src/components/FeatureTrainingContent/FeatureTrainingContentBodyText.tsx b/src/components/FeatureTrainingContent/FeatureTrainingContentBodyText.tsx
index b0ad4d2c851c..4ebcc0873445 100644
--- a/src/components/FeatureTrainingContent/FeatureTrainingContentBodyText.tsx
+++ b/src/components/FeatureTrainingContent/FeatureTrainingContentBodyText.tsx
@@ -16,9 +16,6 @@ type FeatureTrainingModalTextContentProps = {
     /** Describe what is showing */
     description?: string;
 
-    /** Secondary description rendered with additional space */
-    secondaryDescription?: string;
-
     /** Style for the title */
     titleStyles?: StyleProp<TextStyle>;
 
@@ -39,7 +36,6 @@ function FeatureTrainingContentBodyText({
     title = '',
     subtitle = '',
     description = '',
-    secondaryDescription = '',
     titleStyles,
     contentInnerContainerStyles,
     shouldRenderHTMLDescription = false,
@@ -67,7 +63,6 @@ function FeatureTrainingContentBodyText({
             ) : (
                 <Text style={styles.textSupporting}>{description}</Text>
             )}
-            {!!secondaryDescription && <Text style={[styles.textSupporting, styles.mt4]}>{secondaryDescription}</Text>}
             {children}
         </View>
     );
diff --git a/src/components/FeatureTrainingContent/FeatureTrainingContentIllustration.tsx b/src/components/FeatureTrainingContent/FeatureTrainingContentIllustration.tsx
index 23c813ff537d..698601325144 100644
--- a/src/components/FeatureTrainingContent/FeatureTrainingContentIllustration.tsx
+++ b/src/components/FeatureTrainingContent/FeatureTrainingContentIllustration.tsx
@@ -1,8 +1,7 @@
 import type {SourceLoadEventPayload} from 'expo-video';
 import type LottieView from 'lottie-react-native';
 import React, {useEffect, useRef, useState} from 'react';
-import {Image, View} from 'react-native';
-import type {ImageResizeMode, ImageSourcePropType} from 'react-native';
+import {View} from 'react-native';
 import {GestureHandlerRootView} from 'react-native-gesture-handler';
 import ImageSVG from '@components/ImageSVG';
 import Lottie from '@components/Lottie';
@@ -30,10 +29,7 @@ const LANDSCAPE_ILLUSTRATION_MAX_HEIGHT_TO_WINDOW_HEIGHT_RATIO = 0.7;
 
 type VideoStatus = 'video' | 'animation';
 
-type FeatureTrainingContentIllustrationProps = Pick<
-    BaseFeatureTrainingContentProps,
-    'shouldRenderSVG' | 'illustrationAspectRatio' | 'illustrationInnerContainerStyle' | 'illustrationOuterContainerStyle'
-> &
+type FeatureTrainingContentIllustrationProps = Pick<BaseFeatureTrainingContentProps, 'illustrationAspectRatio' | 'illustrationInnerContainerStyle' | 'illustrationOuterContainerStyle'> &
     BaseFeatureTrainingContentIllustrationProps & {
         /** Whether this illustration belongs to the currently-visible carousel page */
         isFocused?: boolean;
@@ -66,7 +62,6 @@ function FeatureTrainingContentIllustration({
     illustrationAspectRatio: illustrationAspectRatioProp,
     illustrationInnerContainerStyle,
     illustrationOuterContainerStyle,
-    shouldRenderSVG = true,
     isFocused = true,
     isCarousel = false,
 }: FeatureTrainingContentIllustrationProps) {
@@ -118,24 +113,15 @@ function FeatureTrainingContentIllustration({
                     (!!videoURL || !!image || (!!animation && !!illustrationAspectRatioProp)) && {aspectRatio},
                 ]}
             >
-                {!!image &&
-                    (shouldRenderSVG ? (
-                        <ImageSVG
-                            src={image}
-                            contentFit={contentFitImage}
-                            width={imageWidth}
-                            height={imageHeight}
-                            testID={CONST.IMAGE_SVG_TEST_ID}
-                        />
-                    ) : (
-                        <Image
-                            accessibilityIgnoresInvertColors
-                            source={image as ImageSourcePropType}
-                            resizeMode={contentFitImage as ImageResizeMode}
-                            style={styles.featureTrainingModalImage}
-                            testID={CONST.IMAGE_TEST_ID}
-                        />
-                    ))}
+                {!!image && (
+                    <ImageSVG
+                        src={image}
+                        contentFit={contentFitImage}
+                        width={imageWidth}
+                        height={imageHeight}
+                        testID={CONST.IMAGE_SVG_TEST_ID}
+                    />
+                )}
                 {!!videoURL && videoStatus === 'video' && (
                     <GestureHandlerRootView>
                         <VideoPlayer
diff --git a/src/components/FeatureTrainingContent/types.ts b/src/components/FeatureTrainingContent/types.ts
index 2a624e132d65..573446d06249 100644
--- a/src/components/FeatureTrainingContent/types.ts
+++ b/src/components/FeatureTrainingContent/types.ts
@@ -55,9 +55,6 @@ type BaseFeatureTrainingContentProps = {
     /** Whether to disable the modal */
     isModalDisabled?: boolean;
 
-    /** Whether the modal image is a SVG */
-    shouldRenderSVG?: boolean;
-
     /** Whether the modal description is written in HTML */
     shouldRenderHTMLDescription?: boolean;
 
@@ -70,12 +67,6 @@ type BaseFeatureTrainingContentProps = {
     /** Whether the modal content is scrollable */
     shouldUseScrollView?: boolean;
 
-    /** Whether the modal is displaying a confirmation loading spinner (useful when fetching data from API during confirmation) */
-    shouldShowConfirmationLoader?: boolean;
-
-    /** Whether the user can confirm the tutorial while offline */
-    canConfirmWhileOffline?: boolean;
-
     /** Whether to navigate back when closing the modal */
     shouldGoBack?: boolean;
 
@@ -99,9 +90,6 @@ type FeatureTrainingContentBodyProps = {
     /** Describe what is showing */
     description?: string;
 
-    /** Secondary description rendered with additional space */
-    secondaryDescription?: string;
-
     /** Text to show on primary button */
     confirmText: string;
 };

From 23e869ac4060495b0c0b8c8c33655e0a4c694c81 Mon Sep 17 00:00:00 2001
From: gijoe0295 <153004152+gijoe0295@users.noreply.github.com>
Date: Fri, 26 Jun 2026 17:22:56 +0700
Subject: [PATCH 20/35] fix: animation flickers when moving

---
 .../FeatureTrainingContentIllustration.tsx                    | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/src/components/FeatureTrainingContent/FeatureTrainingContentIllustration.tsx b/src/components/FeatureTrainingContent/FeatureTrainingContentIllustration.tsx
index 698601325144..1494def97aa4 100644
--- a/src/components/FeatureTrainingContent/FeatureTrainingContentIllustration.tsx
+++ b/src/components/FeatureTrainingContent/FeatureTrainingContentIllustration.tsx
@@ -80,9 +80,9 @@ function FeatureTrainingContentIllustration({
             return;
         }
         if (isFocused) {
-            animationRef.current.play(0);
+            animationRef.current.play();
         } else {
-            animationRef.current.reset();
+            animationRef.current.pause();
         }
     }, [isFocused, isCarousel, isReduceMotionEnabled]);
 

From 6e1ce64eee6e64452a485f9b623a7a900b41da17 Mon Sep 17 00:00:00 2001
From: gijoe0295 <153004152+gijoe0295@users.noreply.github.com>
Date: Fri, 26 Jun 2026 17:27:50 +0700
Subject: [PATCH 21/35] remove redundant code comments

---
 src/libs/Navigation/guards/AIFeaturesPromoGuard.ts | 4 ----
 1 file changed, 4 deletions(-)

diff --git a/src/libs/Navigation/guards/AIFeaturesPromoGuard.ts b/src/libs/Navigation/guards/AIFeaturesPromoGuard.ts
index 004efb81afac..591340c4071a 100644
--- a/src/libs/Navigation/guards/AIFeaturesPromoGuard.ts
+++ b/src/libs/Navigation/guards/AIFeaturesPromoGuard.ts
@@ -228,10 +228,6 @@ function isNavigatingToAIFeaturesPromoModal(state: NavigationState, action: Navi
 
 /**
  * AIFeaturesPromoGuard surfaces the one-time AI features promo modal.
- *
- * This guard relies on the proactive Onyx-driven path (navigateToAIFeaturesPromoModalIfReady)
- * rather than redirecting from evaluate(), because it needs to wait for higher-priority guards
- * (Onboarding, MigratedUserWelcomeModal) to settle before deciding whether to fire.
  */
 const AIFeaturesPromoGuard: NavigationGuard = {
     name: 'AIFeaturesPromoGuard',

From 4a7b89e093fcb9b0de7d72a05bc1f899a7649ba6 Mon Sep 17 00:00:00 2001
From: gijoe0295 <153004152+gijoe0295@users.noreply.github.com>
Date: Fri, 26 Jun 2026 23:48:56 +0700
Subject: [PATCH 22/35] resolved comments

---
 src/libs/Navigation/guards/AIFeaturesPromoGuard.ts | 5 +++--
 1 file changed, 3 insertions(+), 2 deletions(-)

diff --git a/src/libs/Navigation/guards/AIFeaturesPromoGuard.ts b/src/libs/Navigation/guards/AIFeaturesPromoGuard.ts
index 591340c4071a..731344f41524 100644
--- a/src/libs/Navigation/guards/AIFeaturesPromoGuard.ts
+++ b/src/libs/Navigation/guards/AIFeaturesPromoGuard.ts
@@ -205,7 +205,7 @@ function shouldBlockWhileModalActive(state: NavigationState, action: NavigationA
     }
 
     // RESET actions are how deep links / share intents enter the app — never block them.
-    if (action.type === 'RESET') {
+    if (action.type === CONST.NAVIGATION.ACTION_TYPE.RESET) {
         return false;
     }
 
@@ -221,7 +221,8 @@ function shouldBlockWhileModalActive(state: NavigationState, action: NavigationA
 /** Prevents redirect loops by detecting when we're already on or resetting to the modal. */
 function isNavigatingToAIFeaturesPromoModal(state: NavigationState, action: NavigationAction): boolean {
     const isOnModal = findFocusedRoute(state)?.name === SCREENS.AI_FEATURES_PROMO_MODAL.DYNAMIC_ROOT;
-    const isResettingToModal = action.type === 'RESET' && !!action.payload && findFocusedRoute(action.payload as NavigationState)?.name === SCREENS.AI_FEATURES_PROMO_MODAL.DYNAMIC_ROOT;
+    const isResettingToModal =
+        action.type === CONST.NAVIGATION.ACTION_TYPE.RESET && !!action.payload && findFocusedRoute(action.payload as NavigationState)?.name === SCREENS.AI_FEATURES_PROMO_MODAL.DYNAMIC_ROOT;
 
     return isOnModal || isResettingToModal;
 }

From 4f54860d29c7b3067390d52393a89c4e2d1df298 Mon Sep 17 00:00:00 2001
From: gijoe0295 <153004152+gijoe0295@users.noreply.github.com>
Date: Thu, 2 Jul 2026 22:31:20 +0700
Subject: [PATCH 23/35] update lint seatbelt

---
 config/eslint/eslint.seatbelt.tsv | 2 --
 1 file changed, 2 deletions(-)

diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv
index bb02c063e65c..ef9ff0050109 100644
--- a/config/eslint/eslint.seatbelt.tsv
+++ b/config/eslint/eslint.seatbelt.tsv
@@ -167,8 +167,6 @@
 "../../src/components/EmptySelectionListContent.tsx"	"@typescript-eslint/no-unsafe-type-assertion"	1
 "../../src/components/EnvironmentBadge.tsx"	"no-restricted-syntax"	1
 "../../src/components/ExplanationModal.tsx"	"no-restricted-syntax"	1
-"../../src/components/FeatureTrainingContent/FeatureTrainingContentIllustration.tsx"	"@typescript-eslint/no-unsafe-type-assertion"	2
-"../../src/components/FeatureTrainingModal/FeatureTrainingModalIllustration.tsx"	"react-hooks/set-state-in-effect"	1
 "../../src/components/FeedbackSurvey.tsx"	"react-hooks/set-state-in-effect"	1
 "../../src/components/FilePicker/index.native.tsx"	"react-hooks/refs"	1
 "../../src/components/FilePicker/index.tsx"	"react-hooks/refs"	1

From 73dc4c0ff98060ba0c0ed77f96fa474fbdcd9303 Mon Sep 17 00:00:00 2001
From: gijoe0295 <153004152+gijoe0295@users.noreply.github.com>
Date: Thu, 2 Jul 2026 23:09:20 +0700
Subject: [PATCH 24/35] Block pausing animation on mobile Chrome

---
 .../FeatureTrainingContentIllustration.tsx                   | 5 +++--
 1 file changed, 3 insertions(+), 2 deletions(-)

diff --git a/src/components/FeatureTrainingContent/FeatureTrainingContentIllustration.tsx b/src/components/FeatureTrainingContent/FeatureTrainingContentIllustration.tsx
index 1494def97aa4..fcdbc37e0867 100644
--- a/src/components/FeatureTrainingContent/FeatureTrainingContentIllustration.tsx
+++ b/src/components/FeatureTrainingContent/FeatureTrainingContentIllustration.tsx
@@ -13,6 +13,7 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout';
 import useThemeStyles from '@hooks/useThemeStyles';
 import useWindowDimensions from '@hooks/useWindowDimensions';
 import Accessibility from '@libs/Accessibility';
+import {isMobileChrome} from '@libs/Browser';
 import isInLandscapeModeUtil from '@libs/isInLandscapeMode';
 import {getIsOffline} from '@libs/NetworkState';
 import variables from '@styles/variables';
@@ -76,7 +77,7 @@ function FeatureTrainingContentIllustration({
 
     const animationRef = useRef<LottieView | null>(null);
     useEffect(() => {
-        if (!isCarousel || !animationRef.current || isReduceMotionEnabled) {
+        if (isMobileChrome() || !isCarousel || !animationRef.current || isReduceMotionEnabled) {
             return;
         }
         if (isFocused) {
@@ -148,7 +149,7 @@ function FeatureTrainingContentIllustration({
                                 source={animation ?? LottieAnimations.Hands}
                                 style={styles.h100}
                                 webStyle={shouldUseNarrowLayout ? styles.h100 : undefined}
-                                autoPlay={isFocused}
+                                autoPlay={isMobileChrome() || isFocused}
                                 loop
                             />
                         )}

From 34c94c29891c8cbe57616af848183118d2d2edfe Mon Sep 17 00:00:00 2001
From: gijoe0295 <153004152+gijoe0295@users.noreply.github.com>
Date: Fri, 3 Jul 2026 00:34:06 +0700
Subject: [PATCH 25/35] Extract modal guard to hook

---
 src/hooks/useAIFeaturesPromoModal.ts          | 101 +++++++
 .../AppNavigator/AuthScreensInitHandler.tsx   |   2 +
 .../Navigation/guards/AIFeaturesPromoGuard.ts | 259 ------------------
 src/libs/Navigation/guards/index.ts           |   4 -
 4 files changed, 103 insertions(+), 263 deletions(-)
 create mode 100644 src/hooks/useAIFeaturesPromoModal.ts
 delete mode 100644 src/libs/Navigation/guards/AIFeaturesPromoGuard.ts

diff --git a/src/hooks/useAIFeaturesPromoModal.ts b/src/hooks/useAIFeaturesPromoModal.ts
new file mode 100644
index 000000000000..d8d890307b25
--- /dev/null
+++ b/src/hooks/useAIFeaturesPromoModal.ts
@@ -0,0 +1,101 @@
+import {isActingAsDelegateSelector} from '@selectors/Account';
+import {hasCompletedGuidedSetupFlowSelector, tryNewDotOnyxSelector} from '@selectors/Onboarding';
+import {useEffect, useRef} from 'react';
+import type {OnyxEntry} from 'react-native-onyx';
+import Log from '@libs/Log';
+import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute';
+import Navigation from '@libs/Navigation/Navigation';
+import TransitionTracker from '@libs/Navigation/TransitionTracker';
+import isProductTrainingElementDismissed from '@libs/TooltipUtils';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES';
+import type {Session} from '@src/types/onyx';
+import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue';
+import useOnyx from './useOnyx';
+
+let hasRedirectedToAIFeaturesPromoModal = false;
+let observedActiveMigrationModalThisSession = false;
+let observedActiveOnboardingThisSession = false;
+
+/**
+ * Hook that navigates to the AI features promo modal if:
+ * - The user is not acting as a delegate; and
+ * - The user has not dismissed the AI features promo modal; and
+ * - The user has seen neither the migrated user welcome modal nor the onboarding modal in this session
+ */
+function useAIFeaturesPromoModal(session: OnyxEntry<Session>) {
+    const [isLoadingApp = true, isLoadingAppMetadata] = useOnyx(ONYXKEYS.IS_LOADING_APP);
+    const [isActingAsDelegate] = useOnyx(ONYXKEYS.ACCOUNT, {selector: isActingAsDelegateSelector});
+    const [dismissedProductTraining, dismissedProductTrainingMetadata] = useOnyx(ONYXKEYS.NVP_DISMISSED_PRODUCT_TRAINING);
+    const [tryNewDot, tryNewDotMetadata] = useOnyx(ONYXKEYS.NVP_TRY_NEW_DOT, {selector: tryNewDotOnyxSelector});
+    const [onboarding, onboardingMetadata] = useOnyx(ONYXKEYS.NVP_ONBOARDING);
+
+    const isWaitingForProtectedRoutes = useRef(false);
+
+    const hasBeenAddedToNudgeMigration = tryNewDot?.hasBeenAddedToNudgeMigration ?? false;
+    const isMigrationModalDismissed = isProductTrainingElementDismissed(CONST.MIGRATED_USER_WELCOME_MODAL, dismissedProductTraining);
+    const isAIPromoModalDismissed = isProductTrainingElementDismissed(CONST.AI_FEATURES_PROMO_MODAL, dismissedProductTraining);
+    const isMigrationModalPending = hasBeenAddedToNudgeMigration && !isMigrationModalDismissed;
+    const hasCompletedOnboarding = hasCompletedGuidedSetupFlowSelector(onboarding);
+
+    useEffect(() => {
+        if (isAIPromoModalDismissed) {
+            hasRedirectedToAIFeaturesPromoModal = false;
+        }
+    }, [isAIPromoModalDismissed]);
+
+    useEffect(() => {
+        if (isMigrationModalPending) {
+            observedActiveMigrationModalThisSession = true;
+        }
+    }, [isMigrationModalPending]);
+
+    useEffect(() => {
+        if (hasCompletedOnboarding === false) {
+            observedActiveOnboardingThisSession = true;
+        }
+    }, [hasCompletedOnboarding]);
+
+    const isAllOnyxLoaded = !isLoadingOnyxValue(isLoadingAppMetadata, dismissedProductTrainingMetadata, tryNewDotMetadata, onboardingMetadata);
+
+    const isEligible =
+        isAllOnyxLoaded &&
+        !!session?.authToken &&
+        !isLoadingApp &&
+        !isActingAsDelegate &&
+        !hasRedirectedToAIFeaturesPromoModal &&
+        !isAIPromoModalDismissed &&
+        !isMigrationModalPending &&
+        !observedActiveMigrationModalThisSession &&
+        !observedActiveOnboardingThisSession;
+
+    useEffect(() => {
+        if (!isEligible || isWaitingForProtectedRoutes.current) {
+            return;
+        }
+        isWaitingForProtectedRoutes.current = true;
+        // Defer until any in-flight navigation transition (splash → home, etc.) has fully settled
+        const handle = TransitionTracker.runAfterTransitions({
+            callback: () => {
+                Navigation.waitForProtectedRoutes().then(() => {
+                    isWaitingForProtectedRoutes.current = false;
+                    if (hasRedirectedToAIFeaturesPromoModal || observedActiveMigrationModalThisSession || observedActiveOnboardingThisSession || isAIPromoModalDismissed) {
+                        return;
+                    }
+                    Log.info('[useAIFeaturesPromoModal] Navigating to AI features promo modal');
+                    hasRedirectedToAIFeaturesPromoModal = true;
+                    Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.AI_FEATURES_PROMO.path, Navigation.getActiveRoute() || ROUTES.HOME));
+                });
+            },
+            waitForUpcomingTransition: true,
+        });
+
+        return () => {
+            handle.cancel();
+            isWaitingForProtectedRoutes.current = false;
+        };
+    }, [isEligible, isAIPromoModalDismissed]);
+}
+
+export default useAIFeaturesPromoModal;
diff --git a/src/libs/Navigation/AppNavigator/AuthScreensInitHandler.tsx b/src/libs/Navigation/AppNavigator/AuthScreensInitHandler.tsx
index 23b986838e78..d79b0176218d 100644
--- a/src/libs/Navigation/AppNavigator/AuthScreensInitHandler.tsx
+++ b/src/libs/Navigation/AppNavigator/AuthScreensInitHandler.tsx
@@ -2,6 +2,7 @@ import {hasSeenTourSelector} from '@selectors/Onboarding';
 import {useEffect, useRef} from 'react';
 import {useInitialURLActions, useInitialURLState} from '@components/InitialURLContextProvider';
 import useActivePolicy from '@hooks/useActivePolicy';
+import useAIFeaturesPromoModal from '@hooks/useAIFeaturesPromoModal';
 import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
 import useHasActiveAdminPolicies from '@hooks/useHasActiveAdminPolicies';
 import useLastWorkspaceNumber from '@hooks/useLastWorkspaceNumber';
@@ -76,6 +77,7 @@ function AuthScreensInitHandler() {
     reportAttributesRef.current = reportAttributes;
 
     useReconcileHighContrastIntent();
+    useAIFeaturesPromoModal(session);
 
     useEffect(() => {
         registerPusherReinitializeHandler(({accountID, email}: PusherReinitializeHandlerParams = {}) => {
diff --git a/src/libs/Navigation/guards/AIFeaturesPromoGuard.ts b/src/libs/Navigation/guards/AIFeaturesPromoGuard.ts
deleted file mode 100644
index 731344f41524..000000000000
--- a/src/libs/Navigation/guards/AIFeaturesPromoGuard.ts
+++ /dev/null
@@ -1,259 +0,0 @@
-import type {NavigationAction, NavigationState, PartialState} from '@react-navigation/native';
-import {findFocusedRoute} from '@react-navigation/native';
-import {isActingAsDelegateSelector} from '@selectors/Account';
-import {hasCompletedGuidedSetupFlowSelector, tryNewDotOnyxSelector} from '@selectors/Onboarding';
-import Onyx from 'react-native-onyx';
-import type {OnyxEntry} from 'react-native-onyx';
-import Log from '@libs/Log';
-import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute';
-import Navigation from '@libs/Navigation/Navigation';
-import navigationRef from '@libs/Navigation/navigationRef';
-import TransitionTracker from '@libs/Navigation/TransitionTracker';
-import isProductTrainingElementDismissed from '@libs/TooltipUtils';
-import CONST from '@src/CONST';
-import NAVIGATORS from '@src/NAVIGATORS';
-import ONYXKEYS from '@src/ONYXKEYS';
-import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES';
-import type {Route} from '@src/ROUTES';
-import SCREENS from '@src/SCREENS';
-import type {DismissedProductTraining, Onboarding, Session} from '@src/types/onyx';
-import type {GuardResult, NavigationGuard} from './types';
-
-let session: OnyxEntry<Session>;
-let isLoadingApp = true;
-
-let dismissedProductTraining: OnyxEntry<DismissedProductTraining>;
-let isDismissedProductTrainingLoaded = false;
-
-let hasBeenAddedToNudgeMigration = false;
-let isTryNewDotLoaded = false;
-
-let onboarding: OnyxEntry<Onboarding>;
-let isOnboardingLoaded = false;
-
-let isActingAsDelegate = false;
-
-let hasRedirectedToAIFeaturesPromoModal = false;
-let isWaitingForProtectedRoutes = false;
-
-/**
- * This modal must not appear in the same session as the migration welcome modal, the onboarding flow,
- * or the HybridApp explanation modal. These flags trip when we observe any of those navigators mounting
- * during this process lifetime, and suppress the AI promo for the rest of the session.
- */
-let observedActiveMigrationModalThisSession = false;
-let observedActiveOnboardingThisSession = false;
-
-function containsNavigator(state: NavigationState | PartialState<NavigationState> | undefined, navigatorName: string): boolean {
-    if (!state?.routes) {
-        return false;
-    }
-    return state.routes.some((route) => route.name === navigatorName || containsNavigator(route.state, navigatorName));
-}
-
-function snapshotActiveModalsFromNavigationState() {
-    if (!navigationRef.isReady?.()) {
-        return;
-    }
-    const rootState = navigationRef.getRootState?.();
-    if (!rootState) {
-        return;
-    }
-    if (containsNavigator(rootState, NAVIGATORS.ONBOARDING_MODAL_NAVIGATOR)) {
-        observedActiveOnboardingThisSession = true;
-    }
-    if (containsNavigator(rootState, NAVIGATORS.MIGRATED_USER_MODAL_NAVIGATOR)) {
-        observedActiveMigrationModalThisSession = true;
-    }
-}
-
-// Attach lazily — the listener can only be added once react-navigation's ref is ready,
-// which generally happens after this module is loaded.
-let isStateListenerAttached = false;
-function attachNavigationStateListener() {
-    if (isStateListenerAttached || !navigationRef.isReady?.()) {
-        return;
-    }
-    isStateListenerAttached = true;
-    snapshotActiveModalsFromNavigationState();
-    navigationRef.addListener('state', snapshotActiveModalsFromNavigationState);
-}
-
-function isEligibleToShowAIFeaturesPromoModal(): boolean {
-    return (
-        !!session?.authToken &&
-        !isLoadingApp &&
-        !isActingAsDelegate &&
-        !hasRedirectedToAIFeaturesPromoModal &&
-        isDismissedProductTrainingLoaded &&
-        isTryNewDotLoaded &&
-        isOnboardingLoaded &&
-        !isProductTrainingElementDismissed(CONST.AI_FEATURES_PROMO_MODAL, dismissedProductTraining) &&
-        !observedActiveMigrationModalThisSession &&
-        !observedActiveOnboardingThisSession
-    );
-}
-
-function getAIFeaturesPromoModalRoute(): Route {
-    return createDynamicRoute(DYNAMIC_ROUTES.AI_FEATURES_PROMO.path, ROUTES.HOME);
-}
-
-/**
- * Proactively navigate to the AI features promo modal when all conditions are met.
- */
-function navigateToAIFeaturesPromoModalIfReady() {
-    // Sync the modal-active flags from the current navigation state (and attach a listener
-    // for future state changes) before checking, so a freshly-mounted onboarding navigator
-    // is reflected immediately.
-    attachNavigationStateListener();
-
-    if (isWaitingForProtectedRoutes || !isEligibleToShowAIFeaturesPromoModal()) {
-        return;
-    }
-
-    isWaitingForProtectedRoutes = true;
-    // Defer until any in-flight navigation transition (splash → home, etc.)
-    // has fully settled, then wait for the protected stack to be in the nav tree.
-    TransitionTracker.runAfterTransitions({
-        callback: () => {
-            Navigation.waitForProtectedRoutes().then(() => {
-                isWaitingForProtectedRoutes = false;
-                snapshotActiveModalsFromNavigationState();
-                if (!isEligibleToShowAIFeaturesPromoModal()) {
-                    return;
-                }
-                Log.info('[AIFeaturesPromoGuard] Proactively navigating to AI features promo modal');
-                hasRedirectedToAIFeaturesPromoModal = true;
-                Navigation.navigate(getAIFeaturesPromoModalRoute());
-            });
-        },
-        waitForUpcomingTransition: true,
-    });
-}
-
-/**
- * Called by guards/index.ts when session or loading app state changes.
- * Reuses the shared Onyx subscriptions from guards/index.ts to avoid duplicate connections.
- */
-function onSessionOrLoadingAppChanged(sessionValue: OnyxEntry<Session>, isLoadingAppValue: boolean) {
-    session = sessionValue;
-    isLoadingApp = isLoadingAppValue;
-    navigateToAIFeaturesPromoModalIfReady();
-}
-
-Onyx.connectWithoutView({
-    key: ONYXKEYS.NVP_DISMISSED_PRODUCT_TRAINING,
-    callback: (value) => {
-        dismissedProductTraining = value;
-        isDismissedProductTrainingLoaded = true;
-        if (isProductTrainingElementDismissed(CONST.AI_FEATURES_PROMO_MODAL, value)) {
-            hasRedirectedToAIFeaturesPromoModal = false;
-        }
-        // If the migration welcome modal is currently still pending, suppress AI promo this session.
-        if (hasBeenAddedToNudgeMigration && !isProductTrainingElementDismissed(CONST.MIGRATED_USER_WELCOME_MODAL, value)) {
-            observedActiveMigrationModalThisSession = true;
-        }
-        navigateToAIFeaturesPromoModalIfReady();
-    },
-});
-
-Onyx.connectWithoutView({
-    key: ONYXKEYS.NVP_ONBOARDING,
-    callback: (value) => {
-        onboarding = value;
-        isOnboardingLoaded = true;
-        if (hasCompletedGuidedSetupFlowSelector(onboarding) === false) {
-            observedActiveOnboardingThisSession = true;
-        }
-    },
-});
-
-Onyx.connectWithoutView({
-    key: ONYXKEYS.NVP_TRY_NEW_DOT,
-    callback: (value) => {
-        const result = value ? tryNewDotOnyxSelector(value) : undefined;
-        hasBeenAddedToNudgeMigration = result?.hasBeenAddedToNudgeMigration ?? false;
-        isTryNewDotLoaded = true;
-        if (hasBeenAddedToNudgeMigration && !isProductTrainingElementDismissed(CONST.MIGRATED_USER_WELCOME_MODAL, dismissedProductTraining)) {
-            observedActiveMigrationModalThisSession = true;
-        }
-    },
-});
-
-Onyx.connectWithoutView({
-    key: ONYXKEYS.ACCOUNT,
-    callback: (value) => {
-        isActingAsDelegate = isActingAsDelegateSelector(value);
-    },
-});
-
-/**
- * Block navigation while the AI features promo modal is active (on top of the stack).
- */
-function shouldBlockWhileModalActive(state: NavigationState, action: NavigationAction): boolean {
-    if (
-        !hasRedirectedToAIFeaturesPromoModal ||
-        isProductTrainingElementDismissed(CONST.AI_FEATURES_PROMO_MODAL, dismissedProductTraining) ||
-        state.routes.at(-1)?.name !== NAVIGATORS.AI_FEATURES_PROMO_MODAL_NAVIGATOR
-    ) {
-        return false;
-    }
-
-    // Internal modal-close actions always allowed.
-    if (action.type === CONST.NAVIGATION.ACTION_TYPE.DISMISS_MODAL || action.type === CONST.NAVIGATION.ACTION_TYPE.GO_BACK) {
-        return false;
-    }
-
-    // RESET actions are how deep links / share intents enter the app — never block them.
-    if (action.type === CONST.NAVIGATION.ACTION_TYPE.RESET) {
-        return false;
-    }
-
-    // For NAVIGATE/PUSH/REPLACE actions, only block if the target lives inside the AI promo navigator.
-    const targetName = (action.payload as {name?: string} | undefined)?.name;
-    if (targetName && targetName !== NAVIGATORS.AI_FEATURES_PROMO_MODAL_NAVIGATOR) {
-        return false;
-    }
-
-    return true;
-}
-
-/** Prevents redirect loops by detecting when we're already on or resetting to the modal. */
-function isNavigatingToAIFeaturesPromoModal(state: NavigationState, action: NavigationAction): boolean {
-    const isOnModal = findFocusedRoute(state)?.name === SCREENS.AI_FEATURES_PROMO_MODAL.DYNAMIC_ROOT;
-    const isResettingToModal =
-        action.type === CONST.NAVIGATION.ACTION_TYPE.RESET && !!action.payload && findFocusedRoute(action.payload as NavigationState)?.name === SCREENS.AI_FEATURES_PROMO_MODAL.DYNAMIC_ROOT;
-
-    return isOnModal || isResettingToModal;
-}
-
-/**
- * AIFeaturesPromoGuard surfaces the one-time AI features promo modal.
- */
-const AIFeaturesPromoGuard: NavigationGuard = {
-    name: 'AIFeaturesPromoGuard',
-
-    evaluate: (state: NavigationState, action: NavigationAction, context): GuardResult => {
-        if (context.isLoading) {
-            return {type: 'ALLOW'};
-        }
-
-        if (shouldBlockWhileModalActive(state, action)) {
-            return {type: 'BLOCK', reason: '[AIFeaturesPromoGuard] Blocking navigation while AI features promo modal is active'};
-        }
-
-        if (isNavigatingToAIFeaturesPromoModal(state, action) || hasRedirectedToAIFeaturesPromoModal) {
-            return {type: 'ALLOW'};
-        }
-
-        if (isEligibleToShowAIFeaturesPromoModal()) {
-            hasRedirectedToAIFeaturesPromoModal = true;
-            return {type: 'REDIRECT', route: getAIFeaturesPromoModalRoute()};
-        }
-
-        return {type: 'ALLOW'};
-    },
-};
-
-export default AIFeaturesPromoGuard;
-export {onSessionOrLoadingAppChanged};
diff --git a/src/libs/Navigation/guards/index.ts b/src/libs/Navigation/guards/index.ts
index 783b8be533b3..e006bf25dc6b 100644
--- a/src/libs/Navigation/guards/index.ts
+++ b/src/libs/Navigation/guards/index.ts
@@ -4,7 +4,6 @@ import type {OnyxEntry} from 'react-native-onyx';
 import getCurrentUrl from '@libs/Navigation/currentUrl';
 import ONYXKEYS from '@src/ONYXKEYS';
 import type {Session} from '@src/types/onyx';
-import AIFeaturesPromoGuard, {onSessionOrLoadingAppChanged as onAIFeaturesPromoSessionOrLoadingAppChanged} from './AIFeaturesPromoGuard';
 import MigratedUserWelcomeModalGuard, {onSessionOrLoadingAppChanged as onMigratedUserWelcomeModalSessionOrLoadingAppChanged} from './MigratedUserWelcomeModalGuard';
 import OnboardingGuard from './OnboardingGuard';
 import type {GuardContext, GuardResult, NavigationGuard} from './types';
@@ -21,7 +20,6 @@ Onyx.connectWithoutView({
     callback: (value) => {
         session = value;
         onMigratedUserWelcomeModalSessionOrLoadingAppChanged(session, isLoadingApp);
-        onAIFeaturesPromoSessionOrLoadingAppChanged(session, isLoadingApp);
     },
 });
 
@@ -30,7 +28,6 @@ Onyx.connectWithoutView({
     callback: (value) => {
         isLoadingApp = value ?? true;
         onMigratedUserWelcomeModalSessionOrLoadingAppChanged(session, isLoadingApp);
-        onAIFeaturesPromoSessionOrLoadingAppChanged(session, isLoadingApp);
     },
 });
 
@@ -106,6 +103,5 @@ function clearGuards(): void {
 
 registerGuard(OnboardingGuard);
 registerGuard(MigratedUserWelcomeModalGuard);
-registerGuard(AIFeaturesPromoGuard);
 
 export {registerGuard, createGuardContext, evaluateGuards, getRegisteredGuards, clearGuards};

From c927ed05bd89c7c1dded97a004c46af0839499a9 Mon Sep 17 00:00:00 2001
From: gijoe0295 <153004152+gijoe0295@users.noreply.github.com>
Date: Fri, 3 Jul 2026 00:34:53 +0700
Subject: [PATCH 26/35] Update tests

---
 jest/setupAfterEnv.ts                   | 10 ----------
 tests/ui/AuthScreensInitHandlerTest.tsx |  5 +++++
 2 files changed, 5 insertions(+), 10 deletions(-)

diff --git a/jest/setupAfterEnv.ts b/jest/setupAfterEnv.ts
index 4f1a66081887..cf6a137be8e3 100644
--- a/jest/setupAfterEnv.ts
+++ b/jest/setupAfterEnv.ts
@@ -6,16 +6,6 @@ import ONYXKEYS from '@src/ONYXKEYS';
 
 jest.useRealTimers();
 
-// Globally short-circuit AIFeaturesPromoGuard in tests. The real guard proactively
-// navigates any authenticated session to /ai-features-promo unless the dismissal NVP
-// is set, which would otherwise intercept navigation in unrelated UI tests. Tests
-// that need the real guard can override this mock locally.
-jest.mock('@libs/Navigation/guards/AIFeaturesPromoGuard', () => ({
-    __esModule: true,
-    default: {name: 'AIFeaturesPromoGuard', evaluate: () => ({type: 'ALLOW'})},
-    onSessionOrLoadingAppChanged: jest.fn(),
-}));
-
 // Patch Keyboard.addListener to return a subscription object with .remove() so that
 // @react-navigation/bottom-tabs useIsKeyboardShown hook doesn't crash on cleanup.
 if (Keyboard && typeof Keyboard.addListener === 'function') {
diff --git a/tests/ui/AuthScreensInitHandlerTest.tsx b/tests/ui/AuthScreensInitHandlerTest.tsx
index 76b279bb761c..a58b7426ffee 100644
--- a/tests/ui/AuthScreensInitHandlerTest.tsx
+++ b/tests/ui/AuthScreensInitHandlerTest.tsx
@@ -103,6 +103,11 @@ jest.mock('@selectors/Onboarding', () => ({
     hasSeenTourSelector: () => false,
 }));
 
+jest.mock('@hooks/useAIFeaturesPromoModal', () => ({
+    __esModule: true,
+    default: jest.fn(),
+}));
+
 jest.mock('@src/components/ConfirmedRoute.tsx');
 
 const mockedPusherInit = jest.mocked(Pusher.init);

From 4bbd420175c431f75561c937c46c49eae6ebf09e Mon Sep 17 00:00:00 2001
From: gijoe0295 <153004152+gijoe0295@users.noreply.github.com>
Date: Fri, 3 Jul 2026 00:35:13 +0700
Subject: [PATCH 27/35] Allow promo modal to show on top of any screen

---
 src/ROUTES.ts | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/ROUTES.ts b/src/ROUTES.ts
index 6bc8b0737e3f..7d042ed7399d 100644
--- a/src/ROUTES.ts
+++ b/src/ROUTES.ts
@@ -144,7 +144,7 @@ const DYNAMIC_ROUTES = {
     },
     AI_FEATURES_PROMO: {
         path: 'ai-features-promo',
-        entryScreens: [SCREENS.HOME, SCREENS.INBOX, SCREENS.REPORT, SCREENS.SEARCH.ROOT, SCREENS.WORKSPACES_LIST, SCREENS.WORKSPACE.PROFILE, SCREENS.SETTINGS.ROOT],
+        entryScreens: ['*'],
     },
     EXPENSE_LIMIT_TYPE_SELECTOR: {
         path: 'expense-limit-type',

From c31d247ca9cdbb38bc7768615c9dd3cfa897a9c5 Mon Sep 17 00:00:00 2001
From: gijoe0295 <153004152+gijoe0295@users.noreply.github.com>
Date: Fri, 3 Jul 2026 00:35:39 +0700
Subject: [PATCH 28/35] Fix: Animation is broken on swipe on mobile browser

---
 .../FeatureTrainingContentIllustration.tsx                  | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/src/components/FeatureTrainingContent/FeatureTrainingContentIllustration.tsx b/src/components/FeatureTrainingContent/FeatureTrainingContentIllustration.tsx
index fcdbc37e0867..9053517822c4 100644
--- a/src/components/FeatureTrainingContent/FeatureTrainingContentIllustration.tsx
+++ b/src/components/FeatureTrainingContent/FeatureTrainingContentIllustration.tsx
@@ -13,7 +13,7 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout';
 import useThemeStyles from '@hooks/useThemeStyles';
 import useWindowDimensions from '@hooks/useWindowDimensions';
 import Accessibility from '@libs/Accessibility';
-import {isMobileChrome} from '@libs/Browser';
+import {isMobile} from '@libs/Browser';
 import isInLandscapeModeUtil from '@libs/isInLandscapeMode';
 import {getIsOffline} from '@libs/NetworkState';
 import variables from '@styles/variables';
@@ -77,7 +77,7 @@ function FeatureTrainingContentIllustration({
 
     const animationRef = useRef<LottieView | null>(null);
     useEffect(() => {
-        if (isMobileChrome() || !isCarousel || !animationRef.current || isReduceMotionEnabled) {
+        if (isMobile() || !isCarousel || !animationRef.current || isReduceMotionEnabled) {
             return;
         }
         if (isFocused) {
@@ -149,7 +149,7 @@ function FeatureTrainingContentIllustration({
                                 source={animation ?? LottieAnimations.Hands}
                                 style={styles.h100}
                                 webStyle={shouldUseNarrowLayout ? styles.h100 : undefined}
-                                autoPlay={isMobileChrome() || isFocused}
+                                autoPlay={isMobile() || isFocused}
                                 loop
                             />
                         )}

From e05e6ecea89ba021a4b25623c6c863c29aed9256 Mon Sep 17 00:00:00 2001
From: gijoe0295 <153004152+gijoe0295@users.noreply.github.com>
Date: Fri, 3 Jul 2026 00:49:44 +0700
Subject: [PATCH 29/35] Mock useAIFeaturesPromoModal in tests globally

---
 jest/setupAfterEnv.ts                   | 5 +++++
 tests/ui/AuthScreensInitHandlerTest.tsx | 5 -----
 2 files changed, 5 insertions(+), 5 deletions(-)

diff --git a/jest/setupAfterEnv.ts b/jest/setupAfterEnv.ts
index cf6a137be8e3..5fb17b975ca6 100644
--- a/jest/setupAfterEnv.ts
+++ b/jest/setupAfterEnv.ts
@@ -6,6 +6,11 @@ import ONYXKEYS from '@src/ONYXKEYS';
 
 jest.useRealTimers();
 
+jest.mock('@hooks/useAIFeaturesPromoModal', () => ({
+    __esModule: true,
+    default: jest.fn(),
+}));
+
 // Patch Keyboard.addListener to return a subscription object with .remove() so that
 // @react-navigation/bottom-tabs useIsKeyboardShown hook doesn't crash on cleanup.
 if (Keyboard && typeof Keyboard.addListener === 'function') {
diff --git a/tests/ui/AuthScreensInitHandlerTest.tsx b/tests/ui/AuthScreensInitHandlerTest.tsx
index a58b7426ffee..76b279bb761c 100644
--- a/tests/ui/AuthScreensInitHandlerTest.tsx
+++ b/tests/ui/AuthScreensInitHandlerTest.tsx
@@ -103,11 +103,6 @@ jest.mock('@selectors/Onboarding', () => ({
     hasSeenTourSelector: () => false,
 }));
 
-jest.mock('@hooks/useAIFeaturesPromoModal', () => ({
-    __esModule: true,
-    default: jest.fn(),
-}));
-
 jest.mock('@src/components/ConfirmedRoute.tsx');
 
 const mockedPusherInit = jest.mocked(Pusher.init);

From a5fe7f5fbcc9c11c2cb891ba7fa9933987313ae6 Mon Sep 17 00:00:00 2001
From: gijoe0295 <153004152+gijoe0295@users.noreply.github.com>
Date: Fri, 3 Jul 2026 01:23:03 +0700
Subject: [PATCH 30/35] Do not show modal on top of share page. Fixes
 https://github.com/Expensify/App/issues/93976

---
 src/hooks/useAIFeaturesPromoModal.ts | 23 +++++++++++++++++------
 1 file changed, 17 insertions(+), 6 deletions(-)

diff --git a/src/hooks/useAIFeaturesPromoModal.ts b/src/hooks/useAIFeaturesPromoModal.ts
index d8d890307b25..942501fb2c5c 100644
--- a/src/hooks/useAIFeaturesPromoModal.ts
+++ b/src/hooks/useAIFeaturesPromoModal.ts
@@ -5,11 +5,14 @@ import type {OnyxEntry} from 'react-native-onyx';
 import Log from '@libs/Log';
 import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute';
 import Navigation from '@libs/Navigation/Navigation';
+import navigationRef from '@libs/Navigation/navigationRef';
 import TransitionTracker from '@libs/Navigation/TransitionTracker';
 import isProductTrainingElementDismissed from '@libs/TooltipUtils';
 import CONST from '@src/CONST';
+import NAVIGATORS from '@src/NAVIGATORS';
 import ONYXKEYS from '@src/ONYXKEYS';
 import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES';
+import SCREENS from '@src/SCREENS';
 import type {Session} from '@src/types/onyx';
 import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue';
 import useOnyx from './useOnyx';
@@ -40,21 +43,24 @@ function useAIFeaturesPromoModal(session: OnyxEntry<Session>) {
     const hasCompletedOnboarding = hasCompletedGuidedSetupFlowSelector(onboarding);
 
     useEffect(() => {
-        if (isAIPromoModalDismissed) {
-            hasRedirectedToAIFeaturesPromoModal = false;
+        if (!isAIPromoModalDismissed) {
+            return;
         }
+        hasRedirectedToAIFeaturesPromoModal = false;
     }, [isAIPromoModalDismissed]);
 
     useEffect(() => {
-        if (isMigrationModalPending) {
-            observedActiveMigrationModalThisSession = true;
+        if (!isMigrationModalPending) {
+            return;
         }
+        observedActiveMigrationModalThisSession = true;
     }, [isMigrationModalPending]);
 
     useEffect(() => {
-        if (hasCompletedOnboarding === false) {
-            observedActiveOnboardingThisSession = true;
+        if (hasCompletedOnboarding !== false) {
+            return;
         }
+        observedActiveOnboardingThisSession = true;
     }, [hasCompletedOnboarding]);
 
     const isAllOnyxLoaded = !isLoadingOnyxValue(isLoadingAppMetadata, dismissedProductTrainingMetadata, tryNewDotMetadata, onboardingMetadata);
@@ -83,6 +89,11 @@ function useAIFeaturesPromoModal(session: OnyxEntry<Session>) {
                     if (hasRedirectedToAIFeaturesPromoModal || observedActiveMigrationModalThisSession || observedActiveOnboardingThisSession || isAIPromoModalDismissed) {
                         return;
                     }
+                    const lastRoute = navigationRef.getRootState?.()?.routes.at(-1)?.name;
+                    console.log('lastRoute', lastRoute);
+                    if (lastRoute === NAVIGATORS.SHARE_MODAL_NAVIGATOR || lastRoute === SCREENS.NOT_FOUND) {
+                        return;
+                    }
                     Log.info('[useAIFeaturesPromoModal] Navigating to AI features promo modal');
                     hasRedirectedToAIFeaturesPromoModal = true;
                     Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.AI_FEATURES_PROMO.path, Navigation.getActiveRoute() || ROUTES.HOME));

From 8ad081f17bdc9868ac72bc00e976b4363fcb7fd6 Mon Sep 17 00:00:00 2001
From: gijoe0295 <153004152+gijoe0295@users.noreply.github.com>
Date: Fri, 3 Jul 2026 16:05:26 +0700
Subject: [PATCH 31/35] fix lint

---
 src/hooks/useAIFeaturesPromoModal.ts | 5 ++---
 1 file changed, 2 insertions(+), 3 deletions(-)

diff --git a/src/hooks/useAIFeaturesPromoModal.ts b/src/hooks/useAIFeaturesPromoModal.ts
index 942501fb2c5c..19d47fc3a7ef 100644
--- a/src/hooks/useAIFeaturesPromoModal.ts
+++ b/src/hooks/useAIFeaturesPromoModal.ts
@@ -29,7 +29,7 @@ let observedActiveOnboardingThisSession = false;
  */
 function useAIFeaturesPromoModal(session: OnyxEntry<Session>) {
     const [isLoadingApp = true, isLoadingAppMetadata] = useOnyx(ONYXKEYS.IS_LOADING_APP);
-    const [isActingAsDelegate] = useOnyx(ONYXKEYS.ACCOUNT, {selector: isActingAsDelegateSelector});
+    const [isActingAsDelegate, accountMetadata] = useOnyx(ONYXKEYS.ACCOUNT, {selector: isActingAsDelegateSelector});
     const [dismissedProductTraining, dismissedProductTrainingMetadata] = useOnyx(ONYXKEYS.NVP_DISMISSED_PRODUCT_TRAINING);
     const [tryNewDot, tryNewDotMetadata] = useOnyx(ONYXKEYS.NVP_TRY_NEW_DOT, {selector: tryNewDotOnyxSelector});
     const [onboarding, onboardingMetadata] = useOnyx(ONYXKEYS.NVP_ONBOARDING);
@@ -63,7 +63,7 @@ function useAIFeaturesPromoModal(session: OnyxEntry<Session>) {
         observedActiveOnboardingThisSession = true;
     }, [hasCompletedOnboarding]);
 
-    const isAllOnyxLoaded = !isLoadingOnyxValue(isLoadingAppMetadata, dismissedProductTrainingMetadata, tryNewDotMetadata, onboardingMetadata);
+    const isAllOnyxLoaded = !isLoadingOnyxValue(isLoadingAppMetadata, accountMetadata, dismissedProductTrainingMetadata, tryNewDotMetadata, onboardingMetadata);
 
     const isEligible =
         isAllOnyxLoaded &&
@@ -90,7 +90,6 @@ function useAIFeaturesPromoModal(session: OnyxEntry<Session>) {
                         return;
                     }
                     const lastRoute = navigationRef.getRootState?.()?.routes.at(-1)?.name;
-                    console.log('lastRoute', lastRoute);
                     if (lastRoute === NAVIGATORS.SHARE_MODAL_NAVIGATOR || lastRoute === SCREENS.NOT_FOUND) {
                         return;
                     }

From b523f4d91ba8eaf92a700e006cf434d903709549 Mon Sep 17 00:00:00 2001
From: gijoe0295 <153004152+gijoe0295@users.noreply.github.com>
Date: Fri, 3 Jul 2026 16:14:27 +0700
Subject: [PATCH 32/35] Run formatting

---
 src/components/AIFeaturesPromoModal.tsx       | 12 ++-
 .../FeatureTrainingCarousel.tsx               | 77 +++++++++++--------
 .../FeatureTrainingContent.tsx                | 18 +++--
 .../FeatureTrainingContentBody.tsx            | 11 ++-
 .../FeatureTrainingContentBodyText.tsx        |  9 ++-
 .../FeatureTrainingContentIllustration.tsx    | 17 ++--
 .../FeatureTrainingContent/index.tsx          |  3 +-
 .../FeatureTrainingContent/types.ts           |  8 +-
 src/hooks/useAIFeaturesPromoModal.ts          | 12 ++-
 .../AIFeaturesPromoModalNavigator.tsx         |  5 +-
 10 files changed, 110 insertions(+), 62 deletions(-)

diff --git a/src/components/AIFeaturesPromoModal.tsx b/src/components/AIFeaturesPromoModal.tsx
index 985b002eb062..8f10a727394b 100644
--- a/src/components/AIFeaturesPromoModal.tsx
+++ b/src/components/AIFeaturesPromoModal.tsx
@@ -1,18 +1,24 @@
-import React, {useRef} from 'react';
-import {View} from 'react-native';
 import useBeforeRemove from '@hooks/useBeforeRemove';
 import useLocalize from '@hooks/useLocalize';
 import usePermissions from '@hooks/usePermissions';
 import useThemeStyles from '@hooks/useThemeStyles';
+
 import {dismissProductTraining} from '@libs/actions/Welcome';
 import Log from '@libs/Log';
 import Navigation from '@libs/Navigation/Navigation';
+
 import variables from '@styles/variables';
+
 import CONST from '@src/CONST';
+
+import React, {useRef} from 'react';
+import {View} from 'react-native';
+
+import type {FeatureTrainingContentDataProps} from './FeatureTrainingContent';
+
 import Badge from './Badge';
 import CenteredModalLayout from './CenteredModalLayout';
 import {FeatureTrainingCarousel} from './FeatureTrainingContent';
-import type {FeatureTrainingContentDataProps} from './FeatureTrainingContent';
 import LottieAnimations from './LottieAnimations';
 import Text from './Text';
 
diff --git a/src/components/FeatureTrainingContent/FeatureTrainingCarousel.tsx b/src/components/FeatureTrainingContent/FeatureTrainingCarousel.tsx
index 94641aa9f5b0..9450db67ecf5 100644
--- a/src/components/FeatureTrainingContent/FeatureTrainingCarousel.tsx
+++ b/src/components/FeatureTrainingContent/FeatureTrainingCarousel.tsx
@@ -1,11 +1,8 @@
-import React, {useEffect, useRef, useState} from 'react';
-import {FlatList, Platform, View} from 'react-native';
-// eslint-disable-next-line no-restricted-imports -- Type import needed for ref typing; no wrapper available
-import type {LayoutChangeEvent, FlatList as RNFlatList, ScrollView as RNScrollView, ViewabilityConfig, ViewStyle, ViewToken} from 'react-native';
 import Icon from '@components/Icon';
 import PressableWithFeedback from '@components/Pressable/PressableWithFeedback';
 import ScrollView from '@components/ScrollView';
 import Tooltip from '@components/Tooltip';
+
 import useKeyboardState from '@hooks/useKeyboardState';
 import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
 import useLocalize from '@hooks/useLocalize';
@@ -15,13 +12,24 @@ import useStyleUtils from '@hooks/useStyleUtils';
 import useTheme from '@hooks/useTheme';
 import useThemeStyles from '@hooks/useThemeStyles';
 import useWindowDimensions from '@hooks/useWindowDimensions';
+
 import isInLandscapeModeUtil from '@libs/isInLandscapeMode';
+
 import variables from '@styles/variables';
+
 import CONST from '@src/CONST';
+
+// eslint-disable-next-line no-restricted-imports -- Type import needed for ref typing; no wrapper available
+import type {LayoutChangeEvent, FlatList as RNFlatList, ScrollView as RNScrollView, ViewabilityConfig, ViewStyle, ViewToken} from 'react-native';
+
+import React, {useEffect, useRef, useState} from 'react';
+import {FlatList, Platform, View} from 'react-native';
+
+import type {FeatureTrainingCarouselProps, FeatureTrainingContentDataProps} from './types';
+
 import FeatureTrainingContentBody from './FeatureTrainingContentBody';
 import FeatureTrainingContentBodyText from './FeatureTrainingContentBodyText';
 import FeatureTrainingContentIllustration from './FeatureTrainingContentIllustration';
-import type {FeatureTrainingCarouselProps, FeatureTrainingContentDataProps} from './types';
 
 const CONTENT_PADDING = variables.spacing2;
 
@@ -172,35 +180,36 @@ function FeatureTrainingCarousel({
             // eslint-disable-next-line react/forbid-component-props -- fsClass is required for FullStory session masking
             fsClass={CONST.FULLSTORY.CLASS.UNMASK}
         >
-            {carouselViewportWidth > 0 && contentMinHeight === undefined && (
-                // Probe layer is used to measure the tallest page to lock the modal height
-                // when moving between pages with different content lengths.
-                <View
-                    pointerEvents="none"
-                    accessibilityElementsHidden
-                    importantForAccessibility="no-hide-descendants"
-                    style={[styles.pAbsolute, styles.l0, styles.t0, {width: carouselViewportWidth, opacity: 0}]}
-                >
-                    {pages.map((page, index) => (
-                        <View
-                            // The pages array is static for the modal's lifetime, so the index is a stable key.
-                            // eslint-disable-next-line react/no-array-index-key
-                            key={`FeatureTrainingModalCarousel-probe-${index}`}
-                            style={styles.mh5}
-                        >
-                            <FeatureTrainingContentBodyText
-                                title={page.title}
-                                subtitle={page.subtitle}
-                                description={page.description}
-                                titleStyles={titleStyles}
-                                contentInnerContainerStyles={contentInnerContainerStyles}
-                                shouldRenderHTMLDescription={shouldRenderHTMLDescription}
-                                onLayout={handleProbeLayout(index)}
-                            />
-                        </View>
-                    ))}
-                </View>
-            )}
+            {carouselViewportWidth > 0 &&
+                contentMinHeight === undefined && (
+                    // Probe layer is used to measure the tallest page to lock the modal height
+                    // when moving between pages with different content lengths.
+                    <View
+                        pointerEvents="none"
+                        accessibilityElementsHidden
+                        importantForAccessibility="no-hide-descendants"
+                        style={[styles.pAbsolute, styles.l0, styles.t0, {width: carouselViewportWidth, opacity: 0}]}
+                    >
+                        {pages.map((page, index) => (
+                            <View
+                                // The pages array is static for the modal's lifetime, so the index is a stable key.
+                                // eslint-disable-next-line react/no-array-index-key
+                                key={`FeatureTrainingModalCarousel-probe-${index}`}
+                                style={styles.mh5}
+                            >
+                                <FeatureTrainingContentBodyText
+                                    title={page.title}
+                                    subtitle={page.subtitle}
+                                    description={page.description}
+                                    titleStyles={titleStyles}
+                                    contentInnerContainerStyles={contentInnerContainerStyles}
+                                    shouldRenderHTMLDescription={shouldRenderHTMLDescription}
+                                    onLayout={handleProbeLayout(index)}
+                                />
+                            </View>
+                        ))}
+                    </View>
+                )}
             {carouselViewportWidth > 0 && (
                 <>
                     <View>
diff --git a/src/components/FeatureTrainingContent/FeatureTrainingContent.tsx b/src/components/FeatureTrainingContent/FeatureTrainingContent.tsx
index 2beaa2cad824..792e4e070372 100644
--- a/src/components/FeatureTrainingContent/FeatureTrainingContent.tsx
+++ b/src/components/FeatureTrainingContent/FeatureTrainingContent.tsx
@@ -1,20 +1,28 @@
-import React, {useEffect, useRef, useState} from 'react';
-import {View} from 'react-native';
-// eslint-disable-next-line no-restricted-imports -- type-only import from react-native
-import type {LayoutChangeEvent, ScrollView as RNScrollView} from 'react-native';
 import ScrollView from '@components/ScrollView';
+
 import useKeyboardState from '@hooks/useKeyboardState';
 import useResponsiveLayout from '@hooks/useResponsiveLayout';
 import useSafeAreaInsets from '@hooks/useSafeAreaInsets';
 import useStyleUtils from '@hooks/useStyleUtils';
 import useThemeStyles from '@hooks/useThemeStyles';
 import useWindowDimensions from '@hooks/useWindowDimensions';
+
 import isInLandscapeModeUtil from '@libs/isInLandscapeMode';
+
 import variables from '@styles/variables';
+
 import CONST from '@src/CONST';
+
+// eslint-disable-next-line no-restricted-imports -- type-only import from react-native
+import type {LayoutChangeEvent, ScrollView as RNScrollView} from 'react-native';
+
+import React, {useEffect, useRef, useState} from 'react';
+import {View} from 'react-native';
+
+import type {FeatureTrainingContentProps} from './types';
+
 import FeatureTrainingContentBody from './FeatureTrainingContentBody';
 import FeatureTrainingContentIllustration from './FeatureTrainingContentIllustration';
-import type {FeatureTrainingContentProps} from './types';
 
 function FeatureTrainingContent({
     illustrationInnerContainerStyle,
diff --git a/src/components/FeatureTrainingContent/FeatureTrainingContentBody.tsx b/src/components/FeatureTrainingContent/FeatureTrainingContentBody.tsx
index 296d0980e6ab..de0ef853a073 100644
--- a/src/components/FeatureTrainingContent/FeatureTrainingContentBody.tsx
+++ b/src/components/FeatureTrainingContent/FeatureTrainingContentBody.tsx
@@ -1,14 +1,19 @@
-import React from 'react';
-import {View} from 'react-native';
 import Button from '@components/Button';
 import CheckboxWithLabel from '@components/CheckboxWithLabel';
 import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton';
+
 import useLocalize from '@hooks/useLocalize';
 import useThemeStyles from '@hooks/useThemeStyles';
+
 import CONST from '@src/CONST';
-import FeatureTrainingContentBodyText from './FeatureTrainingContentBodyText';
+
+import React from 'react';
+import {View} from 'react-native';
+
 import type {FeatureTrainingContentBodyProps as BaseFeatureTrainingContentBodyProps, BaseFeatureTrainingContentProps} from './types';
 
+import FeatureTrainingContentBodyText from './FeatureTrainingContentBodyText';
+
 type FeatureTrainingModalContentProps = Pick<
     BaseFeatureTrainingContentProps,
     | 'helpText'
diff --git a/src/components/FeatureTrainingContent/FeatureTrainingContentBodyText.tsx b/src/components/FeatureTrainingContent/FeatureTrainingContentBodyText.tsx
index 4ebcc0873445..757fd4f0db7f 100644
--- a/src/components/FeatureTrainingContent/FeatureTrainingContentBodyText.tsx
+++ b/src/components/FeatureTrainingContent/FeatureTrainingContentBodyText.tsx
@@ -1,11 +1,14 @@
-import React from 'react';
-import {View} from 'react-native';
-import type {LayoutChangeEvent, StyleProp, TextStyle, ViewStyle} from 'react-native';
 import RenderHTML from '@components/RenderHTML';
 import Text from '@components/Text';
+
 import useResponsiveLayout from '@hooks/useResponsiveLayout';
 import useThemeStyles from '@hooks/useThemeStyles';
 
+import type {LayoutChangeEvent, StyleProp, TextStyle, ViewStyle} from 'react-native';
+
+import React from 'react';
+import {View} from 'react-native';
+
 type FeatureTrainingModalTextContentProps = {
     /** Title for the modal */
     title?: string | React.ReactNode;
diff --git a/src/components/FeatureTrainingContent/FeatureTrainingContentIllustration.tsx b/src/components/FeatureTrainingContent/FeatureTrainingContentIllustration.tsx
index 9053517822c4..0716dca3987f 100644
--- a/src/components/FeatureTrainingContent/FeatureTrainingContentIllustration.tsx
+++ b/src/components/FeatureTrainingContent/FeatureTrainingContentIllustration.tsx
@@ -1,23 +1,30 @@
-import type {SourceLoadEventPayload} from 'expo-video';
-import type LottieView from 'lottie-react-native';
-import React, {useEffect, useRef, useState} from 'react';
-import {View} from 'react-native';
-import {GestureHandlerRootView} from 'react-native-gesture-handler';
 import ImageSVG from '@components/ImageSVG';
 import Lottie from '@components/Lottie';
 import LottieAnimations from '@components/LottieAnimations';
 import VideoPlayer from '@components/VideoPlayer';
+
 import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset';
 import useNetwork from '@hooks/useNetwork';
 import useResponsiveLayout from '@hooks/useResponsiveLayout';
 import useThemeStyles from '@hooks/useThemeStyles';
 import useWindowDimensions from '@hooks/useWindowDimensions';
+
 import Accessibility from '@libs/Accessibility';
 import {isMobile} from '@libs/Browser';
 import isInLandscapeModeUtil from '@libs/isInLandscapeMode';
 import {getIsOffline} from '@libs/NetworkState';
+
 import variables from '@styles/variables';
+
 import CONST from '@src/CONST';
+
+import type {SourceLoadEventPayload} from 'expo-video';
+import type LottieView from 'lottie-react-native';
+
+import React, {useEffect, useRef, useState} from 'react';
+import {View} from 'react-native';
+import {GestureHandlerRootView} from 'react-native-gesture-handler';
+
 import type {FeatureTrainingContentIllustrationProps as BaseFeatureTrainingContentIllustrationProps, BaseFeatureTrainingContentProps} from './types';
 
 // Aspect ratio and height of the video.
diff --git a/src/components/FeatureTrainingContent/index.tsx b/src/components/FeatureTrainingContent/index.tsx
index 81b3f6a5649b..ce07f8f6eb2c 100644
--- a/src/components/FeatureTrainingContent/index.tsx
+++ b/src/components/FeatureTrainingContent/index.tsx
@@ -1,6 +1,7 @@
+import type {FeatureTrainingContentDataProps, FeatureTrainingContentProps} from './types';
+
 import FeatureTrainingCarousel from './FeatureTrainingCarousel';
 import FeatureTrainingContent from './FeatureTrainingContent';
-import type {FeatureTrainingContentDataProps, FeatureTrainingContentProps} from './types';
 
 export default FeatureTrainingContent;
 export {FeatureTrainingCarousel};
diff --git a/src/components/FeatureTrainingContent/types.ts b/src/components/FeatureTrainingContent/types.ts
index 573446d06249..4ec58b1fb41b 100644
--- a/src/components/FeatureTrainingContent/types.ts
+++ b/src/components/FeatureTrainingContent/types.ts
@@ -1,10 +1,12 @@
+import type ImageSVGProps from '@components/ImageSVG/types';
+import type DotLottieAnimation from '@components/LottieAnimations/types';
+
+import type IconAsset from '@src/types/utils/IconAsset';
+
 import type {ImageContentFit} from 'expo-image';
 import type {ReactNode} from 'react';
 import type {StyleProp, TextStyle, ViewStyle} from 'react-native';
 import type {MergeExclusive} from 'type-fest';
-import type ImageSVGProps from '@components/ImageSVG/types';
-import type DotLottieAnimation from '@components/LottieAnimations/types';
-import type IconAsset from '@src/types/utils/IconAsset';
 
 type BaseFeatureTrainingContentProps = {
     /** The aspect ratio to preserve for the icon, video or animation */
diff --git a/src/hooks/useAIFeaturesPromoModal.ts b/src/hooks/useAIFeaturesPromoModal.ts
index 19d47fc3a7ef..6e4feff0e3c3 100644
--- a/src/hooks/useAIFeaturesPromoModal.ts
+++ b/src/hooks/useAIFeaturesPromoModal.ts
@@ -1,13 +1,10 @@
-import {isActingAsDelegateSelector} from '@selectors/Account';
-import {hasCompletedGuidedSetupFlowSelector, tryNewDotOnyxSelector} from '@selectors/Onboarding';
-import {useEffect, useRef} from 'react';
-import type {OnyxEntry} from 'react-native-onyx';
 import Log from '@libs/Log';
 import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute';
 import Navigation from '@libs/Navigation/Navigation';
 import navigationRef from '@libs/Navigation/navigationRef';
 import TransitionTracker from '@libs/Navigation/TransitionTracker';
 import isProductTrainingElementDismissed from '@libs/TooltipUtils';
+
 import CONST from '@src/CONST';
 import NAVIGATORS from '@src/NAVIGATORS';
 import ONYXKEYS from '@src/ONYXKEYS';
@@ -15,6 +12,13 @@ import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES';
 import SCREENS from '@src/SCREENS';
 import type {Session} from '@src/types/onyx';
 import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue';
+
+import type {OnyxEntry} from 'react-native-onyx';
+
+import {isActingAsDelegateSelector} from '@selectors/Account';
+import {hasCompletedGuidedSetupFlowSelector, tryNewDotOnyxSelector} from '@selectors/Onboarding';
+import {useEffect, useRef} from 'react';
+
 import useOnyx from './useOnyx';
 
 let hasRedirectedToAIFeaturesPromoModal = false;
diff --git a/src/libs/Navigation/AppNavigator/Navigators/AIFeaturesPromoModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/AIFeaturesPromoModalNavigator.tsx
index 3992779bf7a5..07793b4004f2 100644
--- a/src/libs/Navigation/AppNavigator/Navigators/AIFeaturesPromoModalNavigator.tsx
+++ b/src/libs/Navigation/AppNavigator/Navigators/AIFeaturesPromoModalNavigator.tsx
@@ -1,10 +1,13 @@
-import React from 'react';
 import AIFeaturesPromoModal from '@components/AIFeaturesPromoModal';
 import NoDropZone from '@components/DragAndDrop/NoDropZone';
+
 import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator';
 import type {AIFeaturesPromoModalNavigatorParamList} from '@libs/Navigation/types';
+
 import SCREENS from '@src/SCREENS';
 
+import React from 'react';
+
 const Stack = createPlatformStackNavigator<AIFeaturesPromoModalNavigatorParamList>();
 
 function AIFeaturesPromoModalNavigator() {

From 8ec77094d9e929ca132cf1defff30fb18f10bbfd Mon Sep 17 00:00:00 2001
From: gijoe0295 <153004152+gijoe0295@users.noreply.github.com>
Date: Mon, 6 Jul 2026 11:45:08 +0700
Subject: [PATCH 33/35] Resolve feedbacks

---
 src/components/AIFeaturesPromoModal.tsx              | 12 ++++--------
 .../FeatureTrainingCarousel.tsx                      |  4 ++--
 2 files changed, 6 insertions(+), 10 deletions(-)

diff --git a/src/components/AIFeaturesPromoModal.tsx b/src/components/AIFeaturesPromoModal.tsx
index 8f10a727394b..d7368d7aa6ea 100644
--- a/src/components/AIFeaturesPromoModal.tsx
+++ b/src/components/AIFeaturesPromoModal.tsx
@@ -4,7 +4,6 @@ import usePermissions from '@hooks/usePermissions';
 import useThemeStyles from '@hooks/useThemeStyles';
 
 import {dismissProductTraining} from '@libs/actions/Welcome';
-import Log from '@libs/Log';
 import Navigation from '@libs/Navigation/Navigation';
 
 import variables from '@styles/variables';
@@ -70,20 +69,17 @@ function AIFeaturesPromoModal() {
 
     const isCloseButtonDismissalRef = useRef(false);
 
-    const handleDismiss = () => {
-        Log.info(`[AIFeaturesPromoModal] dismissing product training via ${isCloseButtonDismissalRef.current ? 'x' : 'click'}`);
+    const dismissModal = () => {
         dismissProductTraining(CONST.AI_FEATURES_PROMO_MODAL, isCloseButtonDismissalRef.current);
     };
 
-    useBeforeRemove(handleDismiss);
+    useBeforeRemove(dismissModal);
 
-    const onConfirm = () => {
-        Log.info('[AIFeaturesPromoModal] onConfirm called');
+    const goBack = () => {
         Navigation.goBack();
     };
 
     const onClose = () => {
-        Log.info(`[AIFeaturesPromoModal] onClose called, user closed modal without confirming`);
         isCloseButtonDismissalRef.current = true;
         Navigation.goBack();
     };
@@ -96,7 +92,7 @@ function AIFeaturesPromoModal() {
         >
             <FeatureTrainingCarousel
                 pages={pages}
-                onConfirm={onConfirm}
+                onConfirm={goBack}
                 onClose={onClose}
                 width={variables.aiFeaturesPromoModalWidth}
                 shouldRenderHTMLDescription
diff --git a/src/components/FeatureTrainingContent/FeatureTrainingCarousel.tsx b/src/components/FeatureTrainingContent/FeatureTrainingCarousel.tsx
index 9450db67ecf5..567e951dfa61 100644
--- a/src/components/FeatureTrainingContent/FeatureTrainingCarousel.tsx
+++ b/src/components/FeatureTrainingContent/FeatureTrainingCarousel.tsx
@@ -124,7 +124,7 @@ function FeatureTrainingCarousel({
         horizontalListRef.current?.scrollToIndex({index: Math.max(currentPage - 1, 0), animated: true});
     };
 
-    const handleConfirmPress = () => {
+    const confirmPage = () => {
         if (currentPage < pages.length - 1) {
             advanceCarousel();
             return;
@@ -265,7 +265,7 @@ function FeatureTrainingCarousel({
                         onHelp={onHelp}
                         helpSentryLabel={helpSentryLabel}
                         confirmSentryLabel={confirmSentryLabel}
-                        onConfirm={handleConfirmPress}
+                        onConfirm={confirmPage}
                         shouldShowBackButton={currentPage > 0}
                         onBack={goBack}
                         titleStyles={titleStyles}

From 1fd9f8f7d5b8e79b4810466ef05590bc71d2aa6e Mon Sep 17 00:00:00 2001
From: gijoe0295 <153004152+gijoe0295@users.noreply.github.com>
Date: Mon, 6 Jul 2026 11:51:48 +0700
Subject: [PATCH 34/35] Simplify NVP dismissal

---
 src/components/AIFeaturesPromoModal.tsx | 24 ++++++++++--------------
 1 file changed, 10 insertions(+), 14 deletions(-)

diff --git a/src/components/AIFeaturesPromoModal.tsx b/src/components/AIFeaturesPromoModal.tsx
index d7368d7aa6ea..a3b9dca157d8 100644
--- a/src/components/AIFeaturesPromoModal.tsx
+++ b/src/components/AIFeaturesPromoModal.tsx
@@ -1,4 +1,3 @@
-import useBeforeRemove from '@hooks/useBeforeRemove';
 import useLocalize from '@hooks/useLocalize';
 import usePermissions from '@hooks/usePermissions';
 import useThemeStyles from '@hooks/useThemeStyles';
@@ -10,7 +9,7 @@ import variables from '@styles/variables';
 
 import CONST from '@src/CONST';
 
-import React, {useRef} from 'react';
+import React from 'react';
 import {View} from 'react-native';
 
 import type {FeatureTrainingContentDataProps} from './FeatureTrainingContent';
@@ -67,33 +66,30 @@ function AIFeaturesPromoModal() {
             : []),
     ];
 
-    const isCloseButtonDismissalRef = useRef(false);
-
-    const dismissModal = () => {
-        dismissProductTraining(CONST.AI_FEATURES_PROMO_MODAL, isCloseButtonDismissalRef.current);
+    const dismissNVP = (isDismissedUsingCloseButton: boolean) => {
+        dismissProductTraining(CONST.AI_FEATURES_PROMO_MODAL, isDismissedUsingCloseButton);
     };
 
-    useBeforeRemove(dismissModal);
-
-    const goBack = () => {
+    const confirmAndCloseModal = () => {
         Navigation.goBack();
+        dismissNVP(false);
     };
 
-    const onClose = () => {
-        isCloseButtonDismissalRef.current = true;
+    const closeModal = () => {
         Navigation.goBack();
+        dismissNVP(true);
     };
 
     return (
         <CenteredModalLayout
-            onBackdropPress={onClose}
+            onBackdropPress={closeModal}
             width={variables.aiFeaturesPromoModalWidth}
             contentStyle={styles.pt0}
         >
             <FeatureTrainingCarousel
                 pages={pages}
-                onConfirm={goBack}
-                onClose={onClose}
+                onConfirm={confirmAndCloseModal}
+                onClose={closeModal}
                 width={variables.aiFeaturesPromoModalWidth}
                 shouldRenderHTMLDescription
                 shouldUseScrollView

From 199b3ea82016caf42edb5827aa769d21a17f8517 Mon Sep 17 00:00:00 2001
From: gijoe0295 <153004152+gijoe0295@users.noreply.github.com>
Date: Fri, 10 Jul 2026 18:07:05 +0700
Subject: [PATCH 35/35] Fix lint

---
 .../FeatureTrainingContentBody.tsx               | 16 +++++++++-------
 1 file changed, 9 insertions(+), 7 deletions(-)

diff --git a/src/components/FeatureTrainingContent/FeatureTrainingContentBody.tsx b/src/components/FeatureTrainingContent/FeatureTrainingContentBody.tsx
index de0ef853a073..467bf1a52a30 100644
--- a/src/components/FeatureTrainingContent/FeatureTrainingContentBody.tsx
+++ b/src/components/FeatureTrainingContent/FeatureTrainingContentBody.tsx
@@ -1,4 +1,4 @@
-import Button from '@components/Button';
+import Button from '@components/ButtonComposed';
 import CheckboxWithLabel from '@components/CheckboxWithLabel';
 import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton';
 
@@ -89,22 +89,24 @@ function FeatureTrainingContentBody({
             )}
             {!!helpText && (
                 <Button
-                    large
+                    size={CONST.BUTTON_SIZE.LARGE}
                     style={[styles.mb3]}
                     onPress={onHelp}
-                    text={helpText}
                     sentryLabel={helpSentryLabel}
-                />
+                >
+                    <Button.Text>{helpText}</Button.Text>
+                </Button>
             )}
             <View style={styles.featureTrainingModalNavButtons}>
                 {shouldShowBackButton && (
                     <Button
-                        large
+                        size={CONST.BUTTON_SIZE.LARGE}
                         onPress={onBack}
-                        text={translate('common.back')}
                         sentryLabel={CONST.SENTRY_LABEL.FEATURE_TRAINING.BACK_BUTTON}
                         style={styles.flex1}
-                    />
+                    >
+                        <Button.Text>{translate('common.back')}</Button.Text>
+                    </Button>
                 )}
                 <FormAlertWithSubmitButton
                     onSubmit={() => onConfirm?.(willShowAgain)}
