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
1 change: 1 addition & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4080,6 +4080,7 @@ const CONST = {
CONTAINER_VERTICAL_MARGIN: variables.componentSizeNormal,
},
MICROSECONDS_PER_MS: 1000,
MILLISECONDS_PER_SECOND: 1000,
RED_BRICK_ROAD_PENDING_ACTION: {
ADD: 'add',
DELETE: 'delete',
Expand Down
10 changes: 7 additions & 3 deletions src/components/WidgetContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,27 +26,31 @@ type WidgetContainerProps = {
/** The height of the icon. */
iconHeight?: number;

/** The fill color of the icon */
iconFill?: string;

/** 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, containerStyles}: WidgetContainerProps) {
function WidgetContainer({children, icon, title, titleColor, iconWidth = variables.iconSizeNormal, iconHeight = variables.iconSizeNormal, iconFill, containerStyles}: WidgetContainerProps) {
const styles = useThemeStyles();
const theme = useTheme();
const {shouldUseNarrowLayout} = useResponsiveLayout();

return (
<View style={[styles.widgetContainer, containerStyles]}>
<View style={[styles.flexRow, styles.alignItemsStart, styles.mb5, shouldUseNarrowLayout ? styles.mh5 : styles.mh8, shouldUseNarrowLayout ? styles.mt5 : styles.mt8]}>
<View style={styles.getWidgetContainerHeaderStyle(shouldUseNarrowLayout)}>
{!!icon && (
<View style={[styles.flexGrow0, styles.flexShrink0]}>
<View style={styles.widgetContainerIconWrapper}>
<Icon
src={icon}
width={iconWidth}
height={iconHeight}
fill={iconFill}
/>
</View>
)}
Expand Down
7 changes: 7 additions & 0 deletions src/languages/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -566,6 +566,7 @@ const translations: TranslationDeepObject<typeof en> = {
address: 'Adresse',
hourAbbreviation: 'h',
minuteAbbreviation: 'm',
secondAbbreviation: 's',
skip: 'Überspringen',
chatWithAccountManager: (accountManagerDisplayName: string) => `Brauchen Sie etwas Bestimmtes? Chatten Sie mit Ihrem Account Manager, ${accountManagerDisplayName}.`,
chatNow: 'Jetzt chatten',
Expand Down Expand Up @@ -8269,6 +8270,12 @@ Hier ist ein *Testbeleg*, um dir zu zeigen, wie es funktioniert:`,
upcomingTodos: 'Anstehende To-dos werden hier angezeigt.',
},
},
timeSensitiveSection: {
title: 'Zeitkritisch',
cta: 'Antrag',
offer50off: {title: 'Erhalte 50 % Rabatt auf dein erstes Jahr!', subtitle: ({formattedTime}: {formattedTime: string}) => `${formattedTime} verbleibend`},
offer25off: {title: 'Erhalten Sie 25 % Rabatt auf Ihr erstes Jahr!', subtitle: ({days}: {days: number}) => `${days} ${days === 1 ? 'Tag' : 'Tage'} verbleiben`},
},
},
};
// IMPORTANT: This line is manually replaced in generate translation files by scripts/generateTranslations.ts,
Expand Down
13 changes: 13 additions & 0 deletions src/languages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -561,6 +561,7 @@ const translations = {
address: 'Address',
hourAbbreviation: 'h',
minuteAbbreviation: 'm',
secondAbbreviation: 's',
skip: 'Skip',
chatWithAccountManager: (accountManagerDisplayName: string) => `Need something specific? Chat with your account manager, ${accountManagerDisplayName}.`,
chatNow: 'Chat now',
Expand Down Expand Up @@ -993,6 +994,18 @@ const translations = {
},
homePage: {
forYou: 'For you',
timeSensitiveSection: {
title: 'Time sensitive',
cta: 'Claim',
offer50off: {
title: 'Get 50% off your first year!',
subtitle: ({formattedTime}: {formattedTime: string}) => `${formattedTime} remaining`,
},
offer25off: {
title: 'Get 25% off your first year!',
subtitle: ({days}: {days: number}) => `${days} ${days === 1 ? 'day' : 'days'} remaining`,
},
},
announcements: 'Announcements',
discoverSection: {
title: 'Discover',
Expand Down
13 changes: 13 additions & 0 deletions src/languages/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,7 @@ const translations: TranslationDeepObject<typeof en> = {
address: 'Dirección',
hourAbbreviation: 'h',
minuteAbbreviation: 'm',
secondAbbreviation: 's',
chatWithAccountManager: (accountManagerDisplayName) => `¿Necesitas algo específico? Habla con tu gerente de cuenta, ${accountManagerDisplayName}.`,
chatNow: 'Chatear ahora',
workEmail: 'correo electrónico de trabajo',
Expand Down Expand Up @@ -738,6 +739,18 @@ const translations: TranslationDeepObject<typeof en> = {
},
homePage: {
forYou: 'Para ti',
timeSensitiveSection: {
title: 'Requiere atención inmediata',
cta: 'Reclamar',
offer50off: {
title: '¡Obtén 50% de descuento en tu primer año!',
subtitle: ({formattedTime}: {formattedTime: string}) => `${formattedTime} restantes`,
},
offer25off: {
title: '¡Obtén 25% de descuento en tu primer año!',
subtitle: ({days}: {days: number}) => `${days} ${days === 1 ? 'día' : 'días'} restantes`,
},
},
announcements: 'Anuncios',
discoverSection: {
title: 'Descubrir',
Expand Down
7 changes: 7 additions & 0 deletions src/languages/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,7 @@ const translations: TranslationDeepObject<typeof en> = {
address: 'Adresse',
hourAbbreviation: 'h',
minuteAbbreviation: 'm',
secondAbbreviation: 's',
skip: 'Ignorer',
chatWithAccountManager: (accountManagerDisplayName: string) =>
`Vous avez besoin de quelque chose de spécifique ? Discutez avec votre chargé de compte, ${accountManagerDisplayName}.`,
Expand Down Expand Up @@ -8275,6 +8276,12 @@ Voici un *reçu test* pour vous montrer comment cela fonctionne :`,
upcomingTodos: 'Les tâches à venir apparaîtront ici.',
},
},
timeSensitiveSection: {
title: 'Urgent',
cta: 'Demande',
offer50off: {title: 'Obtenez 50 % de réduction sur votre première année !', subtitle: ({formattedTime}: {formattedTime: string}) => `${formattedTime} restant`},
offer25off: {title: 'Obtenez 25 % de réduction sur votre première année !', subtitle: ({days}: {days: number}) => `${days} ${days === 1 ? 'jour' : 'jours'} restants`},
},
},
};
// IMPORTANT: This line is manually replaced in generate translation files by scripts/generateTranslations.ts,
Expand Down
7 changes: 7 additions & 0 deletions src/languages/it.ts
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,7 @@ const translations: TranslationDeepObject<typeof en> = {
address: 'Indirizzo',
hourAbbreviation: 'h',
minuteAbbreviation: 'm',
secondAbbreviation: 's',
skip: 'Salta',
chatWithAccountManager: (accountManagerDisplayName: string) => `Hai bisogno di qualcosa in particolare? Chatta con il tuo account manager, ${accountManagerDisplayName}.`,
chatNow: 'Chatta ora',
Expand Down Expand Up @@ -8255,6 +8256,12 @@ Ecco una *ricevuta di prova* per mostrarti come funziona:`,
upcomingTodos: 'Le prossime attività da fare verranno visualizzate qui.',
},
},
timeSensitiveSection: {
title: 'Urgente',
cta: 'Richiesta',
offer50off: {title: 'Ottieni il 50% di sconto sul tuo primo anno!', subtitle: ({formattedTime}: {formattedTime: string}) => `${formattedTime} rimanenti`},
offer25off: {title: 'Ottieni il 25% di sconto sul tuo primo anno!', subtitle: ({days}: {days: number}) => `${days} ${days === 1 ? 'giorno' : 'giorni'} rimanenti`},
},
},
};
// IMPORTANT: This line is manually replaced in generate translation files by scripts/generateTranslations.ts,
Expand Down
7 changes: 7 additions & 0 deletions src/languages/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,7 @@ const translations: TranslationDeepObject<typeof en> = {
address: '住所',
hourAbbreviation: '時間',
minuteAbbreviation: 'm',
secondAbbreviation: '秒',
skip: 'スキップ',
chatWithAccountManager: (accountManagerDisplayName: string) => `何か特定のご要望がありますか?アカウントマネージャーの${accountManagerDisplayName}とチャットしましょう。`,
chatNow: '今すぐチャット',
Expand Down Expand Up @@ -8167,6 +8168,12 @@ Expensify の使い方をお見せするための*テストレシート*がこ
upcomingTodos: '今後のTo-doがここに表示されます。',
},
},
timeSensitiveSection: {
title: '至急',
cta: '申請',
offer50off: {title: '初年度が50%オフ!', subtitle: ({formattedTime}: {formattedTime: string}) => `残り${formattedTime}`},
offer25off: {title: '初年度が25%オフ!', subtitle: ({days}: {days: number}) => `残り ${days} ${days === 1 ? '日' : '日'}`},
},
},
};
// IMPORTANT: This line is manually replaced in generate translation files by scripts/generateTranslations.ts,
Expand Down
7 changes: 7 additions & 0 deletions src/languages/nl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,7 @@ const translations: TranslationDeepObject<typeof en> = {
address: 'Adres',
hourAbbreviation: 'h',
minuteAbbreviation: 'm',
secondAbbreviation: 's',
skip: 'Overslaan',
chatWithAccountManager: (accountManagerDisplayName: string) => `Iets specifieks nodig? Chat met je accountmanager, ${accountManagerDisplayName}.`,
chatNow: 'Nu chatten',
Expand Down Expand Up @@ -8231,6 +8232,12 @@ Hier is een *testbon* om je te laten zien hoe het werkt:`,
upcomingTodos: 'Aankomende taken verschijnen hier.',
},
},
timeSensitiveSection: {
title: 'Tijdgevoelig',
cta: 'Declaratie',
offer50off: {title: 'Krijg 50% korting op je eerste jaar!', subtitle: ({formattedTime}: {formattedTime: string}) => `${formattedTime} resterend`},
offer25off: {title: 'Krijg 25% korting op je eerste jaar!', subtitle: ({days}: {days: number}) => `Nog ${days} ${days === 1 ? 'dag' : 'dagen'} resterend`},
},
},
};
// IMPORTANT: This line is manually replaced in generate translation files by scripts/generateTranslations.ts,
Expand Down
7 changes: 7 additions & 0 deletions src/languages/pl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,7 @@ const translations: TranslationDeepObject<typeof en> = {
address: 'Adres',
hourAbbreviation: 'h',
minuteAbbreviation: 'm',
secondAbbreviation: 's',
skip: 'Pomiń',
chatWithAccountManager: (accountManagerDisplayName: string) => `Potrzebujesz czegoś konkretnego? Porozmawiaj ze swoim opiekunem konta, ${accountManagerDisplayName}.`,
chatNow: 'Czat teraz',
Expand Down Expand Up @@ -8216,6 +8217,12 @@ Oto *paragon testowy*, który pokazuje, jak to działa:`,
upcomingTodos: 'Nadchodzące zadania do wykonania pojawią się tutaj.',
},
},
timeSensitiveSection: {
title: 'Pilne',
cta: 'Roszczenie',
offer50off: {title: 'Uzyskaj 50% zniżki na pierwszy rok!', subtitle: ({formattedTime}: {formattedTime: string}) => `Pozostało: ${formattedTime}`},
offer25off: {title: 'Uzyskaj 25% zniżki na pierwszy rok!', subtitle: ({days}: {days: number}) => `Pozostało ${days} ${days === 1 ? 'dzień' : 'dni'}`},
},
},
};
// IMPORTANT: This line is manually replaced in generate translation files by scripts/generateTranslations.ts,
Expand Down
7 changes: 7 additions & 0 deletions src/languages/pt-BR.ts
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,7 @@ const translations: TranslationDeepObject<typeof en> = {
address: 'Endereço',
hourAbbreviation: 'h',
minuteAbbreviation: 'm',
secondAbbreviation: 's',
skip: 'Pular',
chatWithAccountManager: (accountManagerDisplayName: string) => `Precisa de algo específico? Converse com seu gerente de conta, ${accountManagerDisplayName}.`,
chatNow: 'Conversar agora',
Expand Down Expand Up @@ -8225,6 +8226,12 @@ Aqui está um *recibo de teste* para mostrar como funciona:`,
upcomingTodos: 'Próximas tarefas aparecerão aqui.',
},
},
timeSensitiveSection: {
title: 'Urgente',
cta: 'Solicitação',
offer50off: {title: 'Ganhe 50% de desconto no seu primeiro ano!', subtitle: ({formattedTime}: {formattedTime: string}) => `${formattedTime} restante`},
offer25off: {title: 'Ganhe 25% de desconto no seu primeiro ano!', subtitle: ({days}: {days: number}) => `${days} ${days === 1 ? 'dia' : 'dias'} restantes`},
},
},
};
// IMPORTANT: This line is manually replaced in generate translation files by scripts/generateTranslations.ts,
Expand Down
7 changes: 7 additions & 0 deletions src/languages/zh-hans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,7 @@ const translations: TranslationDeepObject<typeof en> = {
address: '地址',
hourAbbreviation: 'h',
minuteAbbreviation: 'm',
secondAbbreviation: 's',
skip: '跳过',
chatWithAccountManager: (accountManagerDisplayName: string) => `需要特定帮助?请与您的客户经理 ${accountManagerDisplayName} 聊天。`,
chatNow: '立即聊天',
Expand Down Expand Up @@ -7985,6 +7986,12 @@ ${reportName}
begin: '开始',
emptyStateMessages: {nicelyDone: '做得很好', keepAnEyeOut: '敬请关注接下来的更新!', allCaughtUp: '你已经全部看完了', upcomingTodos: '即将进行的待办事项会显示在此处。'},
},
timeSensitiveSection: {
title: '时间敏感',
cta: '报销申请',
offer50off: {title: '首年立享五折优惠!', subtitle: ({formattedTime}: {formattedTime: string}) => `剩余 ${formattedTime}`},
offer25off: {title: '首次年度订阅立享 25% 折扣!', subtitle: ({days}: {days: number}) => `剩余 ${days} ${days === 1 ? '天' : '天'}`},
},
},
};
// IMPORTANT: This line is manually replaced in generate translation files by scripts/generateTranslations.ts,
Expand Down
13 changes: 13 additions & 0 deletions src/libs/DateUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -833,6 +833,18 @@ function getFormattedDuration(translateParam: LocaleContextProps['translate'], d
return `${hours ? `${hours}${translateParam('common.hourAbbreviation')} ` : ''}${minutes}${translateParam('common.minuteAbbreviation')}`;
}

const TIME_UNIT_PADDING = 2; // Pad time units to 2 digits (e.g., "09" instead of "9")

/**
* Formats a countdown timer with hours, minutes, and seconds (e.g., "23h 59m 59s").
*/
function formatCountdownTimer(translateParam: LocaleContextProps['translate'], hours: number, minutes: number, seconds: number): string {
const paddedMinutes = minutes.toString().padStart(TIME_UNIT_PADDING, '0');
const paddedSeconds = seconds.toString().padStart(TIME_UNIT_PADDING, '0');

return `${hours}${translateParam('common.hourAbbreviation')} ${paddedMinutes}${translateParam('common.minuteAbbreviation')} ${paddedSeconds}${translateParam('common.secondAbbreviation')}`;
}

function doesDateBelongToAPastYear(date: string): boolean {
const transactionYear = new Date(date).getFullYear();
return transactionYear !== new Date().getFullYear();
Expand Down Expand Up @@ -1048,6 +1060,7 @@ const DateUtils = {
isValidDateString,
getFormattedDurationBetweenDates,
getFormattedDuration,
formatCountdownTimer,
isFutureDay,
getFormattedDateRangeForPerDiem,
getFormattedSplitDateRange,
Expand Down
3 changes: 3 additions & 0 deletions src/pages/home/HomePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import usePreloadFullScreenNavigators from '@libs/Navigation/AppNavigator/usePre
import AnnouncementSection from './AnnouncementSection';
import DiscoverSection from './DiscoverSection';
import ForYouSection from './ForYouSection';
import TimeSensitiveSection from './TimeSensitiveSection';

function HomePage() {
const {shouldUseNarrowLayout} = useResponsiveLayout();
Expand Down Expand Up @@ -53,7 +54,9 @@ function HomePage() {
addBottomSafeAreaPadding
>
<View style={styles.homePageMainLayout(shouldUseNarrowLayout)}>
{/* Widgets handle their own visibility and may return null to avoid duplicating visibility logic here */}
<View style={styles.homePageLeftColumn(shouldUseNarrowLayout)}>
<TimeSensitiveSection />
<ForYouSection />
<DiscoverSection />
</View>
Expand Down
59 changes: 59 additions & 0 deletions src/pages/home/TimeSensitiveSection/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import React from 'react';
import {View} from 'react-native';
import WidgetContainer from '@components/WidgetContainer';
import useHasTeam2025Pricing from '@hooks/useHasTeam2025Pricing';
import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
import useLocalize from '@hooks/useLocalize';
import useOnyx from '@hooks/useOnyx';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useSubscriptionPlan from '@hooks/useSubscriptionPlan';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import {getEarlyDiscountInfo, shouldShowDiscountBanner} from '@libs/SubscriptionUtils';
import variables from '@styles/variables';
import ONYXKEYS from '@src/ONYXKEYS';
import Offer25off from './items/Offer25off';
import Offer50off from './items/Offer50off';

function TimeSensitiveSection() {
const styles = useThemeStyles();
const theme = useTheme();
const {translate} = useLocalize();
const icons = useMemoizedLazyExpensifyIcons(['Stopwatch'] as const);
const {shouldUseNarrowLayout} = useResponsiveLayout();
const [firstDayFreeTrial] = useOnyx(ONYXKEYS.NVP_FIRST_DAY_FREE_TRIAL, {canBeMissing: true});
const [lastDayFreeTrial] = useOnyx(ONYXKEYS.NVP_LAST_DAY_FREE_TRIAL, {canBeMissing: true});
const [userBillingFundID] = useOnyx(ONYXKEYS.NVP_BILLING_FUND_ID, {canBeMissing: true});
const hasTeam2025Pricing = useHasTeam2025Pricing();
const subscriptionPlan = useSubscriptionPlan();

// Use the same logic as the subscription page to determine if discount banner should be shown
const shouldShowDiscount = shouldShowDiscountBanner(hasTeam2025Pricing, subscriptionPlan, firstDayFreeTrial, lastDayFreeTrial, userBillingFundID);
const discountInfo = getEarlyDiscountInfo(firstDayFreeTrial);

if (!shouldShowDiscount || !discountInfo) {
return null;
Comment on lines +30 to +35

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Don’t gate the 50% offer on missing billing card

shouldShowDiscountBanner returns false when userBillingFundID is set (see doesUserHavePaymentCardAdded in SubscriptionUtils), so this section never renders for users who already added a billing card. The PR description says only the 25% offer requires “no billing card,” so users who add a card within the first 24 hours would incorrectly miss the 50% offer. Consider using a visibility check that only applies the “no card” requirement to the 25% path.

Useful? React with 👍 / 👎.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The desceription will be udpated

}

// Determine which offer to show based on discount type (they are mutually exclusive)
const shouldShow50off = discountInfo.discountType === 50;
const shouldShow25off = discountInfo.discountType === 25;
Comment on lines +32 to +40

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Recompute discount type so offers switch after 24h

Because discountInfo is computed once per render and shouldShow50off/shouldShow25off are derived from it, the parent never re-evaluates which offer to show as time passes. If a user opens Home during the first 24 hours and keeps it open past the cutoff, Offer50off keeps rendering (its own interval only updates the countdown text) and the 25% offer never appears. This deviates from the “50% only within first 24h” logic; consider moving the timer/discountInfo state to the section or switching on discountInfo.discountType inside the 50% item so it unmounts once the first day elapses.

Useful? React with 👍 / 👎.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is edge case we do not have to worry about


return (
<WidgetContainer
icon={icons.Stopwatch}
iconWidth={variables.iconSizeNormal}
iconHeight={variables.iconSizeNormal}
iconFill={theme.danger}
title={translate('homePage.timeSensitiveSection.title')}
titleColor={theme.danger}
>
<View style={styles.getForYouSectionContainerStyle(shouldUseNarrowLayout)}>
{shouldShow50off && <Offer50off firstDayFreeTrial={firstDayFreeTrial} />}
{shouldShow25off && <Offer25off days={discountInfo.days} />}
</View>
</WidgetContainer>
);
}

export default TimeSensitiveSection;
Loading
Loading