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 70b186393a83..56d6dad47613 100644 --- a/config/eslint/eslint.seatbelt.tsv +++ b/config/eslint/eslint.seatbelt.tsv @@ -171,8 +171,8 @@ "../../src/components/EmptySelectionListContent.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/EnvironmentBadge.tsx" "no-restricted-syntax" 1 "../../src/components/ExplanationModal.tsx" "no-restricted-syntax" 1 -"../../src/components/FeatureTrainingModal.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../src/components/FeatureTrainingModal.tsx" "react-hooks/set-state-in-effect" 1 +"../../src/components/FeatureTrainingModal/FeatureTrainingModalIllustration.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/components/FeatureTrainingModal/FeatureTrainingModalIllustration.tsx" "react-hooks/set-state-in-effect" 1 "../../src/components/FeedbackSurvey.tsx" "react-hooks/set-state-in-effect" 1 "../../src/components/FilePicker/index.native.tsx" "react-hooks/refs" 1 "../../src/components/FilePicker/index.tsx" "react-hooks/refs" 1 @@ -670,6 +670,7 @@ "../../src/libs/Navigation/PlatformStackNavigation/ScreenLayout.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/ScreenFreezeWrapper/index.native.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/libs/Navigation/PlatformStackNavigation/navigationOptions/animation/withAnimation.ts" "@typescript-eslint/no-unsafe-type-assertion" 6 +"../../src/libs/Navigation/guards/AIFeaturesPromoGuard.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/Navigation/guards/MigratedUserWelcomeModalGuard.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/Navigation/guards/OnboardingGuard.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/libs/Navigation/helpers/createNormalizedConfigs.ts" "@typescript-eslint/no-deprecated/escape" 1 diff --git a/jest/setupAfterEnv.ts b/jest/setupAfterEnv.ts index cf6a137be8e3..4f1a66081887 100644 --- a/jest/setupAfterEnv.ts +++ b/jest/setupAfterEnv.ts @@ -6,6 +6,16 @@ import ONYXKEYS from '@src/ONYXKEYS'; jest.useRealTimers(); +// Globally short-circuit AIFeaturesPromoGuard in tests. The real guard proactively +// navigates any authenticated session to /ai-features-promo unless the dismissal NVP +// is set, which would otherwise intercept navigation in unrelated UI tests. Tests +// that need the real guard can override this mock locally. +jest.mock('@libs/Navigation/guards/AIFeaturesPromoGuard', () => ({ + __esModule: true, + default: {name: 'AIFeaturesPromoGuard', evaluate: () => ({type: 'ALLOW'})}, + onSessionOrLoadingAppChanged: jest.fn(), +})); + // Patch Keyboard.addListener to return a subscription object with .remove() so that // @react-navigation/bottom-tabs useIsKeyboardShown hook doesn't crash on cleanup. if (Keyboard && typeof Keyboard.addListener === 'function') { diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 6af591bed322..811031a296e8 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -7258,6 +7258,7 @@ const CONST = { SCREENS.SAML_SIGN_IN, SCREENS.VALIDATE_LOGIN, SCREENS.MIGRATED_USER_WELCOME_MODAL.ROOT, + SCREENS.AI_FEATURES_PROMO_MODAL.ROOT, SCREENS.MONEY_REQUEST.STEP_SCAN, SCREENS.DOMAIN.MEMBERS_MOVE_TO_GROUP, ...Object.values(SCREENS.MULTIFACTOR_AUTHENTICATION), @@ -7777,6 +7778,14 @@ const CONST = { MIGRATED_USER_WELCOME_MODAL: 'migratedUserWelcomeModal', + AI_FEATURES_PROMO_MODAL: 'aiFeaturesPromoModal', + + AI_FEATURES_PROMO_LEARN_MORE_URLS: { + SPEND_ANALYSIS: 'https://help.expensify.com/articles/new-expensify/concierge-ai/How-Concierge-Analyzes-Spend', + EXPENSE_ASSISTANT: 'https://help.expensify.com/articles/new-expensify/concierge-ai/Expense-Assistant', + BUILD_AGENTS: 'https://help.expensify.com/articles/new-expensify/ai-agents/Create-Agent-Rules', + }, + BASE_LIST_ITEM_TEST_ID: 'base-list-item-', SELECTION_BUTTON_TEST_ID: 'selection-button-', PRODUCT_TRAINING_TOOLTIP_NAMES: { diff --git a/src/NAVIGATORS.ts b/src/NAVIGATORS.ts index 7f24aedd382e..3a99812352d3 100644 --- a/src/NAVIGATORS.ts +++ b/src/NAVIGATORS.ts @@ -8,6 +8,7 @@ export default { ONBOARDING_MODAL_NAVIGATOR: 'OnboardingModalNavigator', FEATURE_TRAINING_MODAL_NAVIGATOR: 'FeatureTrainingModalNavigator', MIGRATED_USER_MODAL_NAVIGATOR: 'MigratedUserModalNavigator', + AI_FEATURES_PROMO_MODAL_NAVIGATOR: 'AIFeaturesPromoModalNavigator', TEST_DRIVE_MODAL_NAVIGATOR: 'TestDriveModalNavigator', TEST_DRIVE_DEMO_NAVIGATOR: 'TestDriveDemoNavigator', REPORTS_SPLIT_NAVIGATOR: 'ReportsSplitNavigator', diff --git a/src/ROUTES.ts b/src/ROUTES.ts index d9790039e6ab..8cc6bdcf979d 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -3375,6 +3375,7 @@ const ROUTES = { getRoute: (backTo?: string) => getUrlWithBackToParam('onboarding/migrated-user-welcome', backTo, false), }, + AI_FEATURES_PROMO_MODAL: 'ai-features-promo', TRANSACTION_RECEIPT: { route: 'r/:reportID/transaction/:transactionID/receipt/:action?/:iouType?', diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 5ff83221799a..e448e95c7b71 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -970,6 +970,10 @@ const SCREENS = { ROOT: 'MigratedUserWelcomeModal_Root', }, + AI_FEATURES_PROMO_MODAL: { + ROOT: 'AIFeaturesPromoModal_Root', + }, + TEST_DRIVE_MODAL: { ROOT: 'TestDrive_Modal_Root', }, diff --git a/src/components/AIFeaturesPromoModal/index.tsx b/src/components/AIFeaturesPromoModal/index.tsx new file mode 100644 index 000000000000..a161b7688029 --- /dev/null +++ b/src/components/AIFeaturesPromoModal/index.tsx @@ -0,0 +1,92 @@ +import React, {useRef} from 'react'; +import {View} from 'react-native'; +import Badge from '@components/Badge'; +import FeatureTrainingModal from '@components/FeatureTrainingModal'; +import type {FeatureTrainingModalPageProps} from '@components/FeatureTrainingModal'; +import LottieAnimations from '@components/LottieAnimations'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import usePermissions from '@hooks/usePermissions'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {dismissProductTraining} from '@libs/actions/Welcome'; +import Log from '@libs/Log'; +import variables from '@styles/variables'; +import CONST from '@src/CONST'; + +function AIFeaturesPromoModal() { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const {isBetaEnabled} = usePermissions(); + const canUseCustomAgent = isBetaEnabled(CONST.BETAS.CUSTOM_AGENT); + + const customAgentPromoTitle = ( + + {translate('aiFeaturesPromoModal.customAgents.title')} + + + ); + + const pages: FeatureTrainingModalPageProps[] = [ + { + animation: LottieAnimations.SpendAnalysis, + title: translate('aiFeaturesPromoModal.spendAnalysis.title'), + subtitle: translate('aiFeaturesPromoModal.subtitle'), + description: translate('aiFeaturesPromoModal.spendAnalysis.description'), + confirmText: translate('common.next'), + }, + { + animation: LottieAnimations.ExpenseAssistant, + title: translate('aiFeaturesPromoModal.expenseAssistant.title'), + subtitle: translate('aiFeaturesPromoModal.subtitle'), + description: translate('aiFeaturesPromoModal.expenseAssistant.description'), + confirmText: canUseCustomAgent ? translate('common.next') : translate('aiFeaturesPromoModal.confirmText'), + }, + ...(canUseCustomAgent + ? [ + { + animation: LottieAnimations.CustomAgents, + title: customAgentPromoTitle, + subtitle: translate('aiFeaturesPromoModal.subtitle'), + description: translate('aiFeaturesPromoModal.customAgents.description'), + confirmText: translate('aiFeaturesPromoModal.confirmText'), + }, + ] + : []), + ]; + + const wasDismissedViaConfirmRef = useRef(false); + + const onConfirm = () => { + Log.hmmm('[AIFeaturesPromoModal] onConfirm called, recording click dismissal'); + wasDismissedViaConfirmRef.current = true; + }; + + const onClose = () => { + const isCloseButtonDismissal = !wasDismissedViaConfirmRef.current; + Log.hmmm(`[AIFeaturesPromoModal] onClose called, dismissing product training via ${isCloseButtonDismissal ? 'x' : 'click'}`); + dismissProductTraining(CONST.AI_FEATURES_PROMO_MODAL, isCloseButtonDismissal); + }; + + return ( + + ); +} + +export default AIFeaturesPromoModal; diff --git a/src/components/FeatureTrainingModal/FeatureTrainingModalBody.tsx b/src/components/FeatureTrainingModal/FeatureTrainingModalBody.tsx new file mode 100644 index 000000000000..8bbcc63c56d1 --- /dev/null +++ b/src/components/FeatureTrainingModal/FeatureTrainingModalBody.tsx @@ -0,0 +1,110 @@ +import React from 'react'; +import {View} from 'react-native'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useStyleUtils from '@hooks/useStyleUtils'; +import FeatureTrainingModalContent from './FeatureTrainingModalContent'; +import FeatureTrainingModalIllustration from './FeatureTrainingModalIllustration'; +import type {BaseFeatureTrainingModalProps, FeatureTrainingModalPageProps} from './index'; + +type FeatureTrainingModalBodyProps = BaseFeatureTrainingModalProps & + FeatureTrainingModalPageProps & { + /** Padding for the modal */ + modalPadding: number; + + /** Whether the modal should be shown again */ + willShowAgain: boolean; + + /** A callback to call when the modal should be shown again */ + toggleWillShowAgain: () => void; + + /** A callback to call when we want to close the modal */ + closeModal: (didPressHelpButton?: boolean) => void; + + /** A callback to call when we want to close the modal and confirm */ + confirmModal: () => void; + + /** Whether to show the back button to navigate back to the previous page in carousel mode */ + shouldShowBackButton?: boolean; + + /** A callback to call when we want to navigate back to the previous page in carousel mode */ + onBack?: () => void; + }; + +function FeatureTrainingModalBody({ + illustrationInnerContainerStyle, + illustrationOuterContainerStyle, + illustrationAspectRatio: illustrationAspectRatioProp, + width, + title = '', + subtitle = '', + description = '', + secondaryDescription = '', + titleStyles, + shouldShowDismissModalOption = false, + confirmText = '', + helpText = '', + onHelp = () => {}, + children, + contentInnerContainerStyles, + contentOuterContainerStyles, + shouldRenderSVG = true, + shouldRenderHTMLDescription = false, + shouldShowConfirmationLoader = false, + canConfirmWhileOffline = true, + shouldCallOnHelpWhenModalHidden = false, + helpSentryLabel, + confirmSentryLabel, + modalPadding, + willShowAgain = true, + toggleWillShowAgain, + closeModal, + confirmModal, + shouldShowBackButton = false, + onBack, + ...props +}: FeatureTrainingModalBodyProps) { + const StyleUtils = useStyleUtils(); + const {onboardingIsMediumOrLargerScreenWidth} = useResponsiveLayout(); + + return ( + + + + {children} + + + ); +} + +export default FeatureTrainingModalBody; diff --git a/src/components/FeatureTrainingModal/FeatureTrainingModalCarouselBody.tsx b/src/components/FeatureTrainingModal/FeatureTrainingModalCarouselBody.tsx new file mode 100644 index 000000000000..e66c3e4d01c0 --- /dev/null +++ b/src/components/FeatureTrainingModal/FeatureTrainingModalCarouselBody.tsx @@ -0,0 +1,312 @@ +import React, {useEffect, useRef, useState} from 'react'; +import {FlatList, Platform, View} from 'react-native'; +import type {LayoutChangeEvent, FlatList as RNFlatList, ViewabilityConfig, ViewStyle, ViewToken} from 'react-native'; +import Icon from '@components/Icon'; +import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; +import Tooltip from '@components/Tooltip'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import variables from '@styles/variables'; +import CONST from '@src/CONST'; +import FeatureTrainingModalContent from './FeatureTrainingModalContent'; +import FeatureTrainingModalIllustration from './FeatureTrainingModalIllustration'; +import FeatureTrainingModalTextContent from './FeatureTrainingModalTextContent'; +import type {BaseFeatureTrainingModalProps, FeatureTrainingModalCarouselProps, FeatureTrainingModalPageProps} from './index'; + +// A page is considered "viewable" — and `currentPage` updates — only once it occupies at least +// 95% of the viewport. The viewability event fires for both user swipes and programmatic +// scrollToIndex once the scroll has practically settled on a new page. +const CAROUSEL_VIEWABILITY_CONFIG: ViewabilityConfig = {itemVisiblePercentThreshold: 95}; +const CAROUSEL_DOT_SIZE = 6; +const PAGINATION_DOTS_BOTTOM_OFFSET = 5; + +// react-native-web translates `pagingEnabled` to CSS scroll-snap, but `scroll-snap-align` only +// lands on the ScrollView's direct children — our per-page View (inside `renderItem`) isn't a +// direct child, so the FlatList ends up with a single snap point and fast flings skip pages. +// Applying `scrollSnapAlign: 'start'` on each page wrapper turns every page into a snap point. +// Native ignores this CSS-only key, so we still gate it on Platform.OS === 'web' for clarity. +const WEB_CAROUSEL_PAGE_SNAP_STYLE: ViewStyle = Platform.OS === 'web' ? ({scrollSnapAlign: 'start'} as ViewStyle) : {}; + +type FeatureTrainingModalCarouselBodyProps = Pick< + BaseFeatureTrainingModalProps, + | 'illustrationAspectRatio' + | 'illustrationInnerContainerStyle' + | 'illustrationOuterContainerStyle' + | 'titleStyles' + | 'shouldRenderSVG' + | 'shouldRenderHTMLDescription' + | 'shouldShowDismissModalOption' + | 'helpText' + | 'onHelp' + | 'shouldCallOnHelpWhenModalHidden' + | 'helpSentryLabel' + | 'confirmSentryLabel' + | 'shouldShowConfirmationLoader' + | 'canConfirmWhileOffline' + | 'contentInnerContainerStyles' + | 'contentOuterContainerStyles' + | 'width' +> & + FeatureTrainingModalCarouselProps & { + /** Padding for the modal */ + modalPadding: number; + + /** Whether the modal should be shown again */ + willShowAgain: boolean; + + /** Callback when the "Don't show me this again" option is toggled */ + toggleWillShowAgain: () => void; + + /** Callback to close the modal */ + closeModal: (didPressHelpButton?: boolean) => void; + + /** Callback fired when the user presses the confirm button on the LAST page */ + onConfirm: () => void; + + /** Called when the user swipes to a different page */ + onPageChange?: (index: number) => void; + }; + +function FeatureTrainingModalCarouselBody({ + pages, + modalPadding, + width = variables.featureTrainingModalWidth, + titleStyles, + illustrationAspectRatio, + illustrationInnerContainerStyle, + illustrationOuterContainerStyle, + shouldRenderSVG = true, + shouldRenderHTMLDescription = false, + shouldShowDismissModalOption = false, + helpText = '', + onHelp = () => {}, + shouldCallOnHelpWhenModalHidden = false, + helpSentryLabel, + confirmSentryLabel, + shouldShowConfirmationLoader = false, + canConfirmWhileOffline = true, + contentInnerContainerStyles, + contentOuterContainerStyles, + willShowAgain, + toggleWillShowAgain, + closeModal, + onConfirm, + onPageChange, +}: FeatureTrainingModalCarouselBodyProps) { + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + const theme = useTheme(); + const {translate} = useLocalize(); + const expensifyIcons = useMemoizedLazyExpensifyIcons(['Close']); + const {onboardingIsMediumOrLargerScreenWidth} = useResponsiveLayout(); + + const [currentPage, setCurrentPage] = useState(0); + const [carouselViewportWidth, setCarouselViewportWidth] = useState(0); + const horizontalListRef = useRef>(null); + const lastReportedPage = useRef(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))); + }; + + // FlatList's `onViewableItemsChanged` must keep a stable identity (it errors otherwise). + // The handler reads the latest `onPageChange` via a ref so the callback identity never changes. + const onPageChangeRef = useRef(onPageChange); + useEffect(() => { + onPageChangeRef.current = onPageChange; + }, [onPageChange]); + + const onViewableItemsChanged = ({viewableItems}: {viewableItems: ViewToken[]}) => { + const entry = viewableItems.at(0); + if (entry?.index == null || entry.index === lastReportedPage.current) { + return; + } + lastReportedPage.current = entry.index; + setCurrentPage(entry.index); + onPageChangeRef.current?.(entry.index); + }; + + const advanceCarousel = () => { + horizontalListRef.current?.scrollToIndex({index: Math.min(currentPage + 1, pages.length - 1), animated: true}); + }; + + const goBack = () => { + if (currentPage <= 0) { + return; + } + horizontalListRef.current?.scrollToIndex({index: Math.max(currentPage - 1, 0), animated: true}); + }; + + const handleConfirmPress = () => { + if (currentPage < pages.length - 1) { + advanceCarousel(); + return; + } + onConfirm(); + }; + + const carouselPaginationDots = pages.map((_page, index) => ( + + )); + + const currentPageData = pages.at(currentPage); + + return ( + 0`), so without `w100` it + // collapses to 0×0 inside the `fit-content` modal sheet — `onLayout` then never + // fires with a positive width and the carousel never renders, leaving the modal + // backdrop visible with no content. `w100` makes the View stretch to the sheet's + // known full width and lets `onLayout` resolve immediately. On medium+ screens the + // explicit `getWidthStyle(width)` continues to apply. + style={[onboardingIsMediumOrLargerScreenWidth ? StyleUtils.getWidthStyle(width) : styles.w100]} + onLayout={(e: LayoutChangeEvent) => { + const newWidth = e.nativeEvent.layout.width; + if (newWidth === carouselViewportWidth || newWidth <= 0) { + return; + } + setCarouselViewportWidth(newWidth); + }} + > + {carouselViewportWidth > 0 && contentMinHeight === undefined && ( + // Probe layer is used to measure the tallest page to lock the modal height + // when moving between pages with different content lengths. + + {pages.map((page, index) => ( + + + + ))} + + )} + {carouselViewportWidth > 0 && ( + <> + + `FeatureTrainingModalIllustration-${index}`} + horizontal + pagingEnabled + // Defense-in-depth on native: `pagingEnabled` is honored by the + // platform, but these props arrest momentum precisely at each page + // so fast flings never coast over a middle page. On web they're + // no-ops; `WEB_CAROUSEL_PAGE_SNAP_STYLE` below does the equivalent + // work via CSS scroll-snap. + disableIntervalMomentum + snapToInterval={carouselViewportWidth} + decelerationRate="fast" + showsHorizontalScrollIndicator={false} + keyboardShouldPersistTaps="handled" + viewabilityConfig={CAROUSEL_VIEWABILITY_CONFIG} + onViewableItemsChanged={onViewableItemsChanged} + getItemLayout={(_data, index) => ({length: carouselViewportWidth, offset: index * carouselViewportWidth, index})} + renderItem={({item: page, index}) => ( + + + + )} + /> + + {carouselPaginationDots} + + + 0} + onBack={goBack} + shouldShowConfirmationLoader={shouldShowConfirmationLoader} + canConfirmWhileOffline={canConfirmWhileOffline} + titleStyles={titleStyles} + contentInnerContainerStyles={[contentInnerContainerStyles, contentMinHeight !== undefined && {minHeight: contentMinHeight}]} + contentOuterContainerStyles={contentOuterContainerStyles} + shouldRenderHTMLDescription={shouldRenderHTMLDescription} + /> + + + closeModal()} + role={CONST.ROLE.BUTTON} + accessibilityLabel={translate('common.close')} + sentryLabel="FeatureTrainingModal-Carousel-Close" + style={[styles.p2, styles.opacitySemiTransparent]} + > + + + + + + )} + + ); +} + +export default FeatureTrainingModalCarouselBody; diff --git a/src/components/FeatureTrainingModal/FeatureTrainingModalContent.tsx b/src/components/FeatureTrainingModal/FeatureTrainingModalContent.tsx new file mode 100644 index 000000000000..57d4f258a4b4 --- /dev/null +++ b/src/components/FeatureTrainingModal/FeatureTrainingModalContent.tsx @@ -0,0 +1,138 @@ +import React from 'react'; +import {View} from 'react-native'; +import Button from '@components/Button'; +import CheckboxWithLabel from '@components/CheckboxWithLabel'; +import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; +import OfflineIndicator from '@components/OfflineIndicator'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import FeatureTrainingModalTextContent from './FeatureTrainingModalTextContent'; +import type {FeatureTrainingModalContentProps as BaseFeatureTrainingModalContentProps, BaseFeatureTrainingModalProps} from './index'; + +type FeatureTrainingModalContentProps = Pick< + BaseFeatureTrainingModalProps, + | 'helpText' + | 'onHelp' + | 'shouldCallOnHelpWhenModalHidden' + | 'helpSentryLabel' + | 'confirmSentryLabel' + | 'shouldShowDismissModalOption' + | 'shouldShowConfirmationLoader' + | 'canConfirmWhileOffline' + | 'titleStyles' + | 'contentInnerContainerStyles' + | 'contentOuterContainerStyles' + | 'shouldRenderHTMLDescription' + | 'children' +> & + BaseFeatureTrainingModalContentProps & { + /** Whether the modal should be shown again (drives the dismiss checkbox state) */ + willShowAgain: boolean; + + /** Callback when the "Don't show me this again" option is toggled */ + toggleWillShowAgain: () => void; + + /** Callback to close the modal */ + closeModal: (didPressHelpButton?: boolean) => void; + + /** Callback when the user presses the confirm button */ + confirmModal: () => void; + + /** Whether to render a Back button (carousel mode, non-first pages) */ + shouldShowBackButton?: boolean; + + /** Callback when the Back button is pressed */ + onBack?: () => void; + }; + +function FeatureTrainingModalContent({ + title = '', + subtitle = '', + description = '', + secondaryDescription = '', + confirmText, + helpText = '', + onHelp = () => {}, + shouldCallOnHelpWhenModalHidden = false, + helpSentryLabel, + confirmSentryLabel, + shouldShowDismissModalOption = false, + willShowAgain, + toggleWillShowAgain, + closeModal, + confirmModal, + shouldShowBackButton = false, + onBack, + shouldShowConfirmationLoader = false, + canConfirmWhileOffline = true, + titleStyles, + contentInnerContainerStyles, + contentOuterContainerStyles, + shouldRenderHTMLDescription = false, + children, +}: FeatureTrainingModalContentProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + + return ( + + + {children} + + {shouldShowDismissModalOption && ( + + )} + {!!helpText && ( +