Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified assets/images/home-testdrive-image.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
23 changes: 23 additions & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
30 changes: 30 additions & 0 deletions src/components/DateIcon.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<View style={[styles.br2, styles.alignItemsCenter, styles.justifyContentCenter, styles.dateIconSize, StyleUtils.getBackgroundColorStyle(theme.border)]}>
<Text style={[styles.textMicro, styles.textSupporting]}>{monthAbbr}</Text>
<Text style={[styles.textStrong, styles.fontSizeNormal, styles.textSupporting]}>{dayNumber}</Text>
</View>
);
}

export default DateIcon;
5 changes: 5 additions & 0 deletions src/components/MenuItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -503,6 +506,7 @@ function MenuItem({
shouldShowDescriptionOnTop = false,
shouldShowRightComponent = false,
rightComponent,
leftComponent,
rightIconReportID,
avatarSize = CONST.AVATAR_SIZE.DEFAULT,
isSmallAvatarSubscriptMenu = false,
Expand Down Expand Up @@ -778,6 +782,7 @@ function MenuItem({
</View>
)}
<View style={[styles.flexRow, styles.pointerEventsAuto, disabled && !shouldUseDefaultCursorWhenDisabled && styles.cursorDisabled]}>
{!!leftComponent && <View style={[styles.mr3]}>{leftComponent}</View>}
{/* eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing */}
{isIDPassed && (
<ReportActionAvatars
Expand Down
8 changes: 6 additions & 2 deletions src/components/WidgetContainer.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type {ReactNode} from 'react';
import React from 'react';
import {View} from 'react-native';
import type {StyleProp, ViewStyle} from 'react-native';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
Expand All @@ -27,15 +28,18 @@ type WidgetContainerProps = {

/** The content to display inside the widget container */
children: ReactNode;

/** Additional styles to pass to the container */
containerStyles?: StyleProp<ViewStyle>;
};

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 (
<View style={styles.widgetContainer}>
<View style={[styles.widgetContainer, containerStyles]}>
<View style={[styles.flexRow, styles.alignItemsStart, styles.mb5, shouldUseNarrowLayout ? styles.mh5 : styles.mh8, shouldUseNarrowLayout ? styles.mt5 : styles.mt8]}>
{!!icon && (
<View style={[styles.flexGrow0, styles.flexShrink0]}>
Expand Down
38 changes: 38 additions & 0 deletions src/pages/home/AnnouncementSection.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<WidgetContainer
title={translate('homePage.announcements')}
containerStyles={shouldUseNarrowLayout ? styles.pb2 : styles.pb5}
>
{announcements.map((announcement) => (
<MenuItemWithTopDescription
key={announcement.title}
description={announcement.subtitle}
title={announcement.title}
titleStyle={styles.textBold}
onPress={() => Linking.openURL(announcement.url)}
shouldShowRightIcon
leftComponent={<DateIcon date={announcement.publishedDate} />}
wrapperStyle={[styles.alignItemsCenter, shouldUseNarrowLayout ? styles.pl5 : styles.pl8]}
/>
))}
</WidgetContainer>
);
}

export default AnnouncementSection;
33 changes: 33 additions & 0 deletions src/pages/home/DiscoverSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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 (
<WidgetContainer title={translate('homePage.discoverSection.title')}>
<PressableWithoutFeedback
Expand All @@ -44,6 +76,7 @@ function DiscoverSection() {
<MenuItemWithTopDescription
shouldShowRightIcon
title={isCurrentUserPolicyAdmin ? translate('homePage.discoverSection.menuItemTitleAdmin') : translate('homePage.discoverSection.menuItemTitleNonAdmin')}
titleStyle={styles.textBold}
description={translate('homePage.discoverSection.menuItemDescription')}
onPress={handlePress}
style={shouldUseNarrowLayout ? styles.mb2 : styles.mb5}
Expand Down
49 changes: 27 additions & 22 deletions src/pages/home/ForYouSection/EmptyState.tsx
Original file line number Diff line number Diff line change
@@ -1,38 +1,45 @@
import React, {useMemo} from 'react';
import React from 'react';
import {View} from 'react-native';
import {Fireworks, ThumbsUpStars} from '@components/Icon/Illustrations';
import ImageSVG from '@components/ImageSVG';
import Text from '@components/Text';
import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
import type {TranslationPaths} from '@src/languages/types';
import type IconAsset from '@src/types/utils/IconAsset';

const ILLUSTRATION_SIZE = 100;

const EMPTY_STATE_MESSAGES = [
{
titleKey: 'homePage.forYouSection.emptyStateMessages.nicelyDone',
subtitleKey: 'homePage.forYouSection.emptyStateMessages.keepAnEyeOut',
illustration: ThumbsUpStars,
},
{
titleKey: 'homePage.forYouSection.emptyStateMessages.allCaughtUp',
subtitleKey: 'homePage.forYouSection.emptyStateMessages.upcomingTodos',
illustration: Fireworks,
},
] as const;
type EmptyStateMessage = {
titleKey: TranslationPaths;
subtitleKey: TranslationPaths;
illustration: IconAsset;
};

function EmptyState() {
const styles = useThemeStyles();
const {translate} = useLocalize();
const illustrations = useMemoizedLazyIllustrations(['ThumbsUpStars', 'Fireworks']);

const defaultEmptyStateMessage: EmptyStateMessage = {
titleKey: 'homePage.forYouSection.emptyStateMessages.nicelyDone',
subtitleKey: 'homePage.forYouSection.emptyStateMessages.keepAnEyeOut',
illustration: illustrations.ThumbsUpStars,
};

const emptyStateMessages: EmptyStateMessage[] = [
defaultEmptyStateMessage,
{
titleKey: 'homePage.forYouSection.emptyStateMessages.allCaughtUp',
subtitleKey: 'homePage.forYouSection.emptyStateMessages.upcomingTodos',
illustration: illustrations.Fireworks,
},
];

// Select a random empty state message on mount (will change on refresh/remount)
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- Array.at() with calculated index always returns a value since length is checked
// eslint-disable-next-line react-hooks/purity -- Random selection is intentional and should only happen once on mount
const emptyStateMessage = useMemo(
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion, react-hooks/purity
() => 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 (
<View style={styles.forYouEmptyStateContainer}>
Expand All @@ -47,6 +54,4 @@ function EmptyState() {
);
}

EmptyState.displayName = 'EmptyState';

export default EmptyState;
11 changes: 6 additions & 5 deletions src/pages/home/ForYouSection/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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];
Expand All @@ -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}),
},
Expand Down
11 changes: 5 additions & 6 deletions src/pages/home/HomePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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
Expand Down Expand Up @@ -58,9 +55,11 @@ function HomePage() {
<View style={styles.homePageMainLayout(shouldUseNarrowLayout)}>
<View style={styles.homePageLeftColumn(shouldUseNarrowLayout)}>
<ForYouSection />
{!isSelfTourViewed && <DiscoverSection />}
<DiscoverSection />
</View>
<View style={styles.homePageRightColumn(shouldUseNarrowLayout)}>
<AnnouncementSection />
</View>
<View style={styles.homePageRightColumn(shouldUseNarrowLayout)} />
</View>
</ScrollView>
{shouldDisplayLHB && <NavigationTabBar selectedTab={NAVIGATION_TABS.HOME} />}
Expand Down
4 changes: 4 additions & 0 deletions src/styles/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down
Loading
Loading