diff --git a/assets/images/home-testdrive-image.png b/assets/images/home-testdrive-image.png index 21f0408035dd..e763fca5dd9f 100644 Binary files a/assets/images/home-testdrive-image.png and b/assets/images/home-testdrive-image.png differ diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 67571b1f02a8..b60eb9c52ca3 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -8187,6 +8187,29 @@ const CONST = { DOMAIN_SECURITY_GROUP_PREFIX: 'domain_securityGroup_', }, + HOME: { + ANNOUNCEMENTS: [ + { + title: 'Start the year with smarter spending, admin controls, and more.', + subtitle: 'Product update', + url: 'https://use.expensify.com/blog/expensify-january-2026-product-update', + publishedDate: '2026-01-28', + }, + { + title: 'Our favorite features + final upgrades of the year', + subtitle: 'Product update', + url: 'https://use.expensify.com/blog/expensify-2025-year-end-product-update', + publishedDate: '2025-12-22', + }, + { + title: 'Uber for business + Expensify automates ride and meal receipts', + subtitle: 'Product update', + url: 'https://use.expensify.com/blog/uber-for-business-and-expensify-integration-update', + publishedDate: '2025-12-01', + }, + ], + }, + SECTION_LIST_ITEM_TYPE: { HEADER: 'header', ROW: 'row', diff --git a/src/components/DateIcon.tsx b/src/components/DateIcon.tsx new file mode 100644 index 000000000000..cc07ec20025b --- /dev/null +++ b/src/components/DateIcon.tsx @@ -0,0 +1,30 @@ +import {format, parseISO} from 'date-fns'; +import React from 'react'; +import {View} from 'react-native'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import Text from './Text'; + +type DateIconProps = { + /** Date string (e.g. ISO format YYYY-MM-DD) */ + date: string; +}; + +function DateIcon({date}: DateIconProps) { + const styles = useThemeStyles(); + const theme = useTheme(); + const parsedDate = parseISO(date); + const monthAbbr = format(parsedDate, 'MMM'); + const dayNumber = format(parsedDate, 'd'); + const StyleUtils = useStyleUtils(); + + return ( + + {monthAbbr} + {dayNumber} + + ); +} + +export default DateIcon; diff --git a/src/components/MenuItem.tsx b/src/components/MenuItem.tsx index ba06841c08c1..bbd2c66df9b5 100644 --- a/src/components/MenuItem.tsx +++ b/src/components/MenuItem.tsx @@ -164,6 +164,9 @@ type MenuItemBaseProps = ForwardedFSClassProps & /** Component to be displayed on the right */ rightComponent?: ReactNode; + /** Component to be displayed on the left */ + leftComponent?: ReactNode; + /** A description text to show under the title */ description?: string; @@ -503,6 +506,7 @@ function MenuItem({ shouldShowDescriptionOnTop = false, shouldShowRightComponent = false, rightComponent, + leftComponent, rightIconReportID, avatarSize = CONST.AVATAR_SIZE.DEFAULT, isSmallAvatarSubscriptMenu = false, @@ -778,6 +782,7 @@ function MenuItem({ )} + {!!leftComponent && {leftComponent}} {/* eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing */} {isIDPassed && ( ; }; -function WidgetContainer({children, icon, title, titleColor, iconWidth = variables.iconSizeNormal, iconHeight = variables.iconSizeNormal}: WidgetContainerProps) { +function WidgetContainer({children, icon, title, titleColor, iconWidth = variables.iconSizeNormal, iconHeight = variables.iconSizeNormal, containerStyles}: WidgetContainerProps) { const styles = useThemeStyles(); const theme = useTheme(); const {shouldUseNarrowLayout} = useResponsiveLayout(); return ( - + {!!icon && ( diff --git a/src/pages/home/AnnouncementSection.tsx b/src/pages/home/AnnouncementSection.tsx new file mode 100644 index 000000000000..f1f932643ccc --- /dev/null +++ b/src/pages/home/AnnouncementSection.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import {Linking} from 'react-native'; +import DateIcon from '@components/DateIcon'; +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import WidgetContainer from '@components/WidgetContainer'; +import useLocalize from '@hooks/useLocalize'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useThemeStyles from '@hooks/useThemeStyles'; +import CONST from '@src/CONST'; + +const announcements = CONST.HOME.ANNOUNCEMENTS; + +function AnnouncementSection() { + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const {translate} = useLocalize(); + const styles = useThemeStyles(); + return ( + + {announcements.map((announcement) => ( + Linking.openURL(announcement.url)} + shouldShowRightIcon + leftComponent={} + wrapperStyle={[styles.alignItemsCenter, shouldUseNarrowLayout ? styles.pl5 : styles.pl8]} + /> + ))} + + ); +} + +export default AnnouncementSection; diff --git a/src/pages/home/DiscoverSection.tsx b/src/pages/home/DiscoverSection.tsx index 7ffd2c09d714..995a203d7590 100644 --- a/src/pages/home/DiscoverSection.tsx +++ b/src/pages/home/DiscoverSection.tsx @@ -4,14 +4,19 @@ import HomeTestDriveImage from '@assets/images/home-testdrive-image.png'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import {PressableWithoutFeedback} from '@components/Pressable'; import WidgetContainer from '@components/WidgetContainer'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useIsPaidPolicyAdmin from '@hooks/useIsPaidPolicyAdmin'; import useLocalize from '@hooks/useLocalize'; +import useOnboardingTaskInformation from '@hooks/useOnboardingTaskInformation'; import useOnyx from '@hooks/useOnyx'; +import useParentReportAction from '@hooks/useParentReportAction'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; +import {completeTestDriveTask} from '@libs/actions/Task'; import {getTestDriveURL} from '@libs/TourUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import {hasSeenTourSelector} from '@src/selectors/Onboarding'; const MAX_NUMBER_OF_LINES_TITLE = 4; @@ -21,11 +26,38 @@ function DiscoverSection() { const isCurrentUserPolicyAdmin = useIsPaidPolicyAdmin(); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {canBeMissing: true}); const styles = useThemeStyles(); + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const { + taskReport: viewTourTaskReport, + taskParentReport: viewTourTaskParentReport, + isOnboardingTaskParentReportArchived: isViewTourTaskParentReportArchived, + hasOutstandingChildTask, + } = useOnboardingTaskInformation(CONST.ONBOARDING_TASK_TYPE.VIEW_TOUR); + const parentReportAction = useParentReportAction(viewTourTaskReport); + const [hasSeenTour = false] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector, canBeMissing: true}); const handlePress = () => { Linking.openURL(getTestDriveURL(shouldUseNarrowLayout, introSelected, isCurrentUserPolicyAdmin)); + + if (hasSeenTour || !viewTourTaskReport || viewTourTaskReport.stateNum === CONST.REPORT.STATE_NUM.APPROVED) { + return; + } + + completeTestDriveTask( + viewTourTaskReport, + viewTourTaskParentReport, + isViewTourTaskParentReportArchived, + currentUserPersonalDetails.accountID, + hasOutstandingChildTask, + parentReportAction, + false, + ); }; + if (hasSeenTour) { + return null; + } + return ( EMPTY_STATE_MESSAGES.at(Math.floor(Math.random() * EMPTY_STATE_MESSAGES.length))!, - [], - ); + const randomIndex = Math.floor(Math.random() * emptyStateMessages.length); + const emptyStateMessage = emptyStateMessages.at(randomIndex) ?? defaultEmptyStateMessage; return ( @@ -47,6 +54,4 @@ function EmptyState() { ); } -EmptyState.displayName = 'EmptyState'; - export default EmptyState; diff --git a/src/pages/home/ForYouSection/index.tsx b/src/pages/home/ForYouSection/index.tsx index bbf1825e65f7..291102e50445 100644 --- a/src/pages/home/ForYouSection/index.tsx +++ b/src/pages/home/ForYouSection/index.tsx @@ -2,8 +2,8 @@ import React from 'react'; import {View} from 'react-native'; import ActivityIndicator from '@components/ActivityIndicator'; import BaseWidgetItem from '@components/BaseWidgetItem'; -import {Cash, Export, Send, ThumbsUp} from '@components/Icon/Expensicons'; import WidgetContainer from '@components/WidgetContainer'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; @@ -26,6 +26,7 @@ function ForYouSection() { const [accountID] = useOnyx(ONYXKEYS.SESSION, {canBeMissing: false, selector: accountIDSelector}); const [isLoadingApp = true] = useOnyx(ONYXKEYS.IS_LOADING_APP, {canBeMissing: true}); const {reportCounts} = useTodos(); + const icons = useMemoizedLazyExpensifyIcons(['Cash', 'Send', 'ThumbsUp', 'Export']); const submitCount = reportCounts[CONST.SEARCH.SEARCH_KEYS.SUBMIT]; const approveCount = reportCounts[CONST.SEARCH.SEARCH_KEYS.APPROVE]; @@ -50,28 +51,28 @@ function ForYouSection() { { key: 'submit', count: submitCount, - icon: Send, + icon: icons.Send, translationKey: 'homePage.forYouSection.submit' as const, handler: createNavigationHandler(CONST.SEARCH.ACTION_FILTERS.SUBMIT, {from: [`${accountID}`]}), }, { key: 'approve', count: approveCount, - icon: ThumbsUp, + icon: icons.ThumbsUp, translationKey: 'homePage.forYouSection.approve' as const, handler: createNavigationHandler(CONST.SEARCH.ACTION_FILTERS.APPROVE, {to: [`${accountID}`]}), }, { key: 'pay', count: payCount, - icon: Cash, + icon: icons.Cash, translationKey: 'homePage.forYouSection.pay' as const, handler: createNavigationHandler(CONST.SEARCH.ACTION_FILTERS.PAY, {reimbursable: CONST.SEARCH.BOOLEAN.YES, payer: accountID?.toString()}), }, { key: 'export', count: exportCount, - icon: Export, + icon: icons.Export, translationKey: 'homePage.forYouSection.export' as const, handler: createNavigationHandler(CONST.SEARCH.ACTION_FILTERS.EXPORT, {exporter: [`${accountID}`], exportedOn: CONST.SEARCH.DATE_PRESETS.NEVER}), }, diff --git a/src/pages/home/HomePage.tsx b/src/pages/home/HomePage.tsx index 244fdada1caa..971bd6f0077b 100644 --- a/src/pages/home/HomePage.tsx +++ b/src/pages/home/HomePage.tsx @@ -6,13 +6,11 @@ import TopBar from '@components/Navigation/TopBar'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; import useLocalize from '@hooks/useLocalize'; -import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import {confirmReadyToOpenApp} from '@libs/actions/App'; import usePreloadFullScreenNavigators from '@libs/Navigation/AppNavigator/usePreloadFullScreenNavigators'; -import ONYXKEYS from '@src/ONYXKEYS'; -import {hasSeenTourSelector} from '@src/selectors/Onboarding'; +import AnnouncementSection from './AnnouncementSection'; import DiscoverSection from './DiscoverSection'; import ForYouSection from './ForYouSection'; @@ -21,7 +19,6 @@ function HomePage() { const shouldDisplayLHB = !shouldUseNarrowLayout; const styles = useThemeStyles(); const {translate} = useLocalize(); - const [isSelfTourViewed = false] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector, canBeMissing: true}); // confirmReadyToOpenApp must be called after HomePage mounts // to make sure everything loads properly @@ -58,9 +55,11 @@ function HomePage() { - {!isSelfTourViewed && } + + + + - {shouldDisplayLHB && } diff --git a/src/styles/index.ts b/src/styles/index.ts index f848059302af..5af960a7f2f7 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -5778,6 +5778,10 @@ const staticStyles = (theme: ThemeColors) => height: undefined, aspectRatio: 2.2, }, + dateIconSize: { + width: variables.iconSizeExtraLarge, + height: variables.iconSizeExtraLarge, + }, }) satisfies StaticStyles; const dynamicStyles = (theme: ThemeColors) => diff --git a/src/types/onyx/Announcement.ts b/src/types/onyx/Announcement.ts new file mode 100644 index 000000000000..7280887232df --- /dev/null +++ b/src/types/onyx/Announcement.ts @@ -0,0 +1,16 @@ +/** Model of announcement */ +type Announcement = { + /** The title of the announcement */ + title: string; + + /** The subtitle of the announcement */ + subtitle: string; + + /** The URL of the announcement */ + url: string; + + /** The published date of the announcement */ + publishedDate: string; +}; + +export default Announcement;