diff --git a/assets/animations/CustomAgents.lottie b/assets/animations/CustomAgents.lottie
new file mode 100644
index 000000000000..6f6b40b8c463
Binary files /dev/null and b/assets/animations/CustomAgents.lottie differ
diff --git a/assets/animations/ExpenseAssistant.lottie b/assets/animations/ExpenseAssistant.lottie
new file mode 100644
index 000000000000..e2c0d9729eae
Binary files /dev/null and b/assets/animations/ExpenseAssistant.lottie differ
diff --git a/assets/animations/SpendAnalysis.lottie b/assets/animations/SpendAnalysis.lottie
new file mode 100644
index 000000000000..f1cc7ab61cb5
Binary files /dev/null and b/assets/animations/SpendAnalysis.lottie differ
diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv
index fb1b04d4d4c0..80cf8829a007 100644
--- a/config/eslint/eslint.seatbelt.tsv
+++ b/config/eslint/eslint.seatbelt.tsv
@@ -697,6 +697,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 6756a8c6ac8c..905758dfc8b4 100644
--- a/jest/setupAfterEnv.ts
+++ b/jest/setupAfterEnv.ts
@@ -8,6 +8,11 @@ import Onyx from 'react-native-onyx';
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/src/CONST/index.ts b/src/CONST/index.ts
index 701893c986d6..149b6fdb9bfc 100644
--- a/src/CONST/index.ts
+++ b/src/CONST/index.ts
@@ -7266,6 +7266,7 @@ const CONST = {
SCREENS.SAML_SIGN_IN,
SCREENS.VALIDATE_LOGIN,
SCREENS.MIGRATED_USER_WELCOME_MODAL.DYNAMIC_ROOT,
+ SCREENS.AI_FEATURES_PROMO_MODAL.DYNAMIC_ROOT,
SCREENS.MONEY_REQUEST.STEP_SCAN,
SCREENS.DOMAIN.MEMBERS_MOVE_TO_GROUP,
...Object.values(SCREENS.MULTIFACTOR_AUTHENTICATION),
@@ -7800,6 +7801,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: {
@@ -8812,6 +8821,14 @@ const CONST = {
ROW: 'DomainMembers-Row',
},
},
+ FEATURE_TRAINING: {
+ CLOSE_BUTTON: 'FeatureTraining-CloseButton',
+ BACK_BUTTON: 'FeatureTraining-BackButton',
+ },
+ AI_FEATURES_PROMO_MODAL: {
+ CONFIRM_BUTTON: 'AIFeaturesPromoModal-ConfirmButton',
+ HELP_BUTTON: 'AIFeaturesPromoModal-HelpButton',
+ },
},
DOMAIN: {
diff --git a/src/NAVIGATORS.ts b/src/NAVIGATORS.ts
index 5b7369b7dddf..a042f4e6659e 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_DEMO_NAVIGATOR: 'TestDriveDemoNavigator',
REPORTS_SPLIT_NAVIGATOR: 'ReportsSplitNavigator',
SETTINGS_SPLIT_NAVIGATOR: 'SettingsSplitNavigator',
diff --git a/src/ROUTES.ts b/src/ROUTES.ts
index 8d9908767d6c..815445e71315 100644
--- a/src/ROUTES.ts
+++ b/src/ROUTES.ts
@@ -144,6 +144,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: {
+ path: 'ai-features-promo',
+ entryScreens: ['*'],
+ },
EXPENSE_LIMIT_TYPE_SELECTOR: {
path: 'expense-limit-type',
entryScreens: [SCREENS.WORKSPACE.DYNAMIC_CATEGORY_FLAG_AMOUNTS_OVER],
diff --git a/src/SCREENS.ts b/src/SCREENS.ts
index 68cb4353f4f0..877bab0bc953 100644
--- a/src/SCREENS.ts
+++ b/src/SCREENS.ts
@@ -967,6 +967,10 @@ const SCREENS = {
DYNAMIC_ROOT: 'Dynamic_MigratedUserWelcomeModal_Root',
},
+ AI_FEATURES_PROMO_MODAL: {
+ DYNAMIC_ROOT: 'Dynamic_AIFeaturesPromoModal_Root',
+ },
+
TEST_DRIVE_DEMO: {
ROOT: 'TestDrive_Demo_Root',
},
diff --git a/src/components/AIFeaturesPromoModal.tsx b/src/components/AIFeaturesPromoModal.tsx
new file mode 100644
index 000000000000..a3b9dca157d8
--- /dev/null
+++ b/src/components/AIFeaturesPromoModal.tsx
@@ -0,0 +1,107 @@
+import useLocalize from '@hooks/useLocalize';
+import usePermissions from '@hooks/usePermissions';
+import useThemeStyles from '@hooks/useThemeStyles';
+
+import {dismissProductTraining} from '@libs/actions/Welcome';
+import Navigation from '@libs/Navigation/Navigation';
+
+import variables from '@styles/variables';
+
+import CONST from '@src/CONST';
+
+import React 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 LottieAnimations from './LottieAnimations';
+import Text from './Text';
+
+function AIFeaturesPromoModal() {
+ const {translate} = useLocalize();
+ const styles = useThemeStyles();
+ const {isBetaEnabled} = usePermissions();
+ const canUseCustomAgent = isBetaEnabled(CONST.BETAS.CUSTOM_AGENT);
+
+ const customAgentPromoTitle = (
+
+ {translate('aiFeaturesPromoModal.customAgents.title')}
+
+
+ );
+
+ const pages: FeatureTrainingContentDataProps[] = [
+ {
+ 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 dismissNVP = (isDismissedUsingCloseButton: boolean) => {
+ dismissProductTraining(CONST.AI_FEATURES_PROMO_MODAL, isDismissedUsingCloseButton);
+ };
+
+ const confirmAndCloseModal = () => {
+ Navigation.goBack();
+ dismissNVP(false);
+ };
+
+ const closeModal = () => {
+ Navigation.goBack();
+ dismissNVP(true);
+ };
+
+ return (
+
+
+
+ );
+}
+
+export default AIFeaturesPromoModal;
diff --git a/src/components/FeatureTrainingContent.tsx b/src/components/FeatureTrainingContent.tsx
deleted file mode 100644
index 02969874c268..000000000000
--- a/src/components/FeatureTrainingContent.tsx
+++ /dev/null
@@ -1,342 +0,0 @@
-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 type {ImageContentFit} from 'expo-image';
-import type {SourceLoadEventPayload} from 'expo-video';
-// eslint-disable-next-line no-restricted-imports -- type-only import from react-native
-import type {LayoutChangeEvent, ScrollView as RNScrollView, StyleProp, TextStyle, ViewStyle} from 'react-native';
-import type {MergeExclusive} from 'type-fest';
-
-import React, {useEffect, useRef, useState} from 'react';
-import {View} from 'react-native';
-import {GestureHandlerRootView} from 'react-native-gesture-handler';
-
-import type ImageSVGProps from './ImageSVG/types';
-import type DotLottieAnimation from './LottieAnimations/types';
-
-import Button from './Button';
-import CheckboxWithLabel from './CheckboxWithLabel';
-import FormAlertWithSubmitButton from './FormAlertWithSubmitButton';
-import ImageSVG from './ImageSVG';
-import Lottie from './Lottie';
-import LottieAnimations from './LottieAnimations';
-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;
-
- /** Style for the outer container of the animation */
- illustrationOuterContainerStyle?: StyleProp;
-
- /** Title for the modal */
- title?: string | React.ReactNode;
-
- /** Describe what is showing */
- description?: string;
-
- /** Style for the title */
- titleStyles?: StyleProp;
-
- /** 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;
-
- /** Styles for the content outer container */
- contentOuterContainerStyles?: StyleProp;
-
- /** Children to show below title and description and above buttons */
- children?: React.ReactNode;
-
- /** Content width for wide layouts */
- width?: number;
-
- /** Whether closing should happen on confirm */
- shouldCloseOnConfirm?: boolean;
-
- /** Whether the content is scrollable */
- shouldUseScrollView?: boolean;
-};
-
-type FeatureTrainingContentVideoProps = {
- /** Animation to show when video is unavailable */
- animation?: DotLottieAnimation;
-
- /** Additional styles for the animation */
- animationStyle?: StyleProp;
-
- /** 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;
-
-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 = '',
- titleStyles,
- shouldShowDismissModalOption = false,
- confirmText = '',
- onConfirm,
- onClose,
- onWillShowAgainChange,
- helpText = '',
- onHelp,
- children,
- contentInnerContainerStyles,
- contentOuterContainerStyles,
- imageWidth,
- imageHeight,
- shouldCloseOnConfirm = true,
- shouldUseScrollView: shouldUseScrollViewProp = false,
-}: 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(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 (
-
- {!!image && (
-
- )}
- {!!videoURL && videoStatus === 'video' && (
-
-
-
- )}
- {((!videoURL && !image) || (!!videoURL && videoStatus === 'animation')) && (
-
- {isReduceMotionEnabled && (animation ?? LottieAnimations.Hands) === LottieAnimations.Hands ? (
-
- ) : (
-
- )}
-
- )}
-
- );
- };
-
- 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 (
- 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}
- >
-
- {renderIllustration()}
-
-
- {!!title && !!description && (
-
- {typeof title === 'string' ? {title} : title}
- {description}
- {children}
-
- )}
- {shouldShowDismissModalOption && (
-
- )}
- {!!helpText && (
-
-
- );
-}
-
-export default FeatureTrainingContent;
-
-export type {FeatureTrainingContentProps};
diff --git a/src/components/FeatureTrainingContent/FeatureTrainingCarousel.tsx b/src/components/FeatureTrainingContent/FeatureTrainingCarousel.tsx
new file mode 100644
index 000000000000..567e951dfa61
--- /dev/null
+++ b/src/components/FeatureTrainingContent/FeatureTrainingCarousel.tsx
@@ -0,0 +1,298 @@
+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 useSafeAreaInsets from '@hooks/useSafeAreaInsets';
+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';
+
+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
+// 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;
+
+const WEB_CAROUSEL_PAGE_SNAP_STYLE: ViewStyle = Platform.OS === 'web' ? ({scrollSnapAlign: 'start', scrollSnapStop: 'always'} as ViewStyle) : {};
+
+function FeatureTrainingCarousel({
+ pages,
+ width = variables.featureTrainingModalWidth,
+ titleStyles,
+ illustrationAspectRatio,
+ illustrationInnerContainerStyle,
+ illustrationOuterContainerStyle,
+ shouldRenderHTMLDescription = false,
+ shouldUseScrollView: shouldUseScrollViewProp = false,
+ helpText = '',
+ onHelp = () => {},
+ helpSentryLabel,
+ confirmSentryLabel,
+ contentInnerContainerStyles,
+ contentOuterContainerStyles,
+ onConfirm,
+ onClose,
+ onPageChange,
+}: FeatureTrainingCarouselProps) {
+ const styles = useThemeStyles();
+ const StyleUtils = useStyleUtils();
+ const theme = useTheme();
+ 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>(null);
+ const lastReportedPage = useRef(0);
+
+ const scrollViewRef = useRef(null);
+ const [containerHeight, setContainerHeight] = useState(0);
+ const [contentHeight, setContentHeight] = useState(0);
+ const [contentMinHeight, setContentMinHeight] = useState(undefined);
+ const measuredHeightsRef = useRef>({});
+ 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)));
+ };
+
+ 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);
+ 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 confirmPage = () => {
+ if (currentPage < pages.length - 1) {
+ advanceCarousel();
+ return;
+ }
+ 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) => (
+
+ ));
+
+ const currentPageData = pages.at(currentPage);
+
+ return (
+ {
+ const newWidth = e.nativeEvent.layout.width;
+ if (newWidth === carouselViewportWidth || newWidth <= 0) {
+ return;
+ }
+ 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
+ // when moving between pages with different content lengths.
+
+ {pages.map((page, index) => (
+
+
+
+ ))}
+
+ )}
+ {carouselViewportWidth > 0 && (
+ <>
+
+ `FeatureTrainingContentIllustration-${index}`}
+ horizontal
+ pagingEnabled
+ disableIntervalMomentum
+ snapToInterval={carouselViewportWidth}
+ decelerationRate="fast"
+ bounces={false}
+ showsHorizontalScrollIndicator={false}
+ keyboardShouldPersistTaps="handled"
+ viewabilityConfig={CAROUSEL_VIEWABILITY_CONFIG}
+ onViewableItemsChanged={onViewableItemsChanged}
+ getItemLayout={(_data, index) => ({length: carouselViewportWidth, offset: index * carouselViewportWidth, index})}
+ renderItem={({item: page, index}) => (
+
+
+
+ )}
+ />
+
+ {carouselPaginationDots}
+
+
+ 0}
+ onBack={goBack}
+ titleStyles={titleStyles}
+ contentInnerContainerStyles={[contentInnerContainerStyles, contentMinHeight !== undefined && {minHeight: contentMinHeight}]}
+ contentOuterContainerStyles={contentOuterContainerStyles}
+ shouldRenderHTMLDescription={shouldRenderHTMLDescription}
+ />
+
+
+
+
+
+
+
+ >
+ )}
+
+ );
+}
+
+export default FeatureTrainingCarousel;
diff --git a/src/components/FeatureTrainingContent/FeatureTrainingContent.tsx b/src/components/FeatureTrainingContent/FeatureTrainingContent.tsx
new file mode 100644
index 000000000000..792e4e070372
--- /dev/null
+++ b/src/components/FeatureTrainingContent/FeatureTrainingContent.tsx
@@ -0,0 +1,136 @@
+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';
+
+function FeatureTrainingContent({
+ illustrationInnerContainerStyle,
+ illustrationOuterContainerStyle,
+ illustrationAspectRatio: illustrationAspectRatioProp,
+ width = variables.featureTrainingModalWidth,
+ title = '',
+ subtitle = '',
+ description = '',
+ titleStyles,
+ shouldShowDismissModalOption = false,
+ confirmText = '',
+ onConfirm,
+ onClose,
+ onWillShowAgainChange,
+ helpText = '',
+ onHelp,
+ children,
+ contentInnerContainerStyles,
+ contentOuterContainerStyles,
+ shouldRenderHTMLDescription = false,
+ shouldCloseOnConfirm = true,
+ shouldUseScrollView: shouldUseScrollViewProp = false,
+ 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(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 (
+ 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}
+ >
+
+
+ {children}
+
+
+ );
+}
+
+export default FeatureTrainingContent;
diff --git a/src/components/FeatureTrainingContent/FeatureTrainingContentBody.tsx b/src/components/FeatureTrainingContent/FeatureTrainingContentBody.tsx
new file mode 100644
index 000000000000..467bf1a52a30
--- /dev/null
+++ b/src/components/FeatureTrainingContent/FeatureTrainingContentBody.tsx
@@ -0,0 +1,123 @@
+import Button from '@components/ButtonComposed';
+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 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'
+ | 'onConfirm'
+ | 'onHelp'
+ | 'helpSentryLabel'
+ | 'confirmSentryLabel'
+ | 'shouldShowDismissModalOption'
+ | 'titleStyles'
+ | 'contentInnerContainerStyles'
+ | 'contentOuterContainerStyles'
+ | 'shouldRenderHTMLDescription'
+ | 'children'
+> &
+ 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;
+
+ /** Whether to render a Back button (carousel mode, non-first pages) */
+ shouldShowBackButton?: boolean;
+
+ /** Callback when the Back button is pressed */
+ onBack?: () => void;
+ };
+
+function FeatureTrainingContentBody({
+ title = '',
+ subtitle = '',
+ description = '',
+ confirmText,
+ onConfirm,
+ helpText = '',
+ onHelp,
+ helpSentryLabel,
+ confirmSentryLabel,
+ shouldShowDismissModalOption = false,
+ willShowAgain = false,
+ toggleWillShowAgain,
+ shouldShowBackButton = false,
+ onBack,
+ titleStyles,
+ contentInnerContainerStyles,
+ contentOuterContainerStyles,
+ shouldRenderHTMLDescription = false,
+ children,
+}: FeatureTrainingModalContentProps) {
+ const styles = useThemeStyles();
+ const {translate} = useLocalize();
+
+ return (
+
+
+ {children}
+
+ {shouldShowDismissModalOption && (
+
+ )}
+ {!!helpText && (
+
+ )}
+
+ {shouldShowBackButton && (
+
+ )}
+ onConfirm?.(willShowAgain)}
+ buttonText={confirmText}
+ enabledWhenOffline
+ sentryLabel={confirmSentryLabel}
+ containerStyles={styles.flex1}
+ />
+
+
+ );
+}
+
+export default FeatureTrainingContentBody;
diff --git a/src/components/FeatureTrainingContent/FeatureTrainingContentBodyText.tsx b/src/components/FeatureTrainingContent/FeatureTrainingContentBodyText.tsx
new file mode 100644
index 000000000000..757fd4f0db7f
--- /dev/null
+++ b/src/components/FeatureTrainingContent/FeatureTrainingContentBodyText.tsx
@@ -0,0 +1,74 @@
+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;
+
+ /** Subtitle for the modal */
+ subtitle?: string;
+
+ /** Describe what is showing */
+ description?: string;
+
+ /** Style for the title */
+ titleStyles?: StyleProp;
+
+ /** Styles applied to the inner text container */
+ contentInnerContainerStyles?: StyleProp;
+
+ /** 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 FeatureTrainingContentBodyText({
+ title = '',
+ subtitle = '',
+ description = '',
+ titleStyles,
+ contentInnerContainerStyles,
+ shouldRenderHTMLDescription = false,
+ children,
+ onLayout,
+}: FeatureTrainingModalTextContentProps) {
+ const styles = useThemeStyles();
+ const {onboardingIsMediumOrLargerScreenWidth} = useResponsiveLayout();
+
+ if (!title || !description) {
+ return null;
+ }
+
+ return (
+
+ {!!subtitle && {subtitle}}
+ {typeof title === 'string' ? {title} : title}
+ {shouldRenderHTMLDescription ? (
+
+
+
+ ) : (
+ {description}
+ )}
+ {children}
+
+ );
+}
+
+export default FeatureTrainingContentBodyText;
diff --git a/src/components/FeatureTrainingContent/FeatureTrainingContentIllustration.tsx b/src/components/FeatureTrainingContent/FeatureTrainingContentIllustration.tsx
new file mode 100644
index 000000000000..0716dca3987f
--- /dev/null
+++ b/src/components/FeatureTrainingContent/FeatureTrainingContentIllustration.tsx
@@ -0,0 +1,170 @@
+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.
+// 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';
+
+type FeatureTrainingContentIllustrationProps = Pick &
+ BaseFeatureTrainingContentIllustrationProps & {
+ /** Whether this illustration belongs to the currently-visible carousel page */
+ isFocused?: boolean;
+
+ /** Whether this illustration is part of a carousel */
+ 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,
+ videoURL,
+ image,
+ contentFitImage,
+ imageWidth,
+ imageHeight,
+ illustrationAspectRatio: illustrationAspectRatioProp,
+ illustrationInnerContainerStyle,
+ illustrationOuterContainerStyle,
+ isFocused = true,
+ isCarousel = false,
+}: FeatureTrainingContentIllustrationProps) {
+ const styles = useThemeStyles();
+ 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 isInLandscapeMode = isInLandscapeModeUtil(windowWidth, windowHeight);
+
+ const animationRef = useRef(null);
+ useEffect(() => {
+ if (isMobile() || !isCarousel || !animationRef.current || isReduceMotionEnabled) {
+ return;
+ }
+ if (isFocused) {
+ animationRef.current.play();
+ } else {
+ animationRef.current.pause();
+ }
+ }, [isFocused, isCarousel, isReduceMotionEnabled]);
+
+ 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 (
+
+
+ {!!image && (
+
+ )}
+ {!!videoURL && videoStatus === 'video' && (
+
+
+
+ )}
+ {((!videoURL && !image) || (!!videoURL && videoStatus === 'animation')) && (
+
+ {isReduceMotionEnabled && (animation ?? LottieAnimations.Hands) === LottieAnimations.Hands ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+
+ );
+}
+
+export default FeatureTrainingContentIllustration;
diff --git a/src/components/FeatureTrainingContent/index.tsx b/src/components/FeatureTrainingContent/index.tsx
new file mode 100644
index 000000000000..ce07f8f6eb2c
--- /dev/null
+++ b/src/components/FeatureTrainingContent/index.tsx
@@ -0,0 +1,8 @@
+import type {FeatureTrainingContentDataProps, FeatureTrainingContentProps} from './types';
+
+import FeatureTrainingCarousel from './FeatureTrainingCarousel';
+import FeatureTrainingContent from './FeatureTrainingContent';
+
+export default FeatureTrainingContent;
+export {FeatureTrainingCarousel};
+export type {FeatureTrainingContentProps, FeatureTrainingContentDataProps};
diff --git a/src/components/FeatureTrainingContent/types.ts b/src/components/FeatureTrainingContent/types.ts
new file mode 100644
index 000000000000..4ec58b1fb41b
--- /dev/null
+++ b/src/components/FeatureTrainingContent/types.ts
@@ -0,0 +1,148 @@
+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';
+
+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;
+
+ /** Style for the outer container of the animation */
+ illustrationOuterContainerStyle?: StyleProp;
+
+ /** Style for the title */
+ titleStyles?: StyleProp;
+
+ /** 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;
+
+ /** Styles for the content outer container */
+ contentOuterContainerStyles?: StyleProp;
+
+ /** Styles for the modal inner container */
+ modalInnerContainerStyle?: ViewStyle;
+
+ /** Children to show below title and description and above buttons (single-page mode only) */
+ children?: ReactNode;
+
+ /** Modal width */
+ width?: number;
+
+ /** Whether to disable the modal */
+ isModalDisabled?: 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 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;
+
+ /** 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;
+
+ /** 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;
+
+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/Lottie/index.tsx b/src/components/Lottie/index.tsx
index fa9f8891754d..0ec61429e3eb 100644
--- a/src/components/Lottie/index.tsx
+++ b/src/components/Lottie/index.tsx
@@ -13,6 +13,7 @@ import CONST from '@src/CONST';
import {useSplashScreenState} from '@src/SplashScreenStateContext';
import type {AnimationObject, LottieViewProps} from 'lottie-react-native';
+import type {ForwardedRef} from 'react';
import {NavigationContainerRefContext, NavigationContext} from '@react-navigation/native';
import LottieView from 'lottie-react-native';
@@ -20,11 +21,12 @@ import React, {useContext, useEffect, useRef, useState} from 'react';
import {View} from 'react-native';
type Props = {
+ ref?: ForwardedRef;
source: DotLottieAnimation;
shouldLoadAfterInteractions?: boolean;
} & Omit;
-function Lottie({source, webStyle, shouldLoadAfterInteractions, ...props}: Props) {
+function Lottie({ref, source, webStyle, shouldLoadAfterInteractions, ...props}: Props) {
const animationRef = useRef(null);
const appState = useAppState();
const {splashScreenState} = useSplashScreenState();
@@ -126,8 +128,15 @@ 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) {
+ // Forwarding the LottieView ref onto the caller-provided RefObject requires mutating `.current`.
+ // 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 0e4a5984ca4a..31d613dd8750 100644
--- a/src/components/LottieAnimations/index.tsx
+++ b/src/components/LottieAnimations/index.tsx
@@ -96,6 +96,22 @@ const DotLottieAnimations = {
w: 204,
h: 204,
},
+ SpendAnalysis: {
+ file: require('@assets/animations/SpendAnalysis.lottie'),
+ w: 440,
+ h: 240,
+ backgroundColor: colors.pink700,
+ },
+ ExpenseAssistant: {
+ file: require('@assets/animations/ExpenseAssistant.lottie'),
+ w: 440,
+ h: 240,
+ },
+ CustomAgents: {
+ file: require('@assets/animations/CustomAgents.lottie'),
+ w: 440,
+ h: 240,
+ },
} satisfies Record;
export default DotLottieAnimations;
diff --git a/src/components/Section/index.tsx b/src/components/Section/index.tsx
index 50f14668e735..8f8e538e5dba 100644
--- a/src/components/Section/index.tsx
+++ b/src/components/Section/index.tsx
@@ -172,6 +172,7 @@ function Section({
style={styles.h100}
webStyle={styles.h100}
loop
+ autoPlay
shouldLoadAfterInteractions={shouldUseNarrowLayout}
/>
) : (
diff --git a/src/hooks/useAIFeaturesPromoModal.ts b/src/hooks/useAIFeaturesPromoModal.ts
new file mode 100644
index 000000000000..6e4feff0e3c3
--- /dev/null
+++ b/src/hooks/useAIFeaturesPromoModal.ts
@@ -0,0 +1,115 @@
+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 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;
+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) {
+ const [isLoadingApp = true, isLoadingAppMetadata] = useOnyx(ONYXKEYS.IS_LOADING_APP);
+ 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);
+
+ 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) {
+ return;
+ }
+ hasRedirectedToAIFeaturesPromoModal = false;
+ }, [isAIPromoModalDismissed]);
+
+ useEffect(() => {
+ if (!isMigrationModalPending) {
+ return;
+ }
+ observedActiveMigrationModalThisSession = true;
+ }, [isMigrationModalPending]);
+
+ useEffect(() => {
+ if (hasCompletedOnboarding !== false) {
+ return;
+ }
+ observedActiveOnboardingThisSession = true;
+ }, [hasCompletedOnboarding]);
+
+ const isAllOnyxLoaded = !isLoadingOnyxValue(isLoadingAppMetadata, accountMetadata, 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;
+ }
+ const lastRoute = navigationRef.getRootState?.()?.routes.at(-1)?.name;
+ 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));
+ });
+ },
+ waitForUpcomingTransition: true,
+ });
+
+ return () => {
+ handle.cancel();
+ isWaitingForProtectedRoutes.current = false;
+ };
+ }, [isEligible, isAIPromoModalDismissed]);
+}
+
+export default useAIFeaturesPromoModal;
diff --git a/src/languages/de.ts b/src/languages/de.ts
index 7c8d98df242f..dd5c9b11f3b5 100644
--- a/src/languages/de.ts
+++ b/src/languages/de.ts
@@ -10350,5 +10350,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: `Concierge zeigt monatliche Ausgabenanalysen an und ermöglicht es Ihnen, die Details hinter jeder Zahl genauer zu betrachten. Mehr erfahren.`,
+ },
+ expenseAssistant: {
+ title: 'Lernen Sie Ihre neue Spesenassistenz kennen',
+ description: `Chatten Sie mit Concierge, um Ausgaben direkt in der App oder per E-Mail oder SMS zu erstellen und zu aktualisieren. Mehr erfahren.`,
+ },
+ customAgents: {
+ title: 'Erstellen Sie Ihre eigenen Agenten',
+ description: `Erstellen Sie benutzerdefinierte Agenten, die Ausgaben anhand Ihrer Regeln prüfen, genehmigen und weiterleiten. Mehr erfahren.`,
+ },
+ },
};
export default translations;
diff --git a/src/languages/en.ts b/src/languages/en.ts
index 8d725b42f641..48c74d7c846e 100644
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -10166,6 +10166,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: `Concierge surfaces monthly spend insights and lets you drill into the details behind every number. Learn more.`,
+ },
+ expenseAssistant: {
+ title: 'Meet your new expense assistant',
+ description: `Chat with Concierge to create and update expenses, right in the app or by email or text. Learn more.`,
+ },
+ customAgents: {
+ title: 'Build your own agents',
+ description: `Create custom agents to review, approve, and route expenses based on rules you set. Learn more.`,
+ },
+ },
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 e876881150da..3f447a3d85cb 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -10584,5 +10584,21 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`,
lockScreenTrackingText: 'Siguiendo...',
},
},
+ aiFeaturesPromoModal: {
+ subtitle: 'Nuevo en Concierge AI',
+ confirmText: '¡Vamos!',
+ spendAnalysis: {
+ title: 'Análisis interactivo del gasto',
+ description: `Concierge muestra información mensual sobre gastos y te permite profundizar en los detalles detrás de cada cifra. Más información.`,
+ },
+ expenseAssistant: {
+ title: 'Conoce a tu nuevo asistente de gastos',
+ description: `Chatea con Concierge para crear y actualizar gastos, directamente en la aplicación o por correo electrónico o mensaje de texto. Más información.`,
+ },
+ customAgents: {
+ title: 'Crea tus propios agentes',
+ description: `Crea agentes personalizados para revisar, aprobar y asignar gastos según las reglas que configures. Más información.`,
+ },
+ },
};
export default translations;
diff --git a/src/languages/fr.ts b/src/languages/fr.ts
index 6fb1bf109c2a..c3fad8209053 100644
--- a/src/languages/fr.ts
+++ b/src/languages/fr.ts
@@ -10388,5 +10388,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: `Concierge met en avant des analyses mensuelles des dépenses et vous permet d’examiner en détail chaque chiffre. En savoir plus.`,
+ },
+ expenseAssistant: {
+ title: 'Découvrez votre nouvel assistant de dépenses',
+ description: `Discutez avec Concierge pour créer et mettre à jour des dépenses, directement dans l’application ou par e-mail ou SMS. En savoir plus.`,
+ },
+ customAgents: {
+ title: 'Créez vos propres agents',
+ description: `Créez des agents personnalisés pour examiner, approuver et acheminer les dépenses selon les règles que vous définissez. En savoir plus.`,
+ },
+ },
};
export default translations;
diff --git a/src/languages/it.ts b/src/languages/it.ts
index fa7323cf253a..3f6a04678900 100644
--- a/src/languages/it.ts
+++ b/src/languages/it.ts
@@ -10340,5 +10340,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: `Concierge mette in evidenza approfondimenti sulla spesa mensile e ti permette di approfondire i dettagli dietro ogni numero. Scopri di più.`,
+ },
+ expenseAssistant: {
+ title: 'Incontra il tuo nuovo assistente per le spese',
+ description: `Chatta con Concierge per creare e aggiornare le spese direttamente nell’app o via email o SMS. Scopri di più.`,
+ },
+ customAgents: {
+ title: 'Crea i tuoi agenti',
+ description: `Crea agenti personalizzati per verificare, approvare e instradare le spese in base alle regole che imposti. Scopri di più.`,
+ },
+ },
};
export default translations;
diff --git a/src/languages/ja.ts b/src/languages/ja.ts
index 5ff43744a4b8..757c25a375d5 100644
--- a/src/languages/ja.ts
+++ b/src/languages/ja.ts
@@ -10190,5 +10190,21 @@ ${reportName}`,
negativeButton: 'そうでもありません',
},
monthPickerPage: {month: '月', selectMonth: '月を選択してください'},
+ aiFeaturesPromoModal: {
+ subtitle: 'はじめての Concierge AI',
+ confirmText: '始めましょう!',
+ spendAnalysis: {
+ title: 'インタラクティブな支出分析',
+ description: `Concierge は毎月の支出インサイトを提示し、すべての数値の内訳を詳しく確認できるようにします。詳しく見る。`,
+ },
+ expenseAssistant: {
+ title: '新しい経費アシスタントをご紹介します',
+ description: `アプリ内やメール、テキストメッセージでConciergeとチャットして、経費を作成・更新できます。詳しく見る。`,
+ },
+ customAgents: {
+ title: '独自のエージェントを作成する',
+ description: `設定したルールに基づいて経費を確認、承認、振り分けるカスタムエージェントを作成できます。さらに詳しく。`,
+ },
+ },
};
export default translations;
diff --git a/src/languages/nl.ts b/src/languages/nl.ts
index 01433ff2cea5..4dcf8b5b0402 100644
--- a/src/languages/nl.ts
+++ b/src/languages/nl.ts
@@ -10305,5 +10305,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: `Concierge toont maandelijkse uitgaveninzichten en laat je inzoomen op de details achter elk getal. Meer informatie.`,
+ },
+ expenseAssistant: {
+ title: 'Maak kennis met je nieuwe declaratie-assistent',
+ description: `Chat met Concierge om uitgaven aan te maken en bij te werken, rechtstreeks in de app of via e-mail of sms. Meer informatie.`,
+ },
+ customAgents: {
+ title: 'Bouw je eigen agents',
+ description: `Maak aangepaste agents om uitgaven te beoordelen, goed te keuren en door te sturen op basis van regels die jij instelt. Meer informatie.`,
+ },
+ },
};
export default translations;
diff --git a/src/languages/pl.ts b/src/languages/pl.ts
index 8c533103f70d..81ae503aabdb 100644
--- a/src/languages/pl.ts
+++ b/src/languages/pl.ts
@@ -10274,5 +10274,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: `Concierge przedstawia miesięczne informacje o wydatkach i pozwala zagłębić się w szczegóły stojące za każdą liczbą. Dowiedz się więcej.`,
+ },
+ expenseAssistant: {
+ title: 'Poznaj swojego nowego asystenta wydatków',
+ description: `Rozmawiaj z Concierge, aby tworzyć i aktualizować wydatki bezpośrednio w aplikacji, e-mailem lub SMS-em. Dowiedz się więcej.`,
+ },
+ customAgents: {
+ title: 'Zbuduj własne agentów',
+ description: `Twórz niestandardowych agentów do przeglądania, zatwierdzania i kierowania wydatków na podstawie ustalonych przez siebie zasad. Dowiedz się więcej.`,
+ },
+ },
};
export default translations;
diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts
index c9a7f3a374c9..f1a75962e152 100644
--- a/src/languages/pt-BR.ts
+++ b/src/languages/pt-BR.ts
@@ -10291,5 +10291,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: `O Concierge apresenta insights mensais de gastos e permite que você aprofunde nos detalhes por trás de cada número. Saiba mais.`,
+ },
+ expenseAssistant: {
+ title: 'Conheça seu novo assistente de despesas',
+ description: `Converse com o Concierge para criar e atualizar despesas, direto no app ou por e-mail ou SMS. Saiba mais.`,
+ },
+ customAgents: {
+ title: 'Crie seus próprios agentes',
+ description: `Crie agentes personalizados para revisar, aprovar e direcionar despesas com base nas regras que você definir. Saiba mais.`,
+ },
+ },
};
export default translations;
diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts
index c6e52c2a09d8..5e46367a209c 100644
--- a/src/languages/zh-hans.ts
+++ b/src/languages/zh-hans.ts
@@ -9981,5 +9981,21 @@ ${reportName}`,
},
proactiveAppReview: {title: '喜欢全新的 Expensify 吗?', description: '请告诉我们,这样我们就能帮助您让报销体验变得更好。', positiveButton: '太棒了!', negativeButton: '不太是'},
monthPickerPage: {month: '月份', selectMonth: '请选择月份'},
+ aiFeaturesPromoModal: {
+ subtitle: 'Concierge AI 新手指南',
+ confirmText: '出发吧!',
+ spendAnalysis: {
+ title: '交互式支出分析',
+ description: `Concierge 会提供每月支出洞察,并让你深入查看每个数字背后的详细信息。了解详情。`,
+ },
+ expenseAssistant: {
+ title: '认识你的全新报销助手',
+ description: `在应用内或通过电子邮件、短信与 Concierge 聊天来创建和更新报销。了解更多。`,
+ },
+ customAgents: {
+ title: '构建你自己的代理',
+ description: `创建自定义代理,根据你设置的规则审核、批准和分配报销。了解更多。`,
+ },
+ },
};
export default translations;
diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.tsx b/src/libs/Navigation/AppNavigator/AuthScreens.tsx
index 9910d6b6679e..82ac934225f2 100644
--- a/src/libs/Navigation/AppNavigator/AuthScreens.tsx
+++ b/src/libs/Navigation/AppNavigator/AuthScreens.tsx
@@ -62,6 +62,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';
@@ -309,6 +310,11 @@ function AuthScreens() {
options={rootNavigatorScreenOptions.centeredModalNavigator}
component={MigratedUserWelcomeModalNavigator}
/>
+
();
+
+function AIFeaturesPromoModalNavigator() {
+ return (
+
+
+
+
+
+ );
+}
+
+export default AIFeaturesPromoModalNavigator;
diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts
index 95513d769391..061c14fdb2af 100644
--- a/src/libs/Navigation/linkingConfig/config.ts
+++ b/src/libs/Navigation/linkingConfig/config.ts
@@ -53,6 +53,11 @@ const config: LinkingOptions['config'] = {
[SCREENS.MIGRATED_USER_WELCOME_MODAL.DYNAMIC_ROOT]: DYNAMIC_ROUTES.MIGRATED_USER_WELCOME.path,
},
},
+ [NAVIGATORS.AI_FEATURES_PROMO_MODAL_NAVIGATOR]: {
+ screens: {
+ [SCREENS.AI_FEATURES_PROMO_MODAL.DYNAMIC_ROOT]: DYNAMIC_ROUTES.AI_FEATURES_PROMO.path,
+ },
+ },
[NAVIGATORS.TEST_DRIVE_DEMO_NAVIGATOR]: {
screens: {
diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts
index 12ff43f7d6ca..b165988c7344 100644
--- a/src/libs/Navigation/types.ts
+++ b/src/libs/Navigation/types.ts
@@ -3011,6 +3011,10 @@ type MigratedUserModalNavigatorParamList = {
[SCREENS.MIGRATED_USER_WELCOME_MODAL.DYNAMIC_ROOT]: undefined;
};
+type AIFeaturesPromoModalNavigatorParamList = {
+ [SCREENS.AI_FEATURES_PROMO_MODAL.DYNAMIC_ROOT]: undefined;
+};
+
type TestDriveDemoNavigatorParamList = {
[SCREENS.TEST_DRIVE_DEMO.ROOT]: undefined;
};
@@ -3181,6 +3185,7 @@ type AuthScreensParamList = SharedScreensParamList &
[NAVIGATORS.ONBOARDING_MODAL_NAVIGATOR]: NavigatorScreenParams;
[NAVIGATORS.FEATURE_TRAINING_MODAL_NAVIGATOR]: NavigatorScreenParams;
[NAVIGATORS.MIGRATED_USER_MODAL_NAVIGATOR]: NavigatorScreenParams;
+ [NAVIGATORS.AI_FEATURES_PROMO_MODAL_NAVIGATOR]: NavigatorScreenParams;
[NAVIGATORS.TEST_DRIVE_DEMO_NAVIGATOR]: NavigatorScreenParams;
[SCREENS.CONNECTION_COMPLETE]: undefined;
[NAVIGATORS.SHARE_MODAL_NAVIGATOR]: NavigatorScreenParams;
@@ -3451,6 +3456,7 @@ export type {
WorkspaceSplitNavigatorParamList,
WorkspaceNavigatorParamList,
MigratedUserModalNavigatorParamList,
+ AIFeaturesPromoModalNavigatorParamList,
WorkspaceConfirmationNavigatorParamList,
WorkspaceDuplicateNavigatorParamList,
PolicyCopySettingsNavigatorParamList,
diff --git a/src/styles/index.ts b/src/styles/index.ts
index 5656fdd573d5..29e6ddb272f9 100644
--- a/src/styles/index.ts
+++ b/src/styles/index.ts
@@ -6000,6 +6000,12 @@ const staticStyles = (theme: ThemeColors) =>
border: 'none',
},
+ 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 ac28ad102438..368e7e856d25 100644
--- a/src/styles/utils/index.ts
+++ b/src/styles/utils/index.ts
@@ -2123,6 +2123,26 @@ const createStyleUtils = (theme: ThemeColors, styles: ThemeStyles) => ({
styleObj[key] = null;
return styleObj;
}, {} as Nullable) 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,
+ }),
+
+ getFeatureTrainingCarouselCloseButtonContainerStyle: (padding: number): ViewStyle => ({
+ position: 'absolute',
+ top: padding,
+ right: padding,
+ zIndex: 1,
+ }),
+
+ getFeatureTrainingCarouselDotsContainerStyle: (bottomOffset: number): ViewStyle => ({
+ bottom: bottomOffset,
+ }),
+
getScrollableFeatureTrainingModalStyles: (
insets: EdgeInsets,
isKeyboardOpen = false,
diff --git a/src/styles/variables.ts b/src/styles/variables.ts
index 42366a90b72f..28d9bfa92ef1 100644
--- a/src/styles/variables.ts
+++ b/src/styles/variables.ts
@@ -285,6 +285,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
/**