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 && ( - + )} + + {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 /**