From 4788d0d2f5458b595906b0af41b1343c7a744925 Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Thu, 29 Jan 2026 11:24:09 -0800 Subject: [PATCH 1/8] Implement time sensitive section and offer items --- src/components/WidgetContainer.tsx | 8 ++- src/languages/en.ts | 13 ++++ src/libs/DateUtils.ts | 11 ++++ src/pages/home/DiscoverSection.tsx | 6 ++ src/pages/home/HomePage.tsx | 9 ++- src/pages/home/TimeSensitiveSection/index.tsx | 59 +++++++++++++++++++ .../TimeSensitiveSection/items/Offer25off.tsx | 32 ++++++++++ .../TimeSensitiveSection/items/Offer50off.tsx | 50 ++++++++++++++++ 8 files changed, 181 insertions(+), 7 deletions(-) create mode 100644 src/pages/home/TimeSensitiveSection/index.tsx create mode 100644 src/pages/home/TimeSensitiveSection/items/Offer25off.tsx create mode 100644 src/pages/home/TimeSensitiveSection/items/Offer50off.tsx diff --git a/src/components/WidgetContainer.tsx b/src/components/WidgetContainer.tsx index f79bf5c6a1d89..3de74d3d24c69 100644 --- a/src/components/WidgetContainer.tsx +++ b/src/components/WidgetContainer.tsx @@ -25,11 +25,14 @@ 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; }; -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, iconFill}: WidgetContainerProps) { const styles = useThemeStyles(); const theme = useTheme(); const {shouldUseNarrowLayout} = useResponsiveLayout(); @@ -38,11 +41,12 @@ function WidgetContainer({children, icon, title, titleColor, iconWidth = variabl {!!icon && ( - + )} diff --git a/src/languages/en.ts b/src/languages/en.ts index 93b0bca1a217b..c150bbf2fc70c 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -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', @@ -991,6 +992,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', diff --git a/src/libs/DateUtils.ts b/src/libs/DateUtils.ts index 9a512833eeaac..0bf309dcb30d8 100644 --- a/src/libs/DateUtils.ts +++ b/src/libs/DateUtils.ts @@ -833,6 +833,16 @@ function getFormattedDuration(translateParam: LocaleContextProps['translate'], d return `${hours ? `${hours}${translateParam('common.hourAbbreviation')} ` : ''}${minutes}${translateParam('common.minuteAbbreviation')}`; } +/** + * 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(2, '0'); + const paddedSeconds = seconds.toString().padStart(2, '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(); @@ -1026,6 +1036,7 @@ const DateUtils = { isValidDateString, getFormattedDurationBetweenDates, getFormattedDuration, + formatCountdownTimer, isFutureDay, getFormattedDateRangeForPerDiem, getFormattedSplitDateRange, diff --git a/src/pages/home/DiscoverSection.tsx b/src/pages/home/DiscoverSection.tsx index 7ffd2c09d7149..4fc0fe9da87db 100644 --- a/src/pages/home/DiscoverSection.tsx +++ b/src/pages/home/DiscoverSection.tsx @@ -12,6 +12,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; 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; @@ -20,12 +21,17 @@ function DiscoverSection() { const {shouldUseNarrowLayout} = useResponsiveLayout(); const isCurrentUserPolicyAdmin = useIsPaidPolicyAdmin(); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {canBeMissing: true}); + const [isSelfTourViewed = false] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector, canBeMissing: true}); const styles = useThemeStyles(); const handlePress = () => { Linking.openURL(getTestDriveURL(shouldUseNarrowLayout, introSelected, isCurrentUserPolicyAdmin)); }; + if (isSelfTourViewed) { + return null; + } + return ( + {/* Widgets handle their own visibility and may return null to avoid duplicating visibility logic here */} + - {!isSelfTourViewed && } + diff --git a/src/pages/home/TimeSensitiveSection/index.tsx b/src/pages/home/TimeSensitiveSection/index.tsx new file mode 100644 index 0000000000000..aeb684b453fb5 --- /dev/null +++ b/src/pages/home/TimeSensitiveSection/index.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import {View} from 'react-native'; +import {Stopwatch} from '@components/Icon/Expensicons'; +import WidgetContainer from '@components/WidgetContainer'; +import useHasTeam2025Pricing from '@hooks/useHasTeam2025Pricing'; +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 {doesUserHavePaymentCardAdded, getEarlyDiscountInfo} from '@libs/SubscriptionUtils'; +import variables from '@styles/variables'; +import CONST from '@src/CONST'; +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 {shouldUseNarrowLayout} = useResponsiveLayout(); + const [firstDayFreeTrial] = useOnyx(ONYXKEYS.NVP_FIRST_DAY_FREE_TRIAL, {canBeMissing: true}); + const [userBillingFundID] = useOnyx(ONYXKEYS.NVP_BILLING_FUND_ID, {canBeMissing: true}); + const hasTeam2025Pricing = useHasTeam2025Pricing(); + const subscriptionPlan = useSubscriptionPlan(); + + const discountInfo = getEarlyDiscountInfo(firstDayFreeTrial); + + // Don't show offers for Team plan with 2025 pricing + const isTeamWithNew2025Pricing = hasTeam2025Pricing && subscriptionPlan === CONST.POLICY.TYPE.TEAM; + + // Determine which offer to show (they are mutually exclusive) + const shouldShow50off = discountInfo?.discountType === 50 && !isTeamWithNew2025Pricing; + const shouldShow25off = discountInfo?.discountType === 25 && !doesUserHavePaymentCardAdded(userBillingFundID) && !isTeamWithNew2025Pricing; + + if (!discountInfo || (!shouldShow50off && !shouldShow25off)) { + return null; + } + + return ( + + + {shouldShow50off && } + {shouldShow25off && } + + + ); +} + +export default TimeSensitiveSection; diff --git a/src/pages/home/TimeSensitiveSection/items/Offer25off.tsx b/src/pages/home/TimeSensitiveSection/items/Offer25off.tsx new file mode 100644 index 0000000000000..5e70ed7d1a314 --- /dev/null +++ b/src/pages/home/TimeSensitiveSection/items/Offer25off.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import TreasureChest from '@assets/images/treasure-chest.svg'; +import BaseWidgetItem from '@components/BaseWidgetItem'; +import useLocalize from '@hooks/useLocalize'; +import useTheme from '@hooks/useTheme'; +import Navigation from '@libs/Navigation/Navigation'; +import ROUTES from '@src/ROUTES'; + +type Offer25offProps = { + days: number; +}; + +function Offer25off({days}: Offer25offProps) { + const theme = useTheme(); + const {translate} = useLocalize(); + + const subtitle = translate('homePage.timeSensitiveSection.offer25off.subtitle', {days}); + + return ( + Navigation.navigate(ROUTES.SETTINGS_SUBSCRIPTION.getRoute(ROUTES.HOME))} + /> + ); +} + +export default Offer25off; diff --git a/src/pages/home/TimeSensitiveSection/items/Offer50off.tsx b/src/pages/home/TimeSensitiveSection/items/Offer50off.tsx new file mode 100644 index 0000000000000..f03c5dcd01c72 --- /dev/null +++ b/src/pages/home/TimeSensitiveSection/items/Offer50off.tsx @@ -0,0 +1,50 @@ +import React, {useEffect, useState} from 'react'; +import TreasureChest from '@assets/images/treasure-chest.svg'; +import BaseWidgetItem from '@components/BaseWidgetItem'; +import useLocalize from '@hooks/useLocalize'; +import useTheme from '@hooks/useTheme'; +import DateUtils from '@libs/DateUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import {getEarlyDiscountInfo} from '@libs/SubscriptionUtils'; +import ROUTES from '@src/ROUTES'; + +type Offer50offProps = { + firstDayFreeTrial: string | undefined; +}; + +function Offer50off({firstDayFreeTrial}: Offer50offProps) { + const theme = useTheme(); + const {translate} = useLocalize(); + + const [discountInfo, setDiscountInfo] = useState(() => getEarlyDiscountInfo(firstDayFreeTrial)); + + useEffect(() => { + const intervalID = setInterval(() => { + setDiscountInfo(getEarlyDiscountInfo(firstDayFreeTrial)); + }, 1000); + + return () => clearInterval(intervalID); + }, [firstDayFreeTrial]); + + if (!discountInfo) { + return null; + } + + const {hours, minutes, seconds} = discountInfo; + const formattedTime = DateUtils.formatCountdownTimer(translate, hours, minutes, seconds); + const subtitle = translate('homePage.timeSensitiveSection.offer50off.subtitle', {formattedTime}); + + return ( + Navigation.navigate(ROUTES.SETTINGS_SUBSCRIPTION.getRoute(ROUTES.HOME))} + /> + ); +} + +export default Offer50off; From 2cf3edfebd14af44431b66ec2dc945823688ded2 Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Thu, 29 Jan 2026 13:13:42 -0800 Subject: [PATCH 2/8] Add translations --- src/languages/es.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/languages/es.ts b/src/languages/es.ts index 39df38aac45d5..d13f5a87ab9b3 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -322,6 +322,7 @@ const translations: TranslationDeepObject = { 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', @@ -736,6 +737,18 @@ const translations: TranslationDeepObject = { }, homePage: { forYou: 'Para ti', + timeSensitiveSection: { + title: 'Tiempo limitado', + 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', From ad494594ae5487d02e9e5caedf2ef008a19840d1 Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Thu, 29 Jan 2026 15:22:48 -0800 Subject: [PATCH 3/8] Apply chanage requests --- src/CONST/index.ts | 1 + src/components/WidgetContainer.tsx | 4 ++-- src/libs/DateUtils.ts | 6 ++++-- src/pages/home/TimeSensitiveSection/index.tsx | 5 +++-- .../TimeSensitiveSection/items/Offer25off.tsx | 5 +++-- .../TimeSensitiveSection/items/Offer50off.tsx | 8 +++++--- src/styles/index.ts | 15 +++++++++++++++ 7 files changed, 33 insertions(+), 11 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 807652d22c468..cdebcc6206fa9 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -4088,6 +4088,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', diff --git a/src/components/WidgetContainer.tsx b/src/components/WidgetContainer.tsx index 3de74d3d24c69..9dcd638fb3df2 100644 --- a/src/components/WidgetContainer.tsx +++ b/src/components/WidgetContainer.tsx @@ -39,9 +39,9 @@ function WidgetContainer({children, icon, title, titleColor, iconWidth = variabl return ( - + {!!icon && ( - + getEarlyDiscountInfo(firstDayFreeTrial)); useEffect(() => { const intervalID = setInterval(() => { setDiscountInfo(getEarlyDiscountInfo(firstDayFreeTrial)); - }, 1000); + }, CONST.MILLISECONDS_PER_SECOND); return () => clearInterval(intervalID); }, [firstDayFreeTrial]); @@ -36,7 +38,7 @@ function Offer50off({firstDayFreeTrial}: Offer50offProps) { return ( color, }) satisfies TextStyle, + getWidgetContainerHeaderStyle: (shouldUseNarrowLayout: boolean) => + ({ + flexDirection: 'row', + alignItems: 'flex-start', + marginBottom: 20, + marginHorizontal: shouldUseNarrowLayout ? 20 : 32, + marginTop: shouldUseNarrowLayout ? 20 : 32, + }) satisfies ViewStyle, + + widgetContainerIconWrapper: { + flexGrow: 0, + flexShrink: 0, + marginRight: 11, + }, + getWidgetItemIconContainerStyle: (backgroundColor: string) => ({ alignItems: 'center', From 32638c13bb2e7deaa9c3cbec87977cf38739b23b Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Thu, 29 Jan 2026 15:32:23 -0800 Subject: [PATCH 4/8] Fix es translation --- src/languages/es.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/languages/es.ts b/src/languages/es.ts index d13f5a87ab9b3..3bc8fee854c26 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -738,7 +738,7 @@ const translations: TranslationDeepObject = { homePage: { forYou: 'Para ti', timeSensitiveSection: { - title: 'Tiempo limitado', + title: 'Requiere atención inmediata', cta: 'Reclamar', offer50off: { title: '¡Obtén 50% de descuento en tu primer año!', From fdcfd69c54f433905413f2b81f4494fd3834869e Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Thu, 29 Jan 2026 15:36:34 -0800 Subject: [PATCH 5/8] Add other translations --- src/languages/de.ts | 7 +++++++ src/languages/fr.ts | 7 +++++++ src/languages/it.ts | 7 +++++++ src/languages/ja.ts | 7 +++++++ src/languages/nl.ts | 7 +++++++ src/languages/pl.ts | 7 +++++++ src/languages/pt-BR.ts | 7 +++++++ src/languages/zh-hans.ts | 7 +++++++ 8 files changed, 56 insertions(+) diff --git a/src/languages/de.ts b/src/languages/de.ts index b19bc0811b91a..cc241c33a2d5f 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -642,6 +642,7 @@ const translations: TranslationDeepObject = { month: 'Monat', home: 'Startseite', week: 'Woche', + secondAbbreviation: 's', }, supportalNoAccess: { title: 'Nicht so schnell', @@ -8265,6 +8266,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, diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 07c89ef16cbd4..7bec7e3d26867 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -644,6 +644,7 @@ const translations: TranslationDeepObject = { month: 'Mois', home: 'Accueil', week: 'Semaine', + secondAbbreviation: 's', }, supportalNoAccess: { title: 'Pas si vite', @@ -8271,6 +8272,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, diff --git a/src/languages/it.ts b/src/languages/it.ts index d3f3eaca7ee83..7b0c1b9dee039 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -643,6 +643,7 @@ const translations: TranslationDeepObject = { month: 'Mese', home: 'Home', week: 'Settimana', + secondAbbreviation: 's', }, supportalNoAccess: { title: 'Non così in fretta', @@ -8251,6 +8252,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, diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 188d04a7d93b7..8bf725bfde30f 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -642,6 +642,7 @@ const translations: TranslationDeepObject = { month: '月', home: 'ホーム', week: '週', + secondAbbreviation: '秒', }, supportalNoAccess: { title: 'ちょっと待ってください', @@ -8163,6 +8164,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, diff --git a/src/languages/nl.ts b/src/languages/nl.ts index f1ff6c99a0786..ba01538c67b4d 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -643,6 +643,7 @@ const translations: TranslationDeepObject = { month: 'Maand', home: 'Start', week: 'Week', + secondAbbreviation: 's', }, supportalNoAccess: { title: 'Niet zo snel', @@ -8227,6 +8228,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, diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 554e9d2ad3757..5a6082d9fd5a5 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -643,6 +643,7 @@ const translations: TranslationDeepObject = { month: 'Miesiąc', home: 'Strona główna', week: 'Tydzień', + secondAbbreviation: 's', }, supportalNoAccess: { title: 'Nie tak szybko', @@ -8212,6 +8213,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, diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index a558a4a36fe0d..3273d43acd52c 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -642,6 +642,7 @@ const translations: TranslationDeepObject = { month: 'Mês', home: 'Início', week: 'Semana', + secondAbbreviation: 's', }, supportalNoAccess: { title: 'Não tão rápido', @@ -8221,6 +8222,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, diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index b9c9b59b18a80..f983faae72014 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -639,6 +639,7 @@ const translations: TranslationDeepObject = { month: '月', home: '首页', week: '周', + secondAbbreviation: 's', }, supportalNoAccess: { title: '先别急', @@ -7981,6 +7982,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, From f82b6f7f38d41c8332555d644614bfa8e4b1aba1 Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Thu, 29 Jan 2026 15:50:54 -0800 Subject: [PATCH 6/8] Reuse logic from subscription page --- src/pages/home/TimeSensitiveSection/index.tsx | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/src/pages/home/TimeSensitiveSection/index.tsx b/src/pages/home/TimeSensitiveSection/index.tsx index 026769f7075c5..9eccd9d590be7 100644 --- a/src/pages/home/TimeSensitiveSection/index.tsx +++ b/src/pages/home/TimeSensitiveSection/index.tsx @@ -9,9 +9,8 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSubscriptionPlan from '@hooks/useSubscriptionPlan'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import {doesUserHavePaymentCardAdded, getEarlyDiscountInfo} from '@libs/SubscriptionUtils'; +import {getEarlyDiscountInfo, shouldShowDiscountBanner} from '@libs/SubscriptionUtils'; import variables from '@styles/variables'; -import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import Offer25off from './items/Offer25off'; import Offer50off from './items/Offer50off'; @@ -23,23 +22,23 @@ function TimeSensitiveSection() { 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); - // Don't show offers for Team plan with 2025 pricing - const isTeamWithNew2025Pricing = hasTeam2025Pricing && subscriptionPlan === CONST.POLICY.TYPE.TEAM; - - // Determine which offer to show (they are mutually exclusive) - const shouldShow50off = discountInfo?.discountType === 50 && !isTeamWithNew2025Pricing; - const shouldShow25off = discountInfo?.discountType === 25 && !doesUserHavePaymentCardAdded(userBillingFundID) && !isTeamWithNew2025Pricing; - - if (!discountInfo || (!shouldShow50off && !shouldShow25off)) { + if (!shouldShowDiscount || !discountInfo) { return null; } + // Determine which offer to show based on discount type (they are mutually exclusive) + const shouldShow50off = discountInfo.discountType === 50; + const shouldShow25off = discountInfo.discountType === 25; + return ( Date: Thu, 29 Jan 2026 16:05:33 -0800 Subject: [PATCH 7/8] Fix secondAbbreviation translation --- src/languages/de.ts | 2 +- src/languages/fr.ts | 2 +- src/languages/it.ts | 2 +- src/languages/ja.ts | 2 +- src/languages/nl.ts | 2 +- src/languages/pl.ts | 2 +- src/languages/pt-BR.ts | 2 +- src/languages/zh-hans.ts | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index 41dce4a7607e0..bf7553fc6d39c 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -566,6 +566,7 @@ const translations: TranslationDeepObject = { 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', @@ -642,7 +643,6 @@ const translations: TranslationDeepObject = { month: 'Monat', home: 'Startseite', week: 'Woche', - secondAbbreviation: 's', year: 'Jahr', quarter: 'Quartal', }, diff --git a/src/languages/fr.ts b/src/languages/fr.ts index c2fc6e48be133..f1b52b5665823 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -567,6 +567,7 @@ const translations: TranslationDeepObject = { 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}.`, @@ -644,7 +645,6 @@ const translations: TranslationDeepObject = { month: 'Mois', home: 'Accueil', week: 'Semaine', - secondAbbreviation: 's', year: 'Année', quarter: 'Trimestre', }, diff --git a/src/languages/it.ts b/src/languages/it.ts index f1e2830ab404f..e239aa609951d 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -567,6 +567,7 @@ const translations: TranslationDeepObject = { 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', @@ -643,7 +644,6 @@ const translations: TranslationDeepObject = { month: 'Mese', home: 'Home', week: 'Settimana', - secondAbbreviation: 's', year: 'Anno', quarter: 'Trimestre', }, diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 0eeac2b8d9070..8f7c9dbb1a8d2 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -567,6 +567,7 @@ const translations: TranslationDeepObject = { address: '住所', hourAbbreviation: '時間', minuteAbbreviation: 'm', + secondAbbreviation: '秒', skip: 'スキップ', chatWithAccountManager: (accountManagerDisplayName: string) => `何か特定のご要望がありますか?アカウントマネージャーの${accountManagerDisplayName}とチャットしましょう。`, chatNow: '今すぐチャット', @@ -642,7 +643,6 @@ const translations: TranslationDeepObject = { month: '月', home: 'ホーム', week: '週', - secondAbbreviation: '秒', year: '年', quarter: '四半期', }, diff --git a/src/languages/nl.ts b/src/languages/nl.ts index aa3e20b800175..c9bcb4a348fa7 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -567,6 +567,7 @@ const translations: TranslationDeepObject = { address: 'Adres', hourAbbreviation: 'h', minuteAbbreviation: 'm', + secondAbbreviation: 's', skip: 'Overslaan', chatWithAccountManager: (accountManagerDisplayName: string) => `Iets specifieks nodig? Chat met je accountmanager, ${accountManagerDisplayName}.`, chatNow: 'Nu chatten', @@ -643,7 +644,6 @@ const translations: TranslationDeepObject = { month: 'Maand', home: 'Start', week: 'Week', - secondAbbreviation: 's', year: 'Jaar', quarter: 'Kwartaal', }, diff --git a/src/languages/pl.ts b/src/languages/pl.ts index e8edc55538e6e..e34eb9a2b392a 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -567,6 +567,7 @@ const translations: TranslationDeepObject = { 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', @@ -643,7 +644,6 @@ const translations: TranslationDeepObject = { month: 'Miesiąc', home: 'Strona główna', week: 'Tydzień', - secondAbbreviation: 's', year: 'Rok', quarter: 'Kwartał', }, diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index b949c02a8e140..e7e8885fcd4f5 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -567,6 +567,7 @@ const translations: TranslationDeepObject = { 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', @@ -642,7 +643,6 @@ const translations: TranslationDeepObject = { month: 'Mês', home: 'Início', week: 'Semana', - secondAbbreviation: 's', year: 'Ano', quarter: 'Trimestre', }, diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 12bf34b30d824..dec2395142506 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -567,6 +567,7 @@ const translations: TranslationDeepObject = { address: '地址', hourAbbreviation: 'h', minuteAbbreviation: 'm', + secondAbbreviation: 's', skip: '跳过', chatWithAccountManager: (accountManagerDisplayName: string) => `需要特定帮助?请与您的客户经理 ${accountManagerDisplayName} 聊天。`, chatNow: '立即聊天', @@ -639,7 +640,6 @@ const translations: TranslationDeepObject = { month: '月', home: '首页', week: '周', - secondAbbreviation: 's', year: '年', quarter: '季度', }, From 54d5c5ddb04a5eec10d7ba4b5177e154509cf2aa Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Thu, 29 Jan 2026 16:22:30 -0800 Subject: [PATCH 8/8] Add unit tests --- tests/unit/DateUtilsTest.ts | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/tests/unit/DateUtilsTest.ts b/tests/unit/DateUtilsTest.ts index 44b444f89fff9..a6c5374f6a8db 100644 --- a/tests/unit/DateUtilsTest.ts +++ b/tests/unit/DateUtilsTest.ts @@ -2,6 +2,7 @@ import {addDays, addMinutes, endOfDay, format, set, setHours, setMinutes, subDays, subHours, subMinutes, subSeconds} from 'date-fns'; import {fromZonedTime, toZonedTime, format as tzFormat} from 'date-fns-tz'; import Onyx from 'react-native-onyx'; +import type {LocaleContextProps} from '@components/LocaleContextProvider'; import DateUtils from '@libs/DateUtils'; import {translate} from '@libs/Localize'; import CONST from '@src/CONST'; @@ -482,4 +483,38 @@ describe('DateUtils', () => { expect(result).toContain('12 days'); }); }); + + describe('formatCountdownTimer', () => { + const mockTranslate: LocaleContextProps['translate'] = (path, ...params) => translate(LOCALE, path, ...params); + + it('should format hours, minutes, and seconds correctly', () => { + const result = DateUtils.formatCountdownTimer(mockTranslate, 5, 30, 45); + expect(result).toBe('5h 30m 45s'); + }); + + it('should pad single digit minutes with leading zero', () => { + const result = DateUtils.formatCountdownTimer(mockTranslate, 2, 5, 30); + expect(result).toBe('2h 05m 30s'); + }); + + it('should pad single digit seconds with leading zero', () => { + const result = DateUtils.formatCountdownTimer(mockTranslate, 1, 15, 8); + expect(result).toBe('1h 15m 08s'); + }); + + it('should pad both minutes and seconds with leading zeros', () => { + const result = DateUtils.formatCountdownTimer(mockTranslate, 0, 3, 7); + expect(result).toBe('0h 03m 07s'); + }); + + it('should handle zero values for all parameters', () => { + const result = DateUtils.formatCountdownTimer(mockTranslate, 0, 0, 0); + expect(result).toBe('0h 00m 00s'); + }); + + it('should handle large hour values', () => { + const result = DateUtils.formatCountdownTimer(mockTranslate, 23, 59, 59); + expect(result).toBe('23h 59m 59s'); + }); + }); });